Skip to content

Commit 0abe28e

Browse files
junglerobbajpttrssn
andcommitted
lsp: add code actions on save
Adds a new `code-actions-on-save` config option for LSP code actions to run on save, before auto formatting will be applied. These actions will be spawned as a series of alternating async jobs and callbacks, using the callback followups from the previous commit, which starts the next async task after each callback executes. This is needed to ensure these actions each operate on the latest document version and will run in the configured order, so access to non-thread-safe Editor is required for querying document state in between async tasks. Because this is run automatically on save, and is user-configurable, the reasonable default is using each action from the first LSP that advertises it and, in case it resolves to multiple actions, use the first one. This way no user interaction is required, and potentially conflicting applications of actions are avoided. Co-authored-by: Jonatan Pettersson <jonatan.pettersson@proton.me>
1 parent 5ff7725 commit 0abe28e

File tree

4 files changed

+230
-73
lines changed

4 files changed

+230
-73
lines changed

book/src/languages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ These configuration keys are available:
7575
| `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. |
7676
| `persistent-diagnostic-sources` | An array of LSP diagnostic sources assumed unchanged when the language server resends the same set of diagnostics. Helix can track the position for these diagnostics internally instead. Useful for diagnostics that are recomputed on save.
7777
| `rainbow-brackets` | Overrides the `editor.rainbow-brackets` config key for the language |
78+
| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` |
7879

7980
### File-type detection and the `file-types` key
8081

helix-core/src/syntax/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ pub struct LanguageConfiguration {
5656
#[serde(default)]
5757
pub auto_format: bool,
5858

59+
#[serde(default)]
60+
pub code_actions_on_save: Option<Vec<String>>,
61+
5962
#[serde(skip_serializing_if = "Option::is_none")]
6063
pub formatter: Option<FormatterConfiguration>,
6164

helix-term/src/commands/lsp.rs

Lines changed: 185 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use futures_util::{stream::FuturesOrdered, FutureExt};
22
use helix_lsp::{
33
block_on,
44
lsp::{
5-
self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity,
6-
NumberOrString,
5+
self, CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionTriggerKind,
6+
DiagnosticSeverity, NumberOrString,
77
},
88
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
99
Client, LanguageServerId, OffsetEncoding,
@@ -23,12 +23,12 @@ use helix_view::{
2323
editor::Action,
2424
handlers::lsp::SignatureHelpInvoked,
2525
theme::Style,
26-
Document, View,
26+
Document, DocumentId, View,
2727
};
2828

2929
use crate::{
3030
compositor::{self, Compositor},
31-
job::Callback,
31+
job::{self, Callback, Job},
3232
ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent},
3333
};
3434

@@ -659,34 +659,8 @@ pub fn code_action(cx: &mut Context) {
659659

660660
let selection_range = doc.selection(view.id).primary();
661661

662-
let mut seen_language_servers = HashSet::new();
663-
664-
let mut futures: FuturesOrdered<_> = doc
665-
.language_servers_with_feature(LanguageServerFeature::CodeAction)
666-
.filter(|ls| seen_language_servers.insert(ls.id()))
667-
// TODO this should probably already been filtered in something like "language_servers_with_feature"
668-
.filter_map(|language_server| {
669-
let offset_encoding = language_server.offset_encoding();
670-
let language_server_id = language_server.id();
671-
let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding);
672-
// Filter and convert overlapping diagnostics
673-
let code_action_context = lsp::CodeActionContext {
674-
diagnostics: doc
675-
.diagnostics()
676-
.iter()
677-
.filter(|&diag| {
678-
selection_range
679-
.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
680-
})
681-
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
682-
.collect(),
683-
only: None,
684-
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
685-
};
686-
let code_action_request =
687-
language_server.code_actions(doc.identifier(), range, code_action_context)?;
688-
Some((code_action_request, language_server_id))
689-
})
662+
let mut futures: FuturesOrdered<_> = code_actions_for_range(doc, selection_range, None)
663+
.into_iter()
690664
.map(|(request, ls_id)| async move {
691665
let Some(mut actions) = request.await? else {
692666
return anyhow::Ok(Vec::new());
@@ -788,19 +762,12 @@ pub fn code_action(cx: &mut Context) {
788762
}
789763
lsp::CodeActionOrCommand::CodeAction(code_action) => {
790764
log::debug!("code action: {:?}", code_action);
791-
// we support lsp "codeAction/resolve" for `edit` and `command` fields
792-
let mut resolved_code_action = None;
793-
if code_action.edit.is_none() || code_action.command.is_none() {
794-
if let Some(future) = language_server.resolve_code_action(code_action) {
795-
if let Ok(code_action) = helix_lsp::block_on(future) {
796-
resolved_code_action = Some(code_action);
797-
}
798-
}
799-
}
800765
let resolved_code_action =
801-
resolved_code_action.as_ref().unwrap_or(code_action);
766+
resolve_code_action_blocking(code_action, language_server);
802767

803-
if let Some(ref workspace_edit) = resolved_code_action.edit {
768+
if let Some(ref workspace_edit) =
769+
resolved_code_action.as_ref().unwrap_or(code_action).edit
770+
{
804771
let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit);
805772
}
806773

@@ -825,6 +792,181 @@ pub fn code_action(cx: &mut Context) {
825792
});
826793
}
827794

795+
pub fn code_actions_for_range(
796+
doc: &Document,
797+
range: helix_core::Range,
798+
only: Option<Vec<CodeActionKind>>,
799+
) -> Vec<(
800+
impl Future<Output = Result<Option<Vec<CodeActionOrCommand>>, helix_lsp::Error>>,
801+
LanguageServerId,
802+
)> {
803+
let mut seen_language_servers = HashSet::new();
804+
805+
doc.language_servers_with_feature(LanguageServerFeature::CodeAction)
806+
.filter(|ls| seen_language_servers.insert(ls.id()))
807+
// TODO this should probably already been filtered in something like "language_servers_with_feature"
808+
.filter_map(|language_server| {
809+
let offset_encoding = language_server.offset_encoding();
810+
let language_server_id = language_server.id();
811+
let lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding);
812+
// Filter and convert overlapping diagnostics
813+
let code_action_context = lsp::CodeActionContext {
814+
diagnostics: doc
815+
.diagnostics()
816+
.iter()
817+
.filter(|&diag| {
818+
range.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
819+
})
820+
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
821+
.collect(),
822+
only: only.clone(),
823+
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
824+
};
825+
let code_action_request =
826+
language_server.code_actions(doc.identifier(), lsp_range, code_action_context)?;
827+
Some((code_action_request, language_server_id))
828+
})
829+
.collect::<Vec<_>>()
830+
}
831+
832+
pub fn code_actions_on_save(
833+
cx: &compositor::Context,
834+
doc_id: DocumentId,
835+
make_format_job: Option<Job>,
836+
) -> Option<Job> {
837+
let doc = doc!(cx.editor, &doc_id);
838+
839+
let Some(code_actions_on_save_cfg) = doc
840+
.language_config()
841+
.and_then(|c| c.code_actions_on_save.clone())
842+
else {
843+
return make_format_job;
844+
};
845+
846+
let mut queued_job = make_format_job;
847+
for action_kind in code_actions_on_save_cfg
848+
.into_iter()
849+
// To run the actions in the configured order, build the chain of callbacks in reverse
850+
.rev()
851+
.map(CodeActionKind::from)
852+
{
853+
let call: job::Callback = Callback::Followup(Box::new(move |editor| {
854+
let doc = doc!(editor, &doc_id);
855+
let full_range = helix_core::Range::new(0, doc.text().len_chars());
856+
if let Some((actions, ls_id)) =
857+
code_actions_for_range(doc, full_range, Some(vec![action_kind]))
858+
.into_iter()
859+
.next()
860+
{
861+
let call = make_code_action_callback(actions, ls_id, queued_job.take());
862+
Some(Job::with_callback(call))
863+
} else {
864+
queued_job.take()
865+
}
866+
}));
867+
queued_job = Some(Job::with_callback(async { Ok(call) }));
868+
}
869+
queued_job
870+
}
871+
872+
async fn make_code_action_callback(
873+
actions: impl Future<Output = Result<Option<Vec<CodeActionOrCommand>>, helix_lsp::Error>>
874+
+ Send
875+
+ Sync,
876+
language_server_id: LanguageServerId,
877+
queued: Option<Job>,
878+
) -> anyhow::Result<job::Callback> {
879+
let actions = actions.await?;
880+
881+
let call: job::Callback = Callback::Followup(Box::new(move |editor| {
882+
let Some(actions) = actions else {
883+
return queued;
884+
};
885+
886+
let code_actions: Vec<_> = actions
887+
.iter()
888+
.filter(|action| {
889+
matches!(
890+
action,
891+
CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. })
892+
)
893+
})
894+
.filter_map(|action| match action {
895+
CodeActionOrCommand::CodeAction(code_action) => Some(code_action),
896+
CodeActionOrCommand::Command(_) => None,
897+
})
898+
.collect();
899+
900+
let next = if let Some(code_action) = code_actions.first() {
901+
let Some(language_server) = editor.language_server_by_id(language_server_id) else {
902+
return queued;
903+
};
904+
let Some(resolve) = resolve_code_action(code_action, language_server) else {
905+
return queued;
906+
};
907+
let callback = make_code_action_resolve_callback(resolve, language_server_id, queued);
908+
Some(Job::with_callback(callback))
909+
} else {
910+
queued
911+
};
912+
913+
next
914+
}));
915+
916+
Ok(call)
917+
}
918+
919+
async fn make_code_action_resolve_callback(
920+
resolve: impl Future<Output = Result<lsp::CodeAction, helix_lsp::Error>> + Send + Sync,
921+
language_server_id: LanguageServerId,
922+
queued: Option<Job>,
923+
) -> anyhow::Result<job::Callback> {
924+
let code_action = resolve.await?;
925+
926+
let call: job::Callback = Callback::Followup(Box::new(move |editor| {
927+
let Some(language_server) = editor.language_server_by_id(language_server_id) else {
928+
return queued;
929+
};
930+
let offset_encoding = language_server.offset_encoding();
931+
932+
if let Some(ref workspace_edit) = code_action.edit {
933+
if let Err(e) = editor.apply_workspace_edit(offset_encoding, workspace_edit) {
934+
log::error!("failed to apply workspace edit: {:?}", e);
935+
}
936+
};
937+
938+
queued
939+
}));
940+
941+
Ok(call)
942+
}
943+
944+
fn resolve_code_action(
945+
code_action: &CodeAction,
946+
language_server: &Client,
947+
) -> Option<impl Future<Output = Result<lsp::CodeAction, helix_lsp::Error>>> {
948+
if code_action.edit.is_none() || code_action.command.is_none() {
949+
language_server.resolve_code_action(code_action)
950+
} else {
951+
None
952+
}
953+
}
954+
955+
pub fn resolve_code_action_blocking(
956+
code_action: &CodeAction,
957+
language_server: &Client,
958+
) -> Option<CodeAction> {
959+
let mut resolved_code_action = None;
960+
if code_action.edit.is_none() || code_action.command.is_none() {
961+
if let Some(future) = language_server.resolve_code_action(code_action) {
962+
if let Ok(code_action) = helix_lsp::block_on(future) {
963+
resolved_code_action = Some(code_action);
964+
}
965+
}
966+
}
967+
resolved_code_action
968+
}
969+
828970
#[derive(Debug)]
829971
pub struct ApplyEditError {
830972
pub kind: ApplyEditErrorKind,

0 commit comments

Comments
 (0)