Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix code actions (and code actions integration tests) #11141

Merged
merged 2 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(VSCodeActionParams reque
return null;
}

// VS Provides `CodeActionParams.Context.SelectionRange` in addition to
// `CodeActionParams.Range`. The `SelectionRange` is relative to where the
// code action was invoked (ex. line 14, char 3) whereas the `Range` is
// always at the start of the line (ex. line 14, char 0). We want to utilize
// the relative positioning to ensure we provide code actions for the appropriate
// context.
//
// Note: VS Code doesn't provide a `SelectionRange`.
var vsCodeActionContext = request.Context;
if (vsCodeActionContext.SelectionRange != null)
{
request.Range = vsCodeActionContext.SelectionRange;
}

var correlationId = Guid.NewGuid();
using var __ = _telemetryReporter.TrackLspRequest(LspEndpointName, LanguageServerConstants.RazorLanguageServerName, TelemetryThresholds.CodeActionRazorTelemetryThreshold, correlationId);
cancellationToken.ThrowIfCancellationRequested();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,6 @@ void ConvertCodeActionsToSumType(VSTextDocumentIdentifier textDocument, Immutabl

var sourceText = codeDocument.Source.Text;

// VS Provides `CodeActionParams.Context.SelectionRange` in addition to
// `CodeActionParams.Range`. The `SelectionRange` is relative to where the
// code action was invoked (ex. line 14, char 3) whereas the `Range` is
// always at the start of the line (ex. line 14, char 0). We want to utilize
// the relative positioning to ensure we provide code actions for the appropriate
// context.
//
// Note: VS Code doesn't provide a `SelectionRange`.
var vsCodeActionContext = request.Context;
if (vsCodeActionContext.SelectionRange != null)
{
request.Range = vsCodeActionContext.SelectionRange;
}

if (!sourceText.TryGetAbsoluteIndex(request.Range.Start, out var startLocation))
{
return null;
Expand Down Expand Up @@ -290,12 +276,4 @@ private static ImmutableHashSet<string> GetAllAvailableCodeActionNames()

return availableCodeActionNames.ToImmutableHashSet();
}

internal TestAccessor GetTestAccessor() => new(this);

internal readonly struct TestAccessor(CodeActionsService instance)
{
public Task<RazorCodeActionContext?> GenerateRazorCodeActionContextAsync(VSCodeActionParams request, IDocumentSnapshot documentSnapshot, bool supportsCodeActionResolve, CancellationToken cancellationToken)
=> instance.GenerateRazorCodeActionContextAsync(request, documentSnapshot, supportsCodeActionResolve, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,27 @@
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.AspNetCore.Razor.LanguageServer.Formatting;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer;
using Microsoft.AspNetCore.Razor.Test.Common.Workspaces;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.CodeActions;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions;

public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServerDelegatingEndpointTestBase(testOutput)
public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : CodeActionEndToEndTestBase(testOutput)
{
private const string GenerateEventHandlerTitle = "Generate Event Handler 'DoesNotExist'";
private const string GenerateAsyncEventHandlerTitle = "Generate Async Event Handler 'DoesNotExist'";
private const string GenerateEventHandlerReturnType = "void";
private const string GenerateAsyncEventHandlerReturnType = "global::System.Threading.Tasks.Task";
private const string CodeBehindTestReplaceNamespace = "$$Replace_Namespace$$";

private GenerateMethodCodeActionResolver[] CreateRazorCodeActionResolvers(
IRoslynCodeActionHelpers roslynCodeActionHelpers,
IRazorFormattingService razorFormattingService)
=>
[
new GenerateMethodCodeActionResolver(
roslynCodeActionHelpers,
new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory),
razorFormattingService)
];

#region CSharp CodeAction Tests

[Fact]
Expand Down Expand Up @@ -240,7 +212,7 @@ public async Task Handle_ConvertBetweenRegularAndVerbatimInterpolatedString_Code
var input = """
@functions
{
private string _x = $@"h[||]ello world";
[|private string _x = $@"hel{|selection:|}lo world";|]
}
""";

Expand Down Expand Up @@ -997,277 +969,4 @@ await ValidateCodeActionAsync(input,
}

#endregion

private async Task ValidateCodeBehindFileAsync(
string input,
string initialCodeBehindContent,
string expectedRazorContent,
string expectedCodeBehindContent,
string codeAction,
int childActionIndex = 0)
{
var razorFilePath = FilePathNormalizer.Normalize($"{Path.GetTempPath()}test.razor");
var codeBehindFilePath = FilePathNormalizer.Normalize($"{Path.GetTempPath()}test.razor.cs");
var diagnostics = new[] { new Diagnostic() { Code = "CS0103", Message = "The name 'DoesNotExist' does not exist in the current context" } };

TestFileMarkupParser.GetSpan(input, out input, out var textSpan);
var codeDocument = CreateCodeDocument(input, filePath: razorFilePath, rootNamespace: "Test", tagHelpers: CreateTagHelperDescriptors());
var razorSourceText = codeDocument.Source.Text;
var uri = new Uri(razorFilePath);
var languageServer = await CreateLanguageServerAsync(codeDocument, razorFilePath);
var documentContext = CreateDocumentContext(uri, codeDocument);
var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null);
File.Create(codeBehindFilePath).Close();
try
{
codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace);
initialCodeBehindContent = initialCodeBehindContent.Replace(CodeBehindTestReplaceNamespace, @namespace);
File.WriteAllText(codeBehindFilePath, initialCodeBehindContent);

var result = await GetCodeActionsAsync(
uri,
textSpan,
razorSourceText,
requestContext,
languageServer,
razorProviders: [new GenerateMethodCodeActionProvider()],
diagnostics);

var codeActionToRun = GetCodeActionToRun(codeAction, childActionIndex, result);
Assert.NotNull(codeActionToRun);

var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory);
var roslynCodeActionHelpers = new RoslynCodeActionHelpers(languageServer);
var changes = await GetEditsAsync(
codeActionToRun,
requestContext,
languageServer,
optionsMonitor: null,
CreateRazorCodeActionResolvers(roslynCodeActionHelpers, formattingService));

var razorEdits = new List<TextChange>();
var codeBehindEdits = new List<TextChange>();
var codeBehindSourceText = SourceText.From(initialCodeBehindContent);
foreach (var change in changes)
{
if (FilePathNormalizer.Normalize(change.TextDocument.Uri.GetAbsoluteOrUNCPath()) == codeBehindFilePath)
{
codeBehindEdits.AddRange(change.Edits.Select(codeBehindSourceText.GetTextChange));
}
else
{
razorEdits.AddRange(change.Edits.Select(razorSourceText.GetTextChange));
}
}

var actualRazorContent = razorSourceText.WithChanges(razorEdits).ToString();
AssertEx.EqualOrDiff(expectedRazorContent, actualRazorContent);

var actualCodeBehindContent = codeBehindSourceText.WithChanges(codeBehindEdits).ToString();
AssertEx.EqualOrDiff(expectedCodeBehindContent.Replace(CodeBehindTestReplaceNamespace, @namespace), actualCodeBehindContent);
}
finally
{
File.Delete(codeBehindFilePath);
}
}

private Task ValidateCodeActionAsync(
string input,
string codeAction,
int childActionIndex = 0,
IRazorCodeActionProvider[]? razorCodeActionProviders = null,
Func<IRoslynCodeActionHelpers, IRazorFormattingService, IRazorCodeActionResolver[]>? codeActionResolversCreator = null,
RazorLSPOptionsMonitor? optionsMonitor = null,
Diagnostic[]? diagnostics = null)
{
return ValidateCodeActionAsync(input, expected: null, codeAction, childActionIndex, razorCodeActionProviders, codeActionResolversCreator, optionsMonitor, diagnostics);
}

private async Task ValidateCodeActionAsync(
string input,
string? expected,
string codeAction,
int childActionIndex = 0,
IRazorCodeActionProvider[]? razorCodeActionProviders = null,
Func<IRoslynCodeActionHelpers, IRazorFormattingService, IRazorCodeActionResolver[]>? codeActionResolversCreator = null,
RazorLSPOptionsMonitor? optionsMonitor = null,
Diagnostic[]? diagnostics = null)
{
TestFileMarkupParser.GetSpan(input, out input, out var textSpan);

var razorFilePath = "C:/path/test.razor";
var codeDocument = CreateCodeDocument(input, filePath: razorFilePath, tagHelpers: CreateTagHelperDescriptors());
var sourceText = codeDocument.Source.Text;
var uri = new Uri(razorFilePath);
var languageServer = await CreateLanguageServerAsync(codeDocument, razorFilePath);
var documentContext = CreateDocumentContext(uri, codeDocument);
var requestContext = new RazorRequestContext(documentContext, null!, "lsp/method", uri: null);

var result = await GetCodeActionsAsync(
uri,
textSpan,
sourceText,
requestContext,
languageServer,
razorCodeActionProviders,
diagnostics);

Assert.NotEmpty(result);
var codeActionToRun = GetCodeActionToRun(codeAction, childActionIndex, result);

if (expected is null)
{
Assert.Null(codeActionToRun);
return;
}

Assert.NotNull(codeActionToRun);

var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory, codeDocument, optionsMonitor?.CurrentValue);
var roslynCodeActionHelpers = new RoslynCodeActionHelpers(languageServer);
var changes = await GetEditsAsync(
codeActionToRun,
requestContext,
languageServer,
optionsMonitor,
codeActionResolversCreator?.Invoke(roslynCodeActionHelpers, formattingService) ?? []);

var edits = new List<TextChange>();
foreach (var change in changes)
{
edits.AddRange(change.Edits.Select(sourceText.GetTextChange));
}

var actual = sourceText.WithChanges(edits).ToString();
AssertEx.EqualOrDiff(expected, actual);
}

protected static VSInternalCodeAction? GetCodeActionToRun(string codeAction, int childActionIndex, SumType<Command, CodeAction>[] result)
{
var codeActionToRun = (VSInternalCodeAction?)result.SingleOrDefault(e => ((RazorVSInternalCodeAction)e.Value!).Name == codeAction || ((RazorVSInternalCodeAction)e.Value!).Title == codeAction).Value;
if (codeActionToRun?.Children?.Length > 0)
{
codeActionToRun = codeActionToRun.Children[childActionIndex];
}

return codeActionToRun;
}

internal async Task<SumType<Command, CodeAction>[]> GetCodeActionsAsync(
Uri uri,
TextSpan textSpan,
SourceText sourceText,
RazorRequestContext requestContext,
IClientConnection clientConnection,
IRazorCodeActionProvider[]? razorProviders = null,
Diagnostic[]? diagnostics = null)
{
var delegatedCodeActionsProvider = new DelegatedCodeActionsProvider(clientConnection, NoOpTelemetryReporter.Instance, LoggerFactory);

var codeActionsService = new CodeActionsService(
DocumentMappingService.AssumeNotNull(),
razorCodeActionProviders: razorProviders ?? [],
csharpCodeActionProviders:
[
new CSharpCodeActionProvider(TestLanguageServerFeatureOptions.Instance),
new TypeAccessibilityCodeActionProvider()
],
htmlCodeActionProviders: [],
LanguageServerFeatureOptions.AssumeNotNull());

var endpoint = new CodeActionEndpoint(
codeActionsService,
delegatedCodeActionsProvider,
NoOpTelemetryReporter.Instance);

// Call GetRegistration, so the endpoint knows we support resolve
endpoint.ApplyCapabilities(new(), new VSInternalClientCapabilities
{
TextDocument = new TextDocumentClientCapabilities
{
CodeAction = new CodeActionSetting
{
ResolveSupport = new CodeActionResolveSupportSetting()
}
}
});

var @params = new VSCodeActionParams
{
TextDocument = new VSTextDocumentIdentifier { Uri = uri },
Range = sourceText.GetRange(textSpan),
Context = new VSInternalCodeActionContext() { Diagnostics = diagnostics ?? [] }
};

var result = await endpoint.HandleRequestAsync(@params, requestContext, DisposalToken);
Assert.NotNull(result);
return result;
}

internal async Task<TextDocumentEdit[]> GetEditsAsync(
VSInternalCodeAction codeActionToRun,
RazorRequestContext requestContext,
IClientConnection clientConnection,
RazorLSPOptionsMonitor? optionsMonitor,
IRazorCodeActionResolver[] razorResolvers)
{
var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory);

var delegatedCodeActionResolver = new DelegatedCodeActionResolver(clientConnection);
var csharpResolvers = new ICSharpCodeActionResolver[]
{
new CSharpCodeActionResolver(formattingService)
};

var htmlResolvers = Array.Empty<IHtmlCodeActionResolver>();

optionsMonitor ??= TestRazorLSPOptionsMonitor.Create();
var codeActionResolveService = new CodeActionResolveService(razorResolvers, csharpResolvers, htmlResolvers, LoggerFactory);
var resolveEndpoint = new CodeActionResolveEndpoint(codeActionResolveService, delegatedCodeActionResolver, optionsMonitor);

var resolveResult = await resolveEndpoint.HandleRequestAsync(codeActionToRun, requestContext, DisposalToken);

Assert.NotNull(resolveResult.Edit);

var workspaceEdit = resolveResult.Edit;
Assert.True(workspaceEdit.TryGetTextDocumentEdits(out var documentEdits));

return documentEdits;
}

private static ImmutableArray<TagHelperDescriptor> CreateTagHelperDescriptors()
{
return BuildTagHelpers().ToImmutableArray();

static IEnumerable<TagHelperDescriptor> BuildTagHelpers()
{
var builder = TagHelperDescriptorBuilder.Create("oncontextmenu", "Microsoft.AspNetCore.Components");
builder.SetMetadata(
new KeyValuePair<string, string>(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"),
new KeyValuePair<string, string>(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind));
yield return builder.Build();

builder = TagHelperDescriptorBuilder.Create("onclick", "Microsoft.AspNetCore.Components");
builder.SetMetadata(
new KeyValuePair<string, string>(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.MouseEventArgs"),
new KeyValuePair<string, string>(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind));

yield return builder.Build();

builder = TagHelperDescriptorBuilder.Create("oncopy", "Microsoft.AspNetCore.Components");
builder.SetMetadata(
new KeyValuePair<string, string>(ComponentMetadata.EventHandler.EventArgsType, "Microsoft.AspNetCore.Components.Web.ClipboardEventArgs"),
new KeyValuePair<string, string>(ComponentMetadata.SpecialKindKey, ComponentMetadata.EventHandler.TagHelperKind));

yield return builder.Build();

builder = TagHelperDescriptorBuilder.Create("ref", "Microsoft.AspNetCore.Components");
builder.SetMetadata(
new KeyValuePair<string, string>(ComponentMetadata.SpecialKindKey, ComponentMetadata.Ref.TagHelperKind),
new KeyValuePair<string, string>(ComponentMetadata.Common.DirectiveAttribute, bool.TrueString));

yield return builder.Build();
}
}
}
Loading
Loading