Skip to content

Commit b2dd391

Browse files
jpttrssnjunglerobba
authored andcommitted
Add code actions on save
* Add code-actions-on-save config * Match VS Code config to allow future flexibility * Refactor lsp commands to allow for code reuse * Attempt code actions for all language servers for the document * Add lsp specific integration tests * Update documentation in book * Canonicalize path argument when retrieving documents by path * Resolves issue when running lsp integration tests in windows
1 parent 99a1e50 commit b2dd391

File tree

4 files changed

+180
-49
lines changed

4 files changed

+180
-49
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: 163 additions & 42 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,7 +23,7 @@ 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::{
@@ -587,6 +587,11 @@ struct CodeActionOrCommandItem {
587587
language_server_id: LanguageServerId,
588588
}
589589

590+
struct CodeActionItem {
591+
lsp_item: lsp::CodeAction,
592+
language_server_id: LanguageServerId,
593+
}
594+
590595
impl ui::menu::Item for CodeActionOrCommandItem {
591596
type Data = ();
592597
fn format(&self, _data: &Self::Data) -> Row<'_> {
@@ -659,34 +664,8 @@ pub fn code_action(cx: &mut Context) {
659664

660665
let selection_range = doc.selection(view.id).primary();
661666

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-
})
667+
let mut futures: FuturesOrdered<_> = code_actions_for_range(doc, selection_range, None)
668+
.into_iter()
690669
.map(|(request, ls_id)| async move {
691670
let Some(mut actions) = request.await? else {
692671
return anyhow::Ok(Vec::new());
@@ -788,19 +767,12 @@ pub fn code_action(cx: &mut Context) {
788767
}
789768
lsp::CodeActionOrCommand::CodeAction(code_action) => {
790769
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-
}
800770
let resolved_code_action =
801-
resolved_code_action.as_ref().unwrap_or(code_action);
771+
resolve_code_action(code_action, language_server);
802772

803-
if let Some(ref workspace_edit) = resolved_code_action.edit {
773+
if let Some(ref workspace_edit) =
774+
resolved_code_action.as_ref().unwrap_or(code_action).edit
775+
{
804776
let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit);
805777
}
806778

@@ -825,6 +797,155 @@ pub fn code_action(cx: &mut Context) {
825797
});
826798
}
827799

800+
pub fn code_actions_for_range(
801+
doc: &Document,
802+
range: helix_core::Range,
803+
only: Option<Vec<CodeActionKind>>,
804+
) -> Vec<(
805+
impl Future<Output = Result<Option<Vec<CodeActionOrCommand>>, helix_lsp::Error>>,
806+
LanguageServerId,
807+
)> {
808+
let mut seen_language_servers = HashSet::new();
809+
810+
doc.language_servers_with_feature(LanguageServerFeature::CodeAction)
811+
.filter(|ls| seen_language_servers.insert(ls.id()))
812+
// TODO this should probably already been filtered in something like "language_servers_with_feature"
813+
.filter_map(|language_server| {
814+
let offset_encoding = language_server.offset_encoding();
815+
let language_server_id = language_server.id();
816+
let lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding);
817+
// Filter and convert overlapping diagnostics
818+
let code_action_context = lsp::CodeActionContext {
819+
diagnostics: doc
820+
.diagnostics()
821+
.iter()
822+
.filter(|&diag| {
823+
range.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
824+
})
825+
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
826+
.collect(),
827+
only: only.clone(),
828+
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
829+
};
830+
let code_action_request =
831+
language_server.code_actions(doc.identifier(), lsp_range, code_action_context)?;
832+
Some((code_action_request, language_server_id))
833+
})
834+
.collect::<Vec<_>>()
835+
}
836+
837+
/// Will apply the code actions on save from the language config for each language server
838+
pub fn code_actions_on_save(cx: &mut compositor::Context, doc_id: &DocumentId) {
839+
let doc = doc!(cx.editor, doc_id);
840+
841+
let code_actions_on_save_cfg = doc
842+
.language_config()
843+
.and_then(|c| c.code_actions_on_save.clone());
844+
845+
if let Some(code_actions_on_save_cfg) = code_actions_on_save_cfg {
846+
for code_action_kind in code_actions_on_save_cfg
847+
.into_iter()
848+
.map(CodeActionKind::from)
849+
{
850+
log::debug!("Attempting code action on save {:?}", code_action_kind);
851+
let doc = doc!(cx.editor, doc_id);
852+
let full_range = helix_core::Range::new(0, doc.text().len_chars());
853+
let code_actions: Vec<CodeActionItem> =
854+
code_actions_for_range(doc, full_range, Some(vec![code_action_kind.clone()]))
855+
.into_iter()
856+
.filter_map(|(future, language_server_id)| {
857+
if let Ok(Some(mut actions)) = helix_lsp::block_on(future) {
858+
// Retain only enabled code actions that do not have commands.
859+
//
860+
// Commands are deprecated and are not supported because they apply
861+
// workspace edits asynchronously and there is currently no mechanism
862+
// to handle waiting for the workspace edits to be applied before moving
863+
// on to the next code action (or auto-format).
864+
actions.retain(|action| {
865+
matches!(
866+
action,
867+
CodeActionOrCommand::CodeAction(CodeAction {
868+
disabled: None,
869+
command: None,
870+
..
871+
})
872+
)
873+
});
874+
875+
// Use the first matching code action
876+
if let Some(lsp_item) = actions.first() {
877+
return match lsp_item {
878+
CodeActionOrCommand::CodeAction(code_action) => {
879+
Some(CodeActionItem {
880+
lsp_item: code_action.clone(),
881+
language_server_id,
882+
})
883+
}
884+
_ => None,
885+
};
886+
}
887+
}
888+
None
889+
})
890+
.collect();
891+
892+
if code_actions.is_empty() {
893+
log::debug!("Code action on save not found {:?}", code_action_kind);
894+
cx.editor
895+
.set_error(format!("Code Action not found: {:?}", code_action_kind));
896+
}
897+
898+
for code_action in code_actions {
899+
log::debug!(
900+
"Applying code action on save {:?} for language server {:?}",
901+
code_action.lsp_item,
902+
code_action.language_server_id
903+
);
904+
let Some(language_server) = cx
905+
.editor
906+
.language_server_by_id(code_action.language_server_id)
907+
else {
908+
log::error!(
909+
"Language server disappeared {:?}",
910+
code_action.language_server_id
911+
);
912+
continue;
913+
};
914+
915+
let offset_encoding = language_server.offset_encoding();
916+
917+
let resolved_code_action =
918+
resolve_code_action(&code_action.lsp_item, language_server);
919+
920+
if let Some(ref workspace_edit) = resolved_code_action
921+
.as_ref()
922+
.unwrap_or(&code_action.lsp_item)
923+
.edit
924+
{
925+
let _ = cx
926+
.editor
927+
.apply_workspace_edit_sync(offset_encoding, workspace_edit);
928+
}
929+
}
930+
}
931+
}
932+
}
933+
934+
pub fn resolve_code_action(
935+
code_action: &CodeAction,
936+
language_server: &Client,
937+
) -> Option<CodeAction> {
938+
let mut resolved_code_action = None;
939+
if code_action.edit.is_none() || code_action.command.is_none() {
940+
if let Some(future) = language_server.resolve_code_action(code_action) {
941+
if let Ok(code_action) = helix_lsp::block_on(future) {
942+
resolved_code_action = Some(code_action);
943+
}
944+
}
945+
}
946+
resolved_code_action
947+
}
948+
828949
#[derive(Debug)]
829950
pub struct ApplyEditError {
830951
pub kind: ApplyEditErrorKind,

helix-term/src/commands/typed.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -381,22 +381,25 @@ fn write_impl(
381381
options: WriteOptions,
382382
) -> anyhow::Result<()> {
383383
let config = cx.editor.config();
384-
let jobs = &mut cx.jobs;
385384
let (view, doc) = current!(cx.editor);
385+
let doc_id = doc.id();
386+
let view_id = view.id;
386387

387388
if doc.trim_trailing_whitespace() {
388-
trim_trailing_whitespace(doc, view.id);
389+
trim_trailing_whitespace(doc, view_id);
389390
}
390391
if config.trim_final_newlines {
391-
trim_final_newlines(doc, view.id);
392+
trim_final_newlines(doc, view_id);
392393
}
393394
if doc.insert_final_newline() {
394-
insert_final_newline(doc, view.id);
395+
insert_final_newline(doc, view_id);
395396
}
396397

397398
// Save an undo checkpoint for any outstanding changes.
398399
doc.append_changes_to_history(view);
399400

401+
code_actions_on_save(cx, &doc_id);
402+
400403
let (view, doc) = current_ref!(cx.editor);
401404
let fmt = if config.auto_format && options.auto_format {
402405
doc.auto_format(cx.editor).map(|fmt| {
@@ -408,7 +411,8 @@ fn write_impl(
408411
Some((path.map(Into::into), options.force)),
409412
);
410413

411-
jobs.add(Job::with_callback(callback).wait_before_exiting());
414+
cx.jobs
415+
.add(Job::with_callback(callback).wait_before_exiting());
412416
})
413417
} else {
414418
None
@@ -815,7 +819,6 @@ pub fn write_all_impl(
815819
) -> anyhow::Result<()> {
816820
let mut errors: Vec<&'static str> = Vec::new();
817821
let config = cx.editor.config();
818-
let jobs = &mut cx.jobs;
819822
let saves: Vec<_> = cx
820823
.editor
821824
.documents
@@ -858,6 +861,8 @@ pub fn write_all_impl(
858861
// Save an undo checkpoint for any outstanding changes.
859862
doc.append_changes_to_history(view);
860863

864+
code_actions_on_save(cx, &doc_id);
865+
861866
let fmt = if options.auto_format && config.auto_format {
862867
let doc = doc!(cx.editor, &doc_id);
863868
doc.auto_format(cx.editor).map(|fmt| {
@@ -868,7 +873,8 @@ pub fn write_all_impl(
868873
fmt,
869874
Some((None, options.force)),
870875
);
871-
jobs.add(Job::with_callback(callback).wait_before_exiting());
876+
cx.jobs
877+
.add(Job::with_callback(callback).wait_before_exiting());
872878
})
873879
} else {
874880
None

0 commit comments

Comments
 (0)