diff --git a/book/src/languages.md b/book/src/languages.md index f44509fc84f56..5c001e578ce0f 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -65,6 +65,7 @@ These configuration keys are available: | `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 | | `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` | | `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. | +| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` | ### File-type detection and the `file-types` key diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index c34ea81a3a09b..ccbf4ec53cfe5 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -16,7 +16,7 @@ use slotmap::{DefaultKey as LayerId, HopSlotMap}; use std::{ borrow::Cow, cell::RefCell, - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, fmt, hash::{Hash, Hasher}, mem::{replace, transmute}, @@ -84,6 +84,8 @@ pub struct LanguageConfiguration { pub comment_token: Option, pub text_width: Option, pub soft_wrap: Option, + #[serde(default)] + pub code_actions_on_save: HashSet, // List of LSP code actions to be run in order upon saving #[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")] pub config: Option, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 95310c1fec718..f433947c791e0 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2752,6 +2752,22 @@ async fn make_format_callback( Ok(call) } +async fn make_code_actions_on_save_callback( + future: impl Future, anyhow::Error>> + + Send + + 'static, +) -> anyhow::Result { + let code_actions = future.await?; + let call: job::Callback = Callback::Editor(Box::new(move |editor: &mut Editor| { + log::debug!("Applying code actions on save {:?}", code_actions); + code_actions + .iter() + .map(|code_action| apply_code_action(editor, code_action)) + .collect() + })); + Ok(call) +} + #[derive(PartialEq, Eq)] pub enum Open { Below, diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 7a26b3cf60243..e5ae43309efa6 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,4 +1,4 @@ -use futures_util::FutureExt; +use futures_util::{future::BoxFuture, FutureExt}; use helix_lsp::{ block_on, lsp::{ @@ -15,7 +15,7 @@ use tui::{ use super::{align_view, push_jump, Align, Context, Editor, Open}; -use helix_core::{path, text_annotations::InlineAnnotation, Selection}; +use helix_core::{path, text_annotations::InlineAnnotation, Range, Selection}; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, editor::Action, @@ -544,31 +544,9 @@ fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { pub fn code_action(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let selection_range = doc.selection(view.id).primary(); - let offset_encoding = language_server.offset_encoding(); - let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - - let future = match language_server.code_actions( - doc.identifier(), - range, - // Filter and convert overlapping diagnostics - lsp::CodeActionContext { - diagnostics: doc - .diagnostics() - .iter() - .filter(|&diag| { - selection_range - .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) - }) - .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) - .collect(), - only: None, - trigger_kind: Some(CodeActionTriggerKind::INVOKED), - }, - ) { + let future = match code_actions_for_range(doc, selection_range) { Some(future) => future, None => { cx.editor @@ -642,25 +620,7 @@ pub fn code_action(cx: &mut Context) { // always present here let code_action = code_action.unwrap(); - match code_action { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - execute_lsp_command(editor, command.clone()); - } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - if let Some(ref workspace_edit) = code_action.edit { - log::debug!("edit: {:?}", workspace_edit); - let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit); - } - - // if code action provides both edit and command first the edit - // should be applied and then the command - if let Some(command) = &code_action.command { - execute_lsp_command(editor, command.clone()); - } - } - } + apply_code_action(editor, code_action); }); picker.move_down(); // pre-select the first item @@ -670,6 +630,103 @@ pub fn code_action(cx: &mut Context) { ) } +pub fn code_actions_for_range( + doc: &mut Document, + range: helix_core::Range, +) -> Option>> { + let language_server = doc.language_server()?; + let offset_encoding = language_server.offset_encoding(); + let lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding); + + language_server.code_actions( + doc.identifier(), + lsp_range, + // Filter and convert overlapping diagnostics + lsp::CodeActionContext { + diagnostics: doc + .diagnostics() + .iter() + .filter(|&diag| { + range.overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) + }) + .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) + .collect(), + only: None, + trigger_kind: Some(CodeActionTriggerKind::INVOKED), + }, + ) +} + +pub fn code_actions_on_save( + doc: &mut Document, +) -> Option, anyhow::Error>>> { + let code_actions_on_save = doc + .language_config() + .map(|c| c.code_actions_on_save.clone())?; + + if code_actions_on_save.is_empty() { + return None; + } + + let full_range = Range::new(0, doc.text().len_chars()); + let request = code_actions_for_range(doc, full_range)?; + + let fut = async move { + log::debug!("Configured code actions on save {:?}", code_actions_on_save); + let json = request.await?; + let response: Option = serde_json::from_value(json)?; + let available_code_actions = match response { + Some(value) => value, + None => helix_lsp::lsp::CodeActionResponse::default(), + }; + log::debug!("Available code actions {:?}", available_code_actions); + + let code_actions: Vec = available_code_actions + .into_iter() + .filter(|action| match action { + helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) if x.disabled.is_none() => { + match &x.kind { + Some(kind) => code_actions_on_save.get(kind.as_str()).is_some(), + None => false, + } + } + _ => false, + }) + .collect(); + + Ok(code_actions) + }; + Some(fut.boxed()) +} + +pub fn apply_code_action(editor: &mut Editor, code_action: &CodeActionOrCommand) { + let (_view, doc) = current!(editor); + + let language_server = language_server!(editor, doc); + + let offset_encoding = language_server.offset_encoding(); + + match code_action { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit); + } + + // if code action provides both edit and command first the edit + // should be applied and then the command + if let Some(command) = &code_action.command { + execute_lsp_command(editor, command.clone()); + } + } + } +} + impl ui::menu::Item for lsp::Command { type Data = (); fn format(&self, _data: &Self::Data) -> Row { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index eb5c156fb8e54..373e6c83b983c 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -334,6 +334,11 @@ fn write_impl( let (view, doc) = current!(cx.editor); let path = path.map(AsRef::as_ref); + if let Some(future) = code_actions_on_save(doc) { + let callback = make_code_actions_on_save_callback(future); + jobs.add(Job::with_callback(callback).wait_before_exiting()); + } + let fmt = if editor_auto_fmt { doc.auto_format().map(|fmt| { let callback = make_format_callback( @@ -647,6 +652,11 @@ pub fn write_all_impl( return None; } + if let Some(future) = code_actions_on_save(doc) { + let callback = make_code_actions_on_save_callback(future); + jobs.add(Job::with_callback(callback).wait_before_exiting()); + } + // Look for a view to apply the formatting change to. If the document // is in the current view, just use that. Otherwise, since we don't // have any other metric available for better selection, just pick