Skip to content

Commit 7d642ee

Browse files
committed
Add code actions on save
1 parent 5b3dd6a commit 7d642ee

File tree

6 files changed

+150
-48
lines changed

6 files changed

+150
-48
lines changed

book/src/languages.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ These configuration keys are available:
6565
| `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 |
6666
| `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` |
6767
| `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. |
68+
| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` |
6869

6970
### File-type detection and the `file-types` key
7071

helix-core/src/syntax.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ pub struct LanguageConfiguration {
8484
pub comment_token: Option<String>,
8585
pub text_width: Option<usize>,
8686
pub soft_wrap: Option<SoftWrap>,
87+
#[serde(default)]
88+
pub code_actions_on_save: Vec<String>, // List of LSP code actions to be run in order upon saving
8789

8890
#[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")]
8991
pub config: Option<serde_json::Value>,

helix-term/src/commands.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2745,6 +2745,22 @@ async fn make_format_callback(
27452745
Ok(call)
27462746
}
27472747

2748+
async fn make_code_actions_on_save_callback(
2749+
future: impl Future<Output = Result<Vec<helix_lsp::lsp::CodeActionOrCommand>, anyhow::Error>>
2750+
+ Send
2751+
+ 'static,
2752+
) -> anyhow::Result<job::Callback> {
2753+
let code_actions = future.await?;
2754+
let call: job::Callback = Callback::Editor(Box::new(move |editor: &mut Editor| {
2755+
log::debug!("Applying code actions on save {:?}", code_actions);
2756+
code_actions
2757+
.iter()
2758+
.map(|code_action| apply_code_action(editor, code_action))
2759+
.collect()
2760+
}));
2761+
Ok(call)
2762+
}
2763+
27482764
#[derive(PartialEq, Eq)]
27492765
pub enum Open {
27502766
Below,

helix-term/src/commands/lsp.rs

Lines changed: 32 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
use futures_util::FutureExt;
22
use helix_lsp::{
33
block_on,
4-
lsp::{
5-
self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity,
6-
NumberOrString,
7-
},
8-
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
4+
lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString},
5+
util::lsp_range_to_range,
96
OffsetEncoding,
107
};
118
use tui::{
@@ -544,31 +541,9 @@ fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool {
544541
pub fn code_action(cx: &mut Context) {
545542
let (view, doc) = current!(cx.editor);
546543

547-
let language_server = language_server!(cx.editor, doc);
548-
549544
let selection_range = doc.selection(view.id).primary();
550-
let offset_encoding = language_server.offset_encoding();
551545

552-
let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding);
553-
554-
let future = match language_server.code_actions(
555-
doc.identifier(),
556-
range,
557-
// Filter and convert overlapping diagnostics
558-
lsp::CodeActionContext {
559-
diagnostics: doc
560-
.diagnostics()
561-
.iter()
562-
.filter(|&diag| {
563-
selection_range
564-
.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
565-
})
566-
.map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
567-
.collect(),
568-
only: None,
569-
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
570-
},
571-
) {
546+
let future = match doc.code_actions(selection_range) {
572547
Some(future) => future,
573548
None => {
574549
cx.editor
@@ -642,25 +617,7 @@ pub fn code_action(cx: &mut Context) {
642617
// always present here
643618
let code_action = code_action.unwrap();
644619

645-
match code_action {
646-
lsp::CodeActionOrCommand::Command(command) => {
647-
log::debug!("code action command: {:?}", command);
648-
execute_lsp_command(editor, command.clone());
649-
}
650-
lsp::CodeActionOrCommand::CodeAction(code_action) => {
651-
log::debug!("code action: {:?}", code_action);
652-
if let Some(ref workspace_edit) = code_action.edit {
653-
log::debug!("edit: {:?}", workspace_edit);
654-
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
655-
}
656-
657-
// if code action provides both edit and command first the edit
658-
// should be applied and then the command
659-
if let Some(command) = &code_action.command {
660-
execute_lsp_command(editor, command.clone());
661-
}
662-
}
663-
}
620+
apply_code_action(editor, code_action);
664621
});
665622
picker.move_down(); // pre-select the first item
666623

@@ -670,6 +627,34 @@ pub fn code_action(cx: &mut Context) {
670627
)
671628
}
672629

630+
pub fn apply_code_action(editor: &mut Editor, code_action: &CodeActionOrCommand) {
631+
let (_view, doc) = current!(editor);
632+
633+
let language_server = language_server!(editor, doc);
634+
635+
let offset_encoding = language_server.offset_encoding();
636+
637+
match code_action {
638+
lsp::CodeActionOrCommand::Command(command) => {
639+
log::debug!("code action command: {:?}", command);
640+
execute_lsp_command(editor, command.clone());
641+
}
642+
lsp::CodeActionOrCommand::CodeAction(code_action) => {
643+
log::debug!("code action: {:?}", code_action);
644+
if let Some(ref workspace_edit) = code_action.edit {
645+
log::debug!("edit: {:?}", workspace_edit);
646+
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
647+
}
648+
649+
// if code action provides both edit and command first the edit
650+
// should be applied and then the command
651+
if let Some(command) = &code_action.command {
652+
execute_lsp_command(editor, command.clone());
653+
}
654+
}
655+
}
656+
}
657+
673658
impl ui::menu::Item for lsp::Command {
674659
type Data = ();
675660
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(future) = doc.code_actions_on_save() {
338+
let callback = make_code_actions_on_save_callback(future);
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(
@@ -647,6 +652,11 @@ pub fn write_all_impl(
647652
return None;
648653
}
649654

655+
if let Some(future) = doc.code_actions_on_save() {
656+
let callback = make_code_actions_on_save_callback(future);
657+
jobs.add(Job::with_callback(callback).wait_before_exiting());
658+
}
659+
650660
// Look for a view to apply the formatting change to. If the document
651661
// is in the current view, just use that. Otherwise, since we don't
652662
// have any other metric available for better selection, just pick

helix-view/src/document.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use helix_core::doc_formatter::TextFormat;
88
use helix_core::syntax::Highlight;
99
use helix_core::text_annotations::{InlineAnnotation, TextAnnotations};
1010
use helix_core::Range;
11+
use helix_lsp::util::{diagnostic_to_lsp_diagnostic, range_to_lsp_range};
1112
use helix_vcs::{DiffHandle, DiffProviderRegistry};
1213

1314
use ::parking_lot::Mutex;
@@ -457,7 +458,7 @@ where
457458
*mut_ref = f(mem::take(mut_ref));
458459
}
459460

460-
use helix_lsp::lsp;
461+
use helix_lsp::lsp::{self, CodeActionTriggerKind};
461462
use url::Url;
462463

463464
impl Document {
@@ -633,6 +634,88 @@ impl Document {
633634
Some(fut.boxed())
634635
}
635636

637+
pub fn code_actions_on_save(
638+
&self,
639+
) -> Option<BoxFuture<'static, Result<Vec<helix_lsp::lsp::CodeActionOrCommand>, anyhow::Error>>>
640+
{
641+
let code_actions_on_save = self
642+
.language_config()
643+
.map(|c| c.code_actions_on_save.clone())?;
644+
645+
if code_actions_on_save.is_empty() {
646+
return None;
647+
}
648+
649+
let request = self.code_actions(self.full_range())?;
650+
651+
let fut = async move {
652+
log::debug!("Configured code actions on save {:?}", code_actions_on_save);
653+
let json = request.await?;
654+
let response: Option<helix_lsp::lsp::CodeActionResponse> =
655+
serde_json::from_value(json)?;
656+
let mut code_actions = match response {
657+
Some(value) => value,
658+
None => helix_lsp::lsp::CodeActionResponse::default(),
659+
};
660+
log::debug!("Available code actions {:?}", code_actions);
661+
code_actions.retain(|action| {
662+
matches!(
663+
action,
664+
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) if x.disabled.is_none() &&
665+
code_actions_on_save.iter().any(|a| match &x.kind {
666+
Some(kind) => kind.as_str() == a,
667+
None => false
668+
})
669+
)
670+
});
671+
if code_actions.len() < code_actions_on_save.len() {
672+
code_actions_on_save.iter().for_each(|configured_action| {
673+
if !code_actions.iter().any(|action| match action {
674+
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) => match &x.kind {
675+
Some(kind) => kind.as_str() == configured_action,
676+
None => false,
677+
},
678+
_ => false,
679+
}) {
680+
log::error!(
681+
"Configured code action on save is invalid {:?}",
682+
configured_action
683+
);
684+
}
685+
})
686+
}
687+
Ok(code_actions)
688+
};
689+
Some(fut.boxed())
690+
}
691+
692+
pub fn code_actions(
693+
&self,
694+
range: Range,
695+
) -> Option<impl Future<Output = Result<serde_json::Value, helix_lsp::Error>>> {
696+
let language_server = self.language_server()?;
697+
let offset_encoding = language_server.offset_encoding();
698+
let lsp_range = range_to_lsp_range(self.text(), range, offset_encoding);
699+
700+
language_server.code_actions(
701+
self.identifier(),
702+
lsp_range,
703+
// Filter and convert overlapping diagnostics
704+
lsp::CodeActionContext {
705+
diagnostics: self
706+
.diagnostics()
707+
.iter()
708+
.filter(|&diag| {
709+
range.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
710+
})
711+
.map(|diag| diagnostic_to_lsp_diagnostic(self.text(), diag, offset_encoding))
712+
.collect(),
713+
only: None,
714+
trigger_kind: Some(CodeActionTriggerKind::INVOKED),
715+
},
716+
)
717+
}
718+
636719
pub fn save<P: Into<PathBuf>>(
637720
&mut self,
638721
path: Option<P>,
@@ -1331,6 +1414,11 @@ impl Document {
13311414
&self.text
13321415
}
13331416

1417+
#[inline]
1418+
pub fn full_range(&self) -> Range {
1419+
Range::new(0, self.text.len_chars())
1420+
}
1421+
13341422
#[inline]
13351423
pub fn selection(&self, view_id: ViewId) -> &Selection {
13361424
&self.selections[&view_id]

0 commit comments

Comments
 (0)