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

Add code actions on save #6486

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

jpttrssn
Copy link
Contributor

@jpttrssn jpttrssn commented Mar 30, 2023

Addresses issue #1565

  • Add a per language config option for code-actions-on-save that takes a list of LSP actions to run in order during save
  • Verify configured code actions against available source code actions for the entire document being saved
  • Apply actions on write and write_all before auto formatting
  • Refactor to reduce duplicate code
  • Update languages configuration documentation

@cd-a
Copy link
Contributor

cd-a commented Mar 31, 2023

Nice!

Does this work with #2507?

As far as I can see, that was the only feature that was still not working there

@jpttrssn
Copy link
Contributor Author

This would need some work to be compatible. I can work on adding my changes on top of that PR.

@cd-a
Copy link
Contributor

cd-a commented Mar 31, 2023

That would be amazing. That PR should be merged soon.

Copy link
Member

@pascalkuthe pascalkuthe left a comment

Choose a reason for hiding this comment

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

Thanks for taking this on! I left some minor comments. I think we will likely land #2507 before this one since that has been waiting around for a longtime and while sometimes merging bugfixes before that PR makes sense ideally I think we should holdoff adding new LSP related features until after that lands.

Comment on lines 1417 to 1421
#[inline]
pub fn full_range(&self) -> Range {
Range::new(0, self.text.len_chars())
}

Copy link
Member

Choose a reason for hiding this comment

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

I dont think this needs a dedicated helper method. We have far more common operations that don't have helper methods. Adding too many helper methods clutters the (already very large) document impls even more

Comment on lines 671 to 686
if code_actions.len() < code_actions_on_save.len() {
code_actions_on_save.iter().for_each(|configured_action| {
if !code_actions.iter().any(|action| match action {
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) => match &x.kind {
Some(kind) => kind.as_str() == configured_action,
None => false,
},
_ => false,
}) {
log::error!(
"Configured code action on save is invalid {:?}",
configured_action
);
}
})
}
Copy link
Member

Choose a reason for hiding this comment

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

I don't think these code actions are necessarily returned on every import so this would potentially generate a lot of errors. This should atlest be an infolog but this alsoO(NM)` so I am not sure we really need this anyway?

Also, I think a for loop is basically always more idiomatic than for_each

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 agree this isn't great. I struggled to find a good solution for this. Ideally this would be done at the same time as the filtering with the code_actions.retain() above. Perhaps a for loop there instead? I can play around with it some more and see if I can come up with something. Otherwise I'm fine with leaving this out. It's a nice to have, but like you said perhaps not needed.

matches!(
action,
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) if x.disabled.is_none() &&
code_actions_on_save.iter().any(|a| match &x.kind {
Copy link
Member

Choose a reason for hiding this comment

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

probably a good idea to make this a HashSet so we avoid being O(MN) here even if M and N is usually small frserializing to a HashSet just takes replacing Vec with HashSet

trigger_kind: Some(CodeActionTriggerKind::INVOKED),
},
) {
let future = match doc.code_actions(selection_range) {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we should move this to a function on the document. This doesn't need to be moved into helix-view for this PR to work. Just moving this to a separate function inside commands/lsp.rs works just as well. We generally try to keep the LSP-related stuff separate from the core document primitives. Same for the other function you added to the document (which should also just be a standalone function in this file)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alright, cool. That makes sense.

@pascalkuthe pascalkuthe added A-language-server Area: Language server client E-medium Call for participation: Experience needed to fix: Medium / intermediate S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. labels Apr 14, 2023
@pascalkuthe pascalkuthe added S-waiting-on-review Status: Awaiting review from a maintainer. and removed S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. labels Apr 18, 2023
@cd-a
Copy link
Contributor

cd-a commented May 19, 2023

#2507 has been merged now, which should enable this PR

@jpttrssn
Copy link
Contributor Author

Great! I'll update this PR as soon as I've got my changes working with master.

@ravsii
Copy link

ravsii commented May 19, 2023

@jpttrssn this only adds pre-defined LSP actions on save, but no support for third-party commands, like vscode's run on save?

just asking

@pascalkuthe
Copy link
Member

No that's out of scope for this PR. This PR should only implement support for the relevant part of the LSP spec

@cd-a
Copy link
Contributor

cd-a commented Jun 12, 2023

Any luck getting it rebased on latest master? Would love to test it again

@jpttrssn
Copy link
Contributor Author

I've made some progress, but haven't had much time to work on it. I'll try to make some time, though. I also want to get this done.

@jpttrssn jpttrssn force-pushed the code-actions-on-save branch 2 times, most recently from 1f6a002 to 3b51a53 Compare June 17, 2023 15:08
@jpttrssn
Copy link
Contributor Author

Finally got this rebased with master. Manually tested with the following language config:

[[language]]
name = "go"
code-actions-on-save = ["source.organizeImports", "source.invalid"]

@cd-a
Copy link
Contributor

cd-a commented Jun 17, 2023

Great news, thanks @jpttrssn !

Any idea how to test this with TS/TSX?

I tried this under the language config but it doesn't have any effect

code-actions-on-save = ["source.fixAll.eslint"]

@@ -105,6 +105,8 @@ pub struct LanguageConfiguration {
pub comment_token: Option<String>,
pub text_width: Option<usize>,
pub soft_wrap: Option<SoftWrap>,
#[serde(default)]
pub code_actions_on_save: HashSet<String>, // List of LSP code actions to be run in order upon saving
Copy link
Member

Choose a reason for hiding this comment

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

A HashSet is ordered based on the item's hash value though. Let's just use a Vec here since I can also imagine usecases where you would want to execute action A, B then A again

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this should be a Vec to keep the ordering.

Comment on lines 780 to 784
Some(
code_actions_for_range(doc, full_range)
.into_iter()
.map(|(request, ls_id)| {
let code_actions_on_save = code_actions_on_save_cfg.clone();
Copy link
Member

Choose a reason for hiding this comment

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

This assumes that we can trigger multiple code actions in parallel, which works in some cases, but one action might impact another action (e.g. if A removes a function, and B formats the file). Maybe to simplify we should limit to a single code action on save (since that's the majority usecase anyway)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I'm understanding the LSP spec as long as the requests are in the correct order, the lsp should determine how best to execute those requests.

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#messageOrdering

Copy link
Member

@archseer archseer Jun 19, 2023

Choose a reason for hiding this comment

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

Right, but if you send actions A and B in parallel, the server is computing the edits based on that initial state. Server only considers the change applied when we send back a didChange event. Helix does use OT internally to transform edits (so initial state -> A -> B can be applied) but it's not necessarily going to produce the same result as if we requested A, applied, then requested B and applied B.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But over multiple language servers we can't guarantee the order. That's what you were getting at, I assume. Simplifying to a single code action would definitely make this problem go away, though then we are not feature parity with VSCode, if that is important.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, but if you send actions A and B in parallel, the server is computing the edits based on that initial state. Server only considers the change applied when we send back a didChange event. Helix does use OT internally to transform edits (so initial state -> A -> B can be applied) but it's not necessarily going to produce the same result as if we requested A, applied, then requested B and applied B.

Ok, I see. Thanks! Let me know which direction we want to take and I can make the changes.

@jpttrssn
Copy link
Contributor Author

Any idea how to test this with TS/TSX?

I tried this under the language config but it doesn't have any effect

code-actions-on-save = ["source.fixAll.eslint"]

source.fixAll.eslint appears to be specific to the VSCode Eslint extention. See: https://github.com/microsoft/vscode-eslint#settings-options

Addtionally, this PR only allows executing code actions that are returned from the LSP selecting over the range of the entire document. It would be the same as if you did %-space-a in the editor. I based this off of the PR to add code actions on save to VSCode: microsoft/vscode#48086

There is definitely room for adding more functionality, but perhaps out of scope for this PR?

@rcorre
Copy link
Contributor

rcorre commented Jun 30, 2023

This is great, I'm using it with source.organizeImports in Go and it seems to work well.

Out of curiosity, how do you know what the name of an "action" is, or what actions a particular LSP supports? I just copied "source.organizeImports" from the docs in the PR, but I have no idea how I'd figure out what else I could put in there, or if it would be the same for other language servers.

@jpttrssn
Copy link
Contributor Author

jpttrssn commented Jul 1, 2023

Out of curiosity, how do you know what the name of an "action" is, or what actions a particular LSP supports? I just copied "source.organizeImports" from the docs in the PR, but I have no idea how I'd figure out what else I could put in there, or if it would be the same for other language servers.

There are only two source actions defined in the LSP spec (source.organizeImports and source.fixAll). However, the spec leaves the option for a language server or extension to provide additional source actions. I have myself yet to find a LSP I can use to test this PR with a source.fixAll.

As a side note, I'm in the process of re-working this PR to apply the configured code actions in order (applied individually) and enabling the source.fixAll option.

@jpttrssn jpttrssn force-pushed the code-actions-on-save branch 2 times, most recently from ebb8dfc to e40d16e Compare September 16, 2023 19:54
@jpttrssn
Copy link
Contributor Author

This version will now run code actions, formatting and saving in order. It was a bit tricky to figure out a way to do this. I had to solve around the following constraints:

  1. Current job implementation does not allow for running jobs in order
  2. Each code action requires a async function call with a callback using the non-thread safe Editor
  3. Formatting requires knowledge of the current document version after any code action changes

In order to solve this I ended up needing to run everything in the main thread. A downside to this is that there is no concurrency when saving multiple files. One plus is that there is now only one code path to save a file.

Perhaps there is a better solution I'm not seeing? Making the Editor thread safe, perhaps, but that seems like a big change. @archseer @pascalkuthe

@pascalkuthe
Copy link
Member

I don't think we should ever make Editor thread save, that would require lots of locking and is not really necessary IMO (keeping the main editor state single threaded make it simpler to impose a chronological order on events too)

@mariansimecek
Copy link

Hi,
can´t get it working with typescript LSP.
I have this in languages.toml

 [[language]]
 name = "typescript"
 code-actions-on-save = ["source.sortImports"]

logs:

2023-11-09T20:39:17.277 helix_term::commands [DEBUG] Attempting code action on save "source.sortImports"
2023-11-09T20:39:17.277 helix_lsp::transport [INFO] typescript-language-server -> {"jsonrpc":"2.0","method":"textDocument/codeAction","params":{"context":{"diagnostics":[{"code":6133,"message":"'NodeTracing' is declared but its value is never read.","range":{"end":{"character":40,"line":0},"start":{"character":0,"line":0}},"severity":4,"source":"typescript"},{"code":6133,"message":"'Mod' is declared but its value is never read.","range":{"end":{"character":27,"line":2},"start":{"character":24,"line":2}},"severity":4,"source":"typescript"},{"code":6133,"message":"'something' is declared but its value is never read.","range":{"end":{"character":37,"line":3},"start":{"character":0,"line":3}},"severity":4,"source":"typescript"},{"code":6133,"message":"'Agent' is declared but its value is never read.","range":{"end":{"character":29,"line":4},"start":{"character":0,"line":4}},"severity":4,"source":"typescript"},{"code":6133,"message":"'aa' is declared but its value is never read.","range":{"end":{"character":6,"line":8},"start":{"character":4,"line":8}},"severity":4,"source":"typescript"},{"code":6133,"message":"'aa2' is declared but its value is never read.","range":{"end":{"character":7,"line":9},"start":{"character":4,"line":9}},"severity":4,"source":"typescript"}],"triggerKind":1},"range":{"end":{"character":0,"line":10},"start":{"character":0,"line":0}},"textDocument":{"uri":"file:https:///home/marian-simecek/dev/private/base-ts-proj/index.ts"}},"id":1}
2023-11-09T20:39:17.346 helix_lsp::transport [INFO] typescript-language-server <- {"jsonrpc":"2.0","id":1,"result":[{"title":"Remove import from 'inspector'","command":{"title":"Remove import from 'inspector'","command":"_typescript.applyWorkspaceEdit","arguments":[{"documentChanges":[{"textDocument":{"uri":"file:https:///home/marian-simecek/dev/private/base-ts-proj/index.ts","version":0},"edits":[{"range":{"start":{"line":0,"character":0},"end":{"line":1,"character":0}},"newText":""}]}]}]},"kind":"quickfix"},{"title":"Convert default export to named export","kind":"refactor","disabled":{"reason":"This file already has a default export"}},{"title":"Convert named export to default export","kind":"refactor","disabled":{"reason":"This file already has a default export"}},{"title":"Extract to typedef","kind":"refactor","disabled":{"reason":"Selection is not a valid type node"}},{"title":"Extract to type alias","kind":"refactor","disabled":{"reason":"Selection is not a valid type node"}},{"title":"Extract to interface","kind":"refactor","disabled":{"reason":"Selection is not a valid type node"}},{"title":"Move to a new file","kind":"refactor.move","command":{"title":"Move to a new file","command":"_typescript.applyRefactoring","arguments":[{"file":"/home/marian-simecek/dev/private/base-ts-proj/index.ts","startLine":1,"startOffset":1,"endLine":11,"endOffset":1,"refactor":"Move to a new file","action":"Move to a new file"}]}},{"title":"Add braces to arrow function","kind":"refactor","disabled":{"reason":"Could not find a containing arrow function"}},{"title":"Remove braces from arrow function","kind":"refactor","disabled":{"reason":"Could not find a containing arrow function"}},{"title":"Convert to template string","kind":"refactor","disabled":{"reason":"Can only convert string concatenation"}},{"title":"Convert to optional chain expression","kind":"refactor","disabled":{"reason":"Could not find convertible access expression"}},{"title":"Extract function","kind":"refactor","disabled":{"reason":"Cannot extract import statement."}},{"title":"Extract constant","kind":"refactor","disabled":{"reason":"Cannot extract import statement."}},{"title":"Generate 'get' and 'set' accessors","kind":"refactor","disabled":{"reason":"Could not find property for which to generate accessor"}},{"title":"Infer function return type","kind":"refactor","disabled":{"reason":"Return type must be inferred from a function"}}]}
2023-11-09T20:39:17.346 helix_lsp::transport [INFO] typescript-language-server <- [{"command":{"arguments":[{"documentChanges":[{"edits":[{"newText":"","range":{"end":{"character":0,"line":1},"start":{"character":0,"line":0}}}],"textDocument":{"uri":"file:https:///home/marian-simecek/dev/private/base-ts-proj/index.ts","version":0}}]}],"command":"_typescript.applyWorkspaceEdit","title":"Remove import from 'inspector'"},"kind":"quickfix","title":"Remove import from 'inspector'"},{"disabled":{"reason":"This file already has a default export"},"kind":"refactor","title":"Convert default export to named export"},{"disabled":{"reason":"This file already has a default export"},"kind":"refactor","title":"Convert named export to default export"},{"disabled":{"reason":"Selection is not a valid type node"},"kind":"refactor","title":"Extract to typedef"},{"disabled":{"reason":"Selection is not a valid type node"},"kind":"refactor","title":"Extract to type alias"},{"disabled":{"reason":"Selection is not a valid type node"},"kind":"refactor","title":"Extract to interface"},{"command":{"arguments":[{"action":"Move to a new file","endLine":11,"endOffset":1,"file":"/home/marian-simecek/dev/private/base-ts-proj/index.ts","refactor":"Move to a new file","startLine":1,"startOffset":1}],"command":"_typescript.applyRefactoring","title":"Move to a new file"},"kind":"refactor.move","title":"Move to a new file"},{"disabled":{"reason":"Could not find a containing arrow function"},"kind":"refactor","title":"Add braces to arrow function"},{"disabled":{"reason":"Could not find a containing arrow function"},"kind":"refactor","title":"Remove braces from arrow function"},{"disabled":{"reason":"Can only convert string concatenation"},"kind":"refactor","title":"Convert to template string"},{"disabled":{"reason":"Could not find convertible access expression"},"kind":"refactor","title":"Convert to optional chain expression"},{"disabled":{"reason":"Cannot extract import statement."},"kind":"refactor","title":"Extract function"},{"disabled":{"reason":"Cannot extract import statement."},"kind":"refactor","title":"Extract constant"},{"disabled":{"reason":"Could not find property for which to generate accessor"},"kind":"refactor","title":"Generate 'get' and 'set' accessors"},{"disabled":{"reason":"Return type must be inferred from a function"},"kind":"refactor","title":"Infer function return type"}]
2023-11-09T20:39:17.346 helix_term::commands [DEBUG] Code action on save not found "source.sortImports"
2023-11-09T20:39:17.346 helix_view::editor [ERROR] editor error: Code Action not found: "source.sortImports"

I tried golang and it works as expected.

Am I doing something wrong?

@jpttrssn
Copy link
Contributor Author

@mariansimecek Looks like for the typescript-language-server the code action should be source.sortImports.ts. But regardless, none of the Typescript specific source.*.ts code actions are being returned as options from the typescript-language-server, whether during on-save or within the editor itself. I was able to reproduce this. There might be some issue when the LSP announces these actions. I can dig in a little here, but it doesn't look like this is an issue specific to this PR.

Do you get the expected code actions from within the editor when selecting over the entire file; %-space-a?

@mariansimecek
Copy link

Do you get the expected code actions from within the editor when selecting over the entire file; %-space-a?

Unfortunately, neither.

@ds-281
Copy link

ds-281 commented Dec 5, 2023

Hey, just wanted to drop a message to encourage this to be reviewed, and if given the green light, merged.

This makes working with helix on golang code a much better experience (manually doing imports stuff is a pain). I'm no rust expert, so best I can do is build (rebased on top of master) and try the changes out, and to the limited extent of my testing, it's doing what I need.

@quantonganh
Copy link
Contributor

This makes working with helix on golang code a much better experience (manually doing imports stuff is a pain)

@ds-281 Have you tried this?

[[language]]
name = "go"
formatter = { command = "goimports"}

@archseer archseer added this to the next milestone Jan 9, 2024
@rcorre
Copy link
Contributor

rcorre commented Feb 19, 2024

@quantonganh thank you! I thought goimports needed a filename, as the docs don't suggest it can use stdin/stdout, but it in fact works fine.
This would still be a nice feature to have, so you could take advantage of language server features without needing a separate binary, but that's a good work around for now.

@brielov
Copy link

brielov commented Apr 27, 2024

Any news on this?

@jpttrssn
Copy link
Contributor Author

This probably needs a revisit at this point. From the sound of it this PR might benefit from the new "event system" that came out in the latest release (24.03). I'll see if I can find some time to see what's possible there.

@ds-281
Copy link

ds-281 commented May 2, 2024

That would be much appreciated, thank you.

It's nice that the goimports trick works, but relying on it adds an extra dependency to the system that feels unnecessary and, to my understanding, relies on the fact that only one action is needed (as opposed to this PR, which takes a list of LSP actions to run upon save).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-language-server Area: Language server client E-medium Call for participation: Experience needed to fix: Medium / intermediate S-waiting-on-review Status: Awaiting review from a maintainer.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

10 participants