diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Models/CodeActionExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Models/CodeActionExtensions.cs index 9295e5638ff..4c87ff07dbc 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Models/CodeActionExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Models/CodeActionExtensions.cs @@ -6,12 +6,19 @@ using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.Razor.Protocol; namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models; internal static class CodeActionExtensions { + // TODO: Use Constants once https://github.com/dotnet/roslyn/pull/81094 is available + private const string NestedCodeActionCommand = "roslyn.client.nestedCodeAction"; + private const string NestedCodeActionsProperty = "NestedCodeActions"; + private const string CodeActionPathProperty = "CodeActionPath"; + private const string FixAllFlavorsProperty = "FixAllFlavors"; + public static SumType AsVSCodeCommandOrCodeAction(this VSInternalCodeAction razorCodeAction, VSTextDocumentIdentifier textDocument, Uri? delegatedDocumentUri) { if (razorCodeAction.Data is null) @@ -53,15 +60,18 @@ public static RazorVSInternalCodeAction WrapResolvableCodeAction( RazorLanguageKind language = RazorLanguageKind.CSharp, bool isOnAllowList = true) { - var resolutionParams = new RazorCodeActionResolutionParams() + if (!TryHandleNestedCodeAction(razorCodeAction, context, action, language)) { - TextDocument = context.Request.TextDocument, - Action = action, - Language = language, - DelegatedDocumentUri = context.DelegatedDocumentUri, - Data = razorCodeAction.Data - }; - razorCodeAction.Data = JsonSerializer.SerializeToElement(resolutionParams); + var resolutionParams = new RazorCodeActionResolutionParams() + { + TextDocument = context.Request.TextDocument, + Action = action, + Language = language, + DelegatedDocumentUri = context.DelegatedDocumentUri, + Data = razorCodeAction.Data + }; + razorCodeAction.Data = JsonSerializer.SerializeToElement(resolutionParams); + } if (!isOnAllowList) { @@ -79,6 +89,60 @@ public static RazorVSInternalCodeAction WrapResolvableCodeAction( return razorCodeAction; } + private static bool TryHandleNestedCodeAction(RazorVSInternalCodeAction razorCodeAction, RazorCodeActionContext context, string action, RazorLanguageKind language) + { + if (language != RazorLanguageKind.CSharp || + razorCodeAction.Command is not { CommandIdentifier: NestedCodeActionCommand, Arguments: [JsonElement arg] }) + { + return false; + } + + // For nested code actions in VS Code, we want to not wrap the data from this code action with our context, + // but wrap all of the nested code actions in the first argument. That way, the custom command in the C# + // Extension will work (it expects Data to be unwrapped), and when it tries to resolve the children, they + // will come to us because they're wrapped, and we'll send them on to Roslyn. + // + // We extract each nested code action, wrap its data with our context, then copy across a couple of things + // from its data to our new wrapped data, and we're done. We end up with data that is an odd hybrid of Razor + // and Roslyn expectations, but thanks to the dynamic nature of JSON, it works out. + using var mappedNestedActions = new PooledArrayBuilder(); + var nestedCodeActions = arg.GetProperty(NestedCodeActionsProperty); + foreach (var nestedAction in nestedCodeActions.EnumerateArray()) + { + var nestedCodeAction = nestedAction.Deserialize(JsonHelpers.JsonSerializerOptions).AssumeNotNull(); + var resolutionParams = new RazorCodeActionResolutionParams() + { + TextDocument = context.Request.TextDocument, + Action = action, + Language = language, + DelegatedDocumentUri = context.DelegatedDocumentUri, + Data = nestedCodeAction.Data + }; + + // We have to set two extra properties that Roslyn requires for nested code actions, copied from it's data object + var newActionData = JsonSerializer.SerializeToNode(resolutionParams).AssumeNotNull(); + var nestedData = nestedAction.GetProperty("data"); + if (nestedData.TryGetProperty(CodeActionPathProperty, out var codeActionPath)) + { + newActionData[CodeActionPathProperty] = JsonSerializer.SerializeToNode(codeActionPath, JsonHelpers.JsonSerializerOptions); + } + + if (nestedData.TryGetProperty(FixAllFlavorsProperty, out var fixAllFlavors)) + { + newActionData[FixAllFlavorsProperty] = JsonSerializer.SerializeToNode(fixAllFlavors, JsonHelpers.JsonSerializerOptions); + } + + nestedCodeAction.Data = newActionData; + mappedNestedActions.Add(nestedCodeAction); + } + + // We can't update NestedCodeActions directly, because JsonElement is immutable, so we have to convert to a node + var newArg = JsonSerializer.SerializeToNode(arg, JsonHelpers.JsonSerializerOptions).AssumeNotNull(); + newArg.AsObject()[NestedCodeActionsProperty] = JsonSerializer.SerializeToNode(mappedNestedActions.ToArray(), JsonHelpers.JsonSerializerOptions); + razorCodeAction.Command.Arguments[0] = newArg; + return true; + } + private static VSInternalCodeAction WrapResolvableCodeAction( this VSInternalCodeAction razorCodeAction, RazorCodeActionContext context, diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CSharpCodeActionTests.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CSharpCodeActionTests.cs index 6638595248c..2c10263071d 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CSharpCodeActionTests.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CSharpCodeActionTests.cs @@ -78,7 +78,7 @@ @using System.Linq await VerifyCodeActionAsync(input, expected, RazorPredefinedCodeRefactoringProviderNames.UseExpressionBody); } - [Fact(Skip = "Roslyn code refactoring provider is not finding the expression")] + [Fact] public async Task IntroduceLocal() { var input = """ @@ -90,7 +90,7 @@ @using System.Linq { void M(string[] args) { - if ([|args.First()|].Length > 0) + if (args.First()[||].Length > 0) { } if (args.First().Length > 0) @@ -110,8 +110,8 @@ @using System.Linq { void M(string[] args) { - string v = args.First(); - if (v.Length > 0) + int length = args.First().Length; + if (length > 0) { } if (args.First().Length > 0) @@ -125,7 +125,7 @@ void M(string[] args) await VerifyCodeActionAsync(input, expected, RazorPredefinedCodeRefactoringProviderNames.IntroduceVariable); } - [Fact(Skip = "Roslyn code refactoring provider is not finding the expression")] + [Fact] public async Task IntroduceLocal_All() { var input = """ @@ -137,7 +137,7 @@ @using System.Linq { void M(string[] args) { - if ([|args.First()|].Length > 0) + if (args.First()[||].Length > 0) { } if (args.First().Length > 0) @@ -157,11 +157,11 @@ @using System.Linq { void M(string[] args) { - string v = args.First(); - if (v.Length > 0) + int length = args.First().Length; + if (length > 0) { } - if (v.Length > 0) + if (length > 0) { } } diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs index a853530e68c..82af8af4f46 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs @@ -6,6 +6,8 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; @@ -15,6 +17,7 @@ using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.CodeActions.Models; +using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Telemetry; using Microsoft.CodeAnalysis.Razor.Utilities; @@ -100,11 +103,19 @@ Could not find code action with name '{codeActionName}'. {string.Join(Environment.NewLine + " ", result.Select(e => ((RazorVSInternalCodeAction)e.Value!).Name))} """); + // In VS, child code actions use the children property, and are easy if (codeActionToRun.Children?.Length > 0) { codeActionToRun = codeActionToRun.Children[childActionIndex]; } + // In VS Code, the C# extension has some custom code to handle child code actions, which we mimic here + if (codeActionToRun.Command is { CommandIdentifier: "roslyn.client.nestedCodeAction", Arguments: [JsonObject data] }) + { + var nestedCodeAction = data["NestedCodeActions"].AssumeNotNull().AsArray()[childActionIndex]; + codeActionToRun = JsonSerializer.Deserialize(nestedCodeAction, JsonHelpers.JsonSerializerOptions); + } + Assert.NotNull(codeActionToRun); return codeActionToRun; }