Skip to content

Commit 3b51a53

Browse files
committed
Add code actions on save
1 parent b4fe31c commit 3b51a53

File tree

5 files changed

+164
-55
lines changed

5 files changed

+164
-55
lines changed

book/src/languages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ These configuration keys are available:
6868
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
6969
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
7070
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
71+
| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` |
7172

7273
### File-type detection and the `file-types` key
7374

helix-core/src/syntax.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ pub struct LanguageConfiguration {
105105
pub comment_token: Option<String>,
106106
pub text_width: Option<usize>,
107107
pub soft_wrap: Option<SoftWrap>,
108+
#[serde(default)]
109+
pub code_actions_on_save: HashSet<String>, // List of LSP code actions to be run in order upon saving
108110

109111
#[serde(default)]
110112
pub auto_format: bool,

helix-term/src/commands.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2905,6 +2905,26 @@ async fn make_format_callback(
29052905
Ok(call)
29062906
}
29072907

2908+
async fn make_code_actions_on_save_callback(
2909+
mut futures: FuturesUnordered<
2910+
impl Future<Output = Result<Vec<CodeActionOrCommandItem>, anyhow::Error>>,
2911+
>,
2912+
) -> anyhow::Result<job::Callback> {
2913+
let mut code_actions = Vec::new();
2914+
// TODO if one code action request errors, all other requests are ignored (even if they're valid)
2915+
while let Some(mut lsp_items) = futures.try_next().await? {
2916+
code_actions.append(&mut lsp_items);
2917+
}
2918+
let call: job::Callback = Callback::Editor(Box::new(move |editor: &mut Editor| {
2919+
log::debug!("Applying code actions on save {:?}", code_actions);
2920+
code_actions
2921+
.iter()
2922+
.map(|code_action| apply_code_action(editor, code_action))
2923+
.collect()
2924+
}));
2925+
Ok(call)
2926+
}
2927+
29082928
#[derive(PartialEq, Eq)]
29092929
pub enum Open {
29102930
Below,

helix-term/src/commands/lsp.rs

Lines changed: 131 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use tui::{
1818
use super::{align_view, push_jump, Align, Context, Editor, Open};
1919

2020
use helix_core::{
21-
path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection,
21+
path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Range, Selection,
2222
};
2323
use helix_view::{
2424
document::{DocumentInlayHints, DocumentInlayHintsId, Mode},
@@ -542,7 +542,8 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
542542
cx.push_layer(Box::new(overlaid(picker)));
543543
}
544544

545-
struct CodeActionOrCommandItem {
545+
#[derive(Debug)]
546+
pub struct CodeActionOrCommandItem {
546547
lsp_item: lsp::CodeActionOrCommand,
547548
language_server_id: usize,
548549
}
@@ -619,34 +620,8 @@ pub fn code_action(cx: &mut Context) {
619620

620621
let selection_range = doc.selection(view.id).primary();
621622

622-
let mut seen_language_servers = HashSet::new();
623-
624-
let mut futures: FuturesUnordered<_> = doc
625-
.language_servers_with_feature(LanguageServerFeature::CodeAction)
626-
.filter(|ls| seen_language_servers.insert(ls.id()))
627-
// TODO this should probably already been filtered in something like "language_servers_with_feature"
628-
.filter_map(|language_server| {
629-
let offset_encoding = language_server.offset_encoding();
630-
let language_server_id = language_server.id();
631-
let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding);
632-
// Filter and convert overlapping diagnostics
633-
let code_action_context = lsp::CodeActionContext {
634-
diagnostics: doc
635-
.diagnostics()
636-
.iter()
637-
.filter(|&diag| {
638-
selection_range
639-
.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
640-
})
641-
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
642-
.collect(),
643-
only: None,
644-
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
645-
};
646-
let code_action_request =
647-
language_server.code_actions(doc.identifier(), range, code_action_context)?;
648-
Some((code_action_request, language_server_id))
649-
})
623+
let mut futures: FuturesUnordered<_> = code_actions_for_range(doc, selection_range)
624+
.into_iter()
650625
.map(|(request, ls_id)| async move {
651626
let json = request.await?;
652627
let response: Option<lsp::CodeActionResponse> = serde_json::from_value(json)?;
@@ -734,31 +709,7 @@ pub fn code_action(cx: &mut Context) {
734709

735710
// always present here
736711
let action = action.unwrap();
737-
let Some(language_server) = editor.language_server_by_id(action.language_server_id) else {
738-
editor.set_error("Language Server disappeared");
739-
return;
740-
};
741-
let offset_encoding = language_server.offset_encoding();
742-
743-
match &action.lsp_item {
744-
lsp::CodeActionOrCommand::Command(command) => {
745-
log::debug!("code action command: {:?}", command);
746-
execute_lsp_command(editor, action.language_server_id, command.clone());
747-
}
748-
lsp::CodeActionOrCommand::CodeAction(code_action) => {
749-
log::debug!("code action: {:?}", code_action);
750-
if let Some(ref workspace_edit) = code_action.edit {
751-
log::debug!("edit: {:?}", workspace_edit);
752-
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
753-
}
754-
755-
// if code action provides both edit and command first the edit
756-
// should be applied and then the command
757-
if let Some(command) = &code_action.command {
758-
execute_lsp_command(editor, action.language_server_id, command.clone());
759-
}
760-
}
761-
}
712+
apply_code_action(editor, action);
762713
});
763714
picker.move_down(); // pre-select the first item
764715

@@ -770,6 +721,131 @@ pub fn code_action(cx: &mut Context) {
770721
});
771722
}
772723

724+
pub fn code_actions_for_range(
725+
doc: &mut Document,
726+
range: helix_core::Range,
727+
) -> Vec<(impl Future<Output = Result<Value, helix_lsp::Error>>, usize)> {
728+
let mut seen_language_servers = HashSet::new();
729+
730+
doc.language_servers_with_feature(LanguageServerFeature::CodeAction)
731+
.filter(|ls| seen_language_servers.insert(ls.id()))
732+
// TODO this should probably already been filtered in something like "language_servers_with_feature"
733+
.filter_map(|language_server| {
734+
let offset_encoding = language_server.offset_encoding();
735+
let lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding);
736+
737+
language_server
738+
.code_actions(
739+
doc.identifier(),
740+
lsp_range,
741+
// Filter and convert overlapping diagnostics
742+
lsp::CodeActionContext {
743+
diagnostics: doc
744+
.diagnostics()
745+
.iter()
746+
.filter(|&diag| {
747+
range.overlaps(&helix_core::Range::new(
748+
diag.range.start,
749+
diag.range.end,
750+
))
751+
})
752+
.map(|diag| {
753+
diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)
754+
})
755+
.collect(),
756+
only: None,
757+
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
758+
},
759+
)
760+
.map(|request| (request, language_server.id()))
761+
})
762+
.collect::<Vec<_>>()
763+
}
764+
765+
pub fn code_actions_on_save(
766+
doc: &mut Document,
767+
) -> Option<
768+
FuturesUnordered<impl Future<Output = Result<Vec<CodeActionOrCommandItem>, anyhow::Error>>>,
769+
> {
770+
let code_actions_on_save_cfg = doc
771+
.language_config()
772+
.map(|c| c.code_actions_on_save.clone())?;
773+
774+
if code_actions_on_save_cfg.is_empty() {
775+
return None;
776+
}
777+
778+
let full_range = Range::new(0, doc.text().len_chars());
779+
780+
Some(
781+
code_actions_for_range(doc, full_range)
782+
.into_iter()
783+
.map(|(request, ls_id)| {
784+
let code_actions_on_save = code_actions_on_save_cfg.clone();
785+
async move {
786+
log::debug!("Configured code actions on save {:?}", code_actions_on_save);
787+
let json = request.await?;
788+
let response: Option<helix_lsp::lsp::CodeActionResponse> =
789+
serde_json::from_value(json)?;
790+
let available_code_actions = match response {
791+
Some(value) => value,
792+
None => helix_lsp::lsp::CodeActionResponse::default(),
793+
};
794+
log::debug!("Available code actions {:?}", available_code_actions);
795+
796+
Ok(available_code_actions
797+
.into_iter()
798+
.filter(|action| match action {
799+
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x)
800+
if x.disabled.is_none() =>
801+
{
802+
match &x.kind {
803+
Some(kind) => code_actions_on_save.get(kind.as_str()).is_some(),
804+
None => false,
805+
}
806+
}
807+
_ => false,
808+
})
809+
.map(|lsp_item| CodeActionOrCommandItem {
810+
lsp_item,
811+
language_server_id: ls_id,
812+
})
813+
.collect())
814+
}
815+
})
816+
.collect(),
817+
)
818+
}
819+
820+
pub fn apply_code_action(editor: &mut Editor, code_action_item: &CodeActionOrCommandItem) {
821+
let Some(language_server) = editor.language_server_by_id(code_action_item.language_server_id) else {
822+
editor.set_error("Language Server disappeared");
823+
return;
824+
};
825+
826+
let offset_encoding = language_server.offset_encoding();
827+
828+
match code_action_item.lsp_item.clone() {
829+
lsp::CodeActionOrCommand::Command(command) => {
830+
log::debug!("code action command: {:?}", command);
831+
execute_lsp_command(editor, code_action_item.language_server_id, command);
832+
}
833+
lsp::CodeActionOrCommand::CodeAction(code_action) => {
834+
log::debug!("code action: {:?}", code_action);
835+
if let Some(ref workspace_edit) = code_action.edit {
836+
log::debug!("edit: {:?}", workspace_edit);
837+
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
838+
}
839+
840+
// if code action provides both edit and command first the edit
841+
// should be applied and then the command
842+
if let Some(command) = code_action.command {
843+
execute_lsp_command(editor, code_action_item.language_server_id, command);
844+
}
845+
}
846+
}
847+
}
848+
773849
impl ui::menu::Item for lsp::Command {
774850
type Data = ();
775851
fn format(&self, _data: &Self::Data) -> Row {

helix-term/src/commands/typed.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ fn write_impl(
334334
let (view, doc) = current!(cx.editor);
335335
let path = path.map(AsRef::as_ref);
336336

337+
if let Some(futures) = code_actions_on_save(doc) {
338+
let callback = make_code_actions_on_save_callback(futures);
339+
jobs.add(Job::with_callback(callback).wait_before_exiting());
340+
}
341+
337342
let fmt = if editor_auto_fmt {
338343
doc.auto_format().map(|fmt| {
339344
let callback = make_format_callback(
@@ -677,6 +682,11 @@ pub fn write_all_impl(
677682
return None;
678683
}
679684

685+
if let Some(futures) = code_actions_on_save(doc) {
686+
let callback = make_code_actions_on_save_callback(futures);
687+
jobs.add(Job::with_callback(callback).wait_before_exiting());
688+
}
689+
680690
// Look for a view to apply the formatting change to. If the document
681691
// is in the current view, just use that. Otherwise, since we don't
682692
// have any other metric available for better selection, just pick

0 commit comments

Comments
 (0)