Skip to content

Move code actions to LSP#45572

Merged
allisonchou merged 21 commits intodotnet:master-vs-depsfrom
allisonchou:LSPCodeActionSupport
Jul 13, 2020
Merged

Move code actions to LSP#45572
allisonchou merged 21 commits intodotnet:master-vs-depsfrom
allisonchou:LSPCodeActionSupport

Conversation

@allisonchou
Copy link
Contributor

@allisonchou allisonchou commented Jun 30, 2020

Moves code actions over to LSP.

How it works:
There are three main handlers involved in processing LSP code actions: CodeActionsHandler, CodeActionResolveHandler, and RunCodeActionsHandler.

  1. CodeActionsHandler: Deals with the initial code actions request from LSP. Returns an array of VSCodeActions generated from Roslyn code actions. The Edit and Command properties in the VSCodeAction are left blank, since those are populated in the CodeActionResolveHandler. We avoid filling out those two properties so we don't need to a bunch of work unless the user actually requests it.
  2. CodeActionResolveHandler: Invoked when a user hovers over a code action. We leave the bulk of the processing to this handler. Fills out either the Edit or Command properties (can be both as well, but due to the LSP bug below this isn't currently possible). If we send back an Edit, the edit is processed and executed on the client/LSP side. If we send back a Command, the command will eventually make its way to the RunCodeActionsHandler and run from there.
  3. RunCodeActionsHandler: Invoked when Command property is filled out. We run actions as commands if they can't be executed as WorkspaceEdits for some reason (i.e. if not an ApplyChangesOperation, or due to the LSP bug below). These commands are executed on the local Roslyn side. The general ExecuteWorkspaceCommandHandler is called first. The general handler then calls into our code-action specific RunCodeActionsHandler.

Code actions that add a document must currently be executed on the server side due to this LSP bug: https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1147293/

eta: thanks to David for all the help on this PR!

Copy link
Member

@dibarbet dibarbet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have some high level comments I want to get out of the way before diving too deep.

var runAsCommand = false;

var applyChangesOperations = operations.Where(operation => operation is ApplyChangesOperation);
if (applyChangesOperations.Any())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should be all, or more specific filtering.

If we have an ApplyChangesOperation + DocumentNavigation operation, then it should be safe to do this (we can't tell the client to navigate anywhere anyway, so we can't do anything but drop the document navigation operation). But if there is an ApplyChangesOperation + some other actual modification operation, then we might get into a corrupt state b/c we applied half of the code action.

Not sure the best way to do this, thoughts @CyrusNajmabadi ?

}

// Set up to run as command on the server instead of using WorkspaceEdits.
var commandOperations = operations.All(operation => !(operation is ApplyChangesOperation));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, we apply any non ApplyChangesOperation as a command. Then I think I'm only worried about the order. The LSP spec states that commands are applied after workspace edits. But if the code action specifies the non-ApplyChangesOperation to run before the ApplyChangesOperation, the code action could get corrupted if order matters.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also could be problematic - I see in the execute code actions request, we just find the one with the right title. It will end up executing all of the operations, so if there are workspace edits + a command, it will execute the edits twice (once on client workspace edits, once on server during RunCodeActions ApplyChangesOperation)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would be best if we were to just file a Roslyn bug on this?
I think ideally, the solution would be to include a check in RunCodeActionsHandler to see whether we are executing an ApplyChangesOperation so that we don't execute those kinds of operations twice. But we can't currently do that since some ApplyChangesOperations are applied as commands due to this bug on the LSP side. Once the LSP bug is fixed, I think we can address the concerns you mentioned.

The current approach is: if we have a mix of ApplyChangesOperations and other operations, only the ApplyChangesOperations are executed. But if we have only non-ApplyChangesOperations, then we can execute them (as a command). This isn't ideal, but it prevents workspace edits from potentially executing twice.

return codeAction;

// Local functions
static async Task<TextDocumentEdit[]> ComputeWorkspaceEdits(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be cleaner for this to be something like TryComputeWorkspaceEdits that returns false when unable to compute them and a command has to be used instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can that be done when the function is async? If we return a bool, then we would need to have an out parameter for the workspace edits, but I don't think we can use out parameters in async methods. I guess we could also return a tuple of (bool, WorkspaceEdits) but that seems a bit messy.

Kind = codeActionKind,
Diagnostics = request.Context.Diagnostics,
Children = nestedActions.ToArray(),
Data = new CodeActionResolveData { CodeActionParams = request, DistinctTitle = parentTitle + codeAction.Title }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there cases where the nested code action titles conflict between two different nested groups? If so, I wonder if we could use the codeactionprovider type name + title instead of having to specify the entire tree (unless the title can be the same within a nested group)

we should also doc on the CodeActionResolveData what goes inside the DistinctTitle

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think this would fail with 'Suppress or Configure issues' if we had two diagnostics that we could suppress/configure, e.g. we would have "ConfigureSeverityCodeFixProvider" + "None" twice in this case.

@allisonchou allisonchou requested a review from dibarbet July 2, 2020 19:59
@allisonchou
Copy link
Contributor Author

@dibarbet I think I've addressed all your concerns via code changes or by responding to the initial feedback.
Just want to confirm with Manish that the resource string can be moved. Also, I'm not completely sure but I think a bug may need to be filed to address your feedback here: #45572 (comment).

}

public override async Task<LSP.VSCodeAction> HandleRequestAsync(
LSP.VSCodeAction codeAction,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm a little confused as to the semantics here. it takes in a code action... and returns a code action? can you clarify the concept here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per your suggestion I've added some details in the original post of this PR, as well as added more comments in the code.
The CodeActionsResolveHandler fills out the VSCodeAction's Edit and/or Command properties. The handler is only invoked when the user requests the code action (e.g. by hovering), so it saves unnecessary computation.

return codeAction;
}

var codeActionToResolve = GetCodeActionToResolve(data.DistinctTitle, codeActions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so to resolve a code action, we have to compute all the code actions again? seems costly. can/should we store/save things locally across calls to speed this up?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One suggestion I would have is to do the caching in a follow-up pr. This matches the current behavior in nexus (always recompute) so we're not regressing anything.

This pr is already fairly big, and caching I think will be a little complex and would prefer to review separately.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am ok with that decision for this PR. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #45727 to track this.

codeRefactorings.SelectMany(r => r.CodeActions.Select(ca => ca.action)));
// Special case (also dealt with specially in local Roslyn):
// If we have configure/suppress code actions, combine them under one top-level code action.
var configureSuppressActions = codeFixes.Where(a => a.Action is AbstractConfigurationActionWithNestedActions);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var configureSuppressActions = codeFixes.Where(a => a.Action is AbstractConfigurationActionWithNestedActions);
var configureSuppressActions = codeFixes.OfType<AbstractConfigurationActionWithNestedActions>();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work since we wanted to check the type of a.Action and not just a?

// Flatten out the nested codeactions.
var nestedCodeActions = codeActions.Where(c => c is CodeAction.CodeActionWithNestedActions nc && nc.IsInlinable).SelectMany(nc => nc.NestedCodeActions);
codeActions = codeActions.Where(c => !(c is CodeAction.CodeActionWithNestedActions)).Concat(nestedCodeActions);
return results.ToArray();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also feels like it' reimplementing logic that must exist elsewhere. that doesn't seem desirable. i would far prefer to have a single location where we sort/order these things, and then use it from VS and LSP.

@CyrusNajmabadi
Copy link
Contributor

Done with pass. I have some moderate concerns, but nothing critically blocking. We could create work items to track some things if they are too large for this PR. i have a general deep concern that we're being too loosy goosy with just papering over things that seem like they would be bugs, and that that could lead to a very frustrating user experience where things don't work and users don't know why.

@allisonchou
Copy link
Contributor Author

Opened #45726, #45727, and #45623 to track future work items for LSP code actions on the Roslyn side.

() => codeRefactoringService.GetRefactoringsAsync(document, textSpan, cancellationToken));

await Task.WhenAll(codeFixCollectionsTask, codeRefactoringsTask).ConfigureAwait(false);
return (await codeFixCollectionsTask.ConfigureAwait(false), await codeRefactoringsTask.ConfigureAwait(false));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you don't need WhenAll here since you await the completion of the tasks before returning.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discussed this with Cyrus recently and was told that it would probably be good to keep the WhenAll in here. I think the logic was that if both codeFixCollectionsTask and codeRefactoringsTask error or cancel, then using WhenAll allows us to retrieve both messages, while if we awaited them separately then we might only get one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allison is correct. It's an extremely subtle thing and easy to make a mistake around. However, we can lose exceptions if we kick off tasks and then await each one serially. then WhenAll ensures we properly see all of this, and doesn't hurt anything in terms of perf. i.e. the later awaits will be no-ops because they will precheck if the tasks are complete and will grab the values if so.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah interesting, that behavior makes sense now that I think about it. Thanks for the explanation!


var changedDocuments = projectChanges.SelectMany(pc => pc.GetChangedDocuments());
var changedAnalyzerConfigDocuments = projectChanges.SelectMany(pc => pc.GetChangedAnalyzerConfigDocuments());
var changedAdditionalDocuments = projectChanges.SelectMany(pc => pc.GetChangedAdditionalDocuments());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding / removing documents via LSP workspace edits should be supported, but there are bugs in the LSP client preventing it from working

@allisonchou I think right now if we have to execute these actions on the server, let's not even implement the bit turning them into LSP edits. Just do a simple check to see if there are any changes at all outside the current document and return a command to execute on the server.

Once they fix the bugs we can followup and do the actual implementation to ensure add / remove documents works correctly.

// Changed analyzer config documents
// We won't get any results until https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1147293/
// is fixed.
await AddTextDocumentEdits(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above, lets not do this in this PR. Can followup once the LSP client bug is fixed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue with changed documents should now be fixed due to a change on the Liveshare side (hooray).
There's a bug on the Roslyn side when modifying editorconfig documents (#44331 is the PR fix), but it should hopefully be merged in soon.
Still pending is the work to support adding documents via code actions in LSP.

@allisonchou
Copy link
Contributor Author

It looks like modifying existing documents is now supported after a bug fix on the Liveshare side. Adding new documents is still not supported.
There's currently a bug that affects modifying .editorconfig documents on the Roslyn side, but this should be fixed once #44331 is merged in.

@allisonchou
Copy link
Contributor Author

@CyrusNajmabadi @dibarbet Would it be possible to get another look at this PR? I believe all feedback has been addressed.

Outstanding bugs:

Currently, the future work items on the Roslyn side are:

Copy link
Member

@dibarbet dibarbet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good to me - let's definitely get #45726 done though. Ideally there shouldn't really be much logic in these methods other than converting roslyn code actions to LSP code actions / edits.

@allisonchou allisonchou merged commit e0604a4 into dotnet:master-vs-deps Jul 13, 2020
@ghost ghost added this to the Next milestone Jul 13, 2020
@JoeRobich JoeRobich modified the milestones: Next, 16.8.P1 Jul 20, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants