diff --git a/book/src/languages.md b/book/src/languages.md index 778489f8d2365..c18463ee7f8f9 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -68,6 +68,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 881b45098a9e4..c8242a67e0d06 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -105,6 +105,8 @@ pub struct LanguageConfiguration { pub comment_token: Option, pub text_width: Option, pub soft_wrap: Option, + #[serde(default)] + pub code_actions_on_save: Vec, // List of LSP code actions to be run in order upon saving #[serde(default)] pub auto_format: bool, diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 3e6da5bbd3aba..b7a31412b989b 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -319,11 +319,11 @@ impl Application { self.handle_terminal_events(event).await; } Some(callback) = self.jobs.futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback).await; self.render().await; } Some(callback) = self.jobs.wait_futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback).await; self.render().await; } event = self.editor.wait_event() => { diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 0fd011cc936bf..b14c8f955e5fa 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -50,7 +50,7 @@ use crate::{ args, compositor::{self, Component, Compositor}, filter_picker_entry, - job::Callback, + job::{Callback, OnSaveCallbackData}, keymap::ReverseKeymap, ui::{ self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker, @@ -3024,38 +3024,105 @@ async fn make_format_callback( doc_version: i32, view_id: ViewId, format: impl Future> + Send + 'static, - write: Option<(Option, bool)>, ) -> anyhow::Result { let format = format.await; let call: job::Callback = Callback::Editor(Box::new(move |editor| { - if !editor.documents.contains_key(&doc_id) || !editor.tree.contains(view_id) { - return; + format_callback(doc_id, doc_version, view_id, format, editor); + })); + + Ok(call) +} + +pub fn format_callback( + doc_id: DocumentId, + doc_version: i32, + view_id: ViewId, + format: Result, + editor: &mut Editor, +) { + if !editor.documents.contains_key(&doc_id) || !editor.tree.contains(view_id) { + return; + } + + let scrolloff = editor.config().scrolloff; + let doc = doc_mut!(editor, &doc_id); + let view = view_mut!(editor, view_id); + + if let Ok(format) = format { + if doc.version() == doc_version { + doc.apply(&format, view.id); + doc.append_changes_to_history(view); + doc.detect_indent_and_line_ending(); + view.ensure_cursor_in_view(doc, scrolloff); + } else { + log::info!("discarded formatting changes because the document changed"); } + } +} - let scrolloff = editor.config().scrolloff; - let doc = doc_mut!(editor, &doc_id); - let view = view_mut!(editor, view_id); +pub async fn on_save_callback( + mut editor: &mut Editor, + doc_id: DocumentId, + view_id: ViewId, + path: Option, + force: bool, +) { + let doc = doc!(editor, &doc_id); + if let Some(code_actions_on_save_cfg) = doc + .language_config() + .map(|c| c.code_actions_on_save.clone()) + { + for code_action_on_save_cfg in code_actions_on_save_cfg { + log::debug!( + "Attempting code action on save {:?}", + code_action_on_save_cfg + ); + let doc = doc!(editor, &doc_id); + let lsp_item_result = code_action_on_save(&doc, code_action_on_save_cfg.clone()).await; - if let Ok(format) = format { - if doc.version() == doc_version { - doc.apply(&format, view.id); - doc.append_changes_to_history(view); - doc.detect_indent_and_line_ending(); - view.ensure_cursor_in_view(doc, scrolloff); + if let Some(lsp_item) = lsp_item_result { + log::debug!("Applying code action on save {:?}", code_action_on_save_cfg); + apply_code_action(&mut editor, &lsp_item); } else { - log::info!("discarded formatting changes because the document changed"); + log::debug!( + "Code action on save not found {:?}", + code_action_on_save_cfg + ); + editor.set_error(format!( + "Code Action not found: {:?}", + code_action_on_save_cfg + )); } } + } - if let Some((path, force)) = write { - let id = doc.id(); - if let Err(err) = editor.save(id, path, force) { - editor.set_error(format!("Error saving: {}", err)); - } + if editor.config().auto_format { + let doc = doc!(editor, &doc_id); + if let Some(fmt) = doc.auto_format() { + format_callback(doc.id(), doc.version(), view_id, fmt.await, &mut editor); } - })); + } + if let Err(err) = editor.save::(doc_id, path, force) { + editor.set_error(format!("Error saving: {}", err)); + } +} + +pub async fn make_on_save_callback( + doc_id: DocumentId, + view_id: ViewId, + path: Option, + force: bool, +) -> anyhow::Result { + let call: job::Callback = Callback::OnSave(Box::new({ + OnSaveCallbackData { + doc_id, + view_id, + path, + force, + } + })); Ok(call) } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 57be1267ca52a..c2b267990a6fa 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -18,7 +18,7 @@ use tui::{ use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{ - path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, + path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Range, Selection, }; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, @@ -505,7 +505,7 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -struct CodeActionOrCommandItem { +pub struct CodeActionOrCommandItem { lsp_item: lsp::CodeActionOrCommand, language_server_id: usize, } @@ -582,34 +582,8 @@ pub fn code_action(cx: &mut Context) { let selection_range = doc.selection(view.id).primary(); - let mut seen_language_servers = HashSet::new(); - - let mut futures: FuturesUnordered<_> = doc - .language_servers_with_feature(LanguageServerFeature::CodeAction) - .filter(|ls| seen_language_servers.insert(ls.id())) - // TODO this should probably already been filtered in something like "language_servers_with_feature" - .filter_map(|language_server| { - let offset_encoding = language_server.offset_encoding(); - let language_server_id = language_server.id(); - let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - // Filter and convert overlapping diagnostics - let code_action_context = 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 code_action_request = - language_server.code_actions(doc.identifier(), range, code_action_context)?; - Some((code_action_request, language_server_id)) - }) + let mut futures: FuturesUnordered<_> = code_actions_for_range(doc, selection_range) + .into_iter() .map(|(request, ls_id)| async move { let json = request.await?; let response: Option = serde_json::from_value(json)?; @@ -697,59 +671,140 @@ pub fn code_action(cx: &mut Context) { // always present here let action = action.unwrap(); - let Some(language_server) = editor.language_server_by_id(action.language_server_id) - else { - editor.set_error("Language Server disappeared"); - return; - }; - let offset_encoding = language_server.offset_encoding(); + apply_code_action(editor, action); + }); + picker.move_down(); // pre-select the first item - match &action.lsp_item { - lsp::CodeActionOrCommand::Command(command) => { - log::debug!("code action command: {:?}", command); - execute_lsp_command(editor, action.language_server_id, command.clone()); - } - lsp::CodeActionOrCommand::CodeAction(code_action) => { - log::debug!("code action: {:?}", code_action); - // we support lsp "codeAction/resolve" for `edit` and `command` fields - let mut resolved_code_action = None; - if code_action.edit.is_none() || code_action.command.is_none() { - if let Some(future) = - language_server.resolve_code_action(code_action.clone()) - { - if let Ok(response) = helix_lsp::block_on(future) { - if let Ok(code_action) = - serde_json::from_value::(response) - { - resolved_code_action = Some(code_action); + let popup = Popup::new("code-action", picker).with_scrollbar(false); + compositor.replace_or_push("code-action", popup); + }; + + Ok(Callback::EditorCompositor(Box::new(call))) + }); +} + +pub fn code_actions_for_range( + doc: &Document, + range: helix_core::Range, +) -> Vec<(impl Future>, usize)> { + let mut seen_language_servers = HashSet::new(); + + doc.language_servers_with_feature(LanguageServerFeature::CodeAction) + .filter(|ls| seen_language_servers.insert(ls.id())) + // TODO this should probably already been filtered in something like "language_servers_with_feature" + .filter_map(|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), + }, + ) + .map(|request| (request, language_server.id())) + }) + .collect::>() +} + +/// Finds the first matching code action over all language servers +pub fn code_action_on_save( + doc: &Document, + code_action_on_save: String, +) -> impl Future> { + let full_range = Range::new(0, doc.text().len_chars()); + let futures = code_actions_for_range(doc, full_range); + + async move { + for (request, language_server_id) in futures { + if let Ok(json) = request.await { + if let Ok(Some(available_code_actions)) = + serde_json::from_value::>(json) + { + if let Some(lsp_item) = + available_code_actions + .into_iter() + .find(|action| match action { + helix_lsp::lsp::CodeActionOrCommand::CodeAction(x) + if x.disabled.is_none() => + { + match &x.kind { + Some(kind) => code_action_on_save == kind.as_str(), + None => false, } } - } - } - let resolved_code_action = - resolved_code_action.as_ref().unwrap_or(code_action); + _ => false, + }) + { + return Some(CodeActionOrCommandItem { + lsp_item, + language_server_id, + }); + } + } + } + } + None + } +} - if let Some(ref workspace_edit) = resolved_code_action.edit { - log::debug!("edit: {:?}", workspace_edit); - let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit); - } +pub fn apply_code_action(editor: &mut Editor, action: &CodeActionOrCommandItem) { + let Some(language_server) = editor.language_server_by_id(action.language_server_id) + else { + editor.set_error("Language Server disappeared"); + return; + }; + let offset_encoding = language_server.offset_encoding(); - // 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, action.language_server_id, command.clone()); + match &action.lsp_item { + lsp::CodeActionOrCommand::Command(command) => { + log::debug!("code action command: {:?}", command); + execute_lsp_command(editor, action.language_server_id, command.clone()); + } + lsp::CodeActionOrCommand::CodeAction(code_action) => { + log::debug!("code action: {:?}", code_action); + // we support lsp "codeAction/resolve" for `edit` and `command` fields + let mut resolved_code_action = None; + if code_action.edit.is_none() || code_action.command.is_none() { + if let Some(future) = language_server.resolve_code_action(code_action.clone()) { + if let Ok(response) = helix_lsp::block_on(future) { + if let Ok(code_action) = serde_json::from_value::(response) { + resolved_code_action = Some(code_action); } } } - }); - picker.move_down(); // pre-select the first item + } + let resolved_code_action = resolved_code_action.as_ref().unwrap_or(code_action); - let popup = Popup::new("code-action", picker).with_scrollbar(false); - compositor.replace_or_push("code-action", popup); - }; + if let Some(ref workspace_edit) = resolved_code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit); + } - Ok(Callback::EditorCompositor(Box::new(call))) - }); + // 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, action.language_server_id, command.clone()); + } + } + } } impl ui::menu::Item for lsp::Command { diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index abe6dd97ec895..ce60611f9e5e4 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -10,6 +10,7 @@ use helix_core::{encoding, line_ending, shellwords::Shellwords}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; + use ui::completers::{self, Completer}; #[derive(Clone)] @@ -339,26 +340,8 @@ fn write_impl( insert_final_newline(doc, view); } - let fmt = if config.auto_format { - doc.auto_format().map(|fmt| { - let callback = make_format_callback( - doc.id(), - doc.version(), - view.id, - fmt, - Some((path.map(Into::into), force)), - ); - - jobs.add(Job::with_callback(callback).wait_before_exiting()); - }) - } else { - None - }; - - if fmt.is_none() { - let id = doc.id(); - cx.editor.save(id, path, force)?; - } + let callback = make_on_save_callback(doc.id(), view.id, path.map(Into::into), force); + jobs.add(Job::with_callback(callback).wait_before_exiting()); Ok(()) } @@ -452,7 +435,7 @@ fn format( let (view, doc) = current!(cx.editor); if let Some(format) = doc.format() { - let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None); + let callback = make_format_callback(doc.id(), doc.version(), view.id, format); cx.jobs.callback(callback); } @@ -717,24 +700,8 @@ pub fn write_all_impl( insert_final_newline(doc, view_mut!(cx.editor, target_view)); } - let fmt = if config.auto_format { - doc.auto_format().map(|fmt| { - let callback = make_format_callback( - doc_id, - doc.version(), - target_view, - fmt, - Some((None, force)), - ); - jobs.add(Job::with_callback(callback).wait_before_exiting()); - }) - } else { - None - }; - - if fmt.is_none() { - cx.editor.save::(doc_id, None, force)?; - } + let callback = make_on_save_callback(doc.id(), target_view, None, force); + jobs.add(Job::with_callback(callback).wait_before_exiting()); } if !errors.is_empty() && !force { diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index 19f2521a5231d..051405aa05762 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -1,5 +1,8 @@ -use helix_view::Editor; +use std::path::PathBuf; +use helix_view::{DocumentId, Editor, ViewId}; + +use crate::commands::on_save_callback; use crate::compositor::Compositor; use futures_util::future::{BoxFuture, Future, FutureExt}; @@ -7,19 +10,27 @@ use futures_util::stream::{FuturesUnordered, StreamExt}; pub type EditorCompositorCallback = Box; pub type EditorCallback = Box; +pub type OnSaveCallback = Box; pub enum Callback { EditorCompositor(EditorCompositorCallback), Editor(EditorCallback), + OnSave(OnSaveCallback), } pub type JobFuture = BoxFuture<'static, anyhow::Result>>; pub struct Job { - pub future: BoxFuture<'static, anyhow::Result>>, + pub future: JobFuture, /// Do we need to wait for this job to finish before exiting? pub wait: bool, } +pub struct OnSaveCallbackData { + pub doc_id: DocumentId, + pub view_id: ViewId, + pub path: Option, + pub force: bool, +} #[derive(Default)] pub struct Jobs { @@ -67,7 +78,7 @@ impl Jobs { self.add(Job::with_callback(f)); } - pub fn handle_callback( + pub async fn handle_callback( &self, editor: &mut Editor, compositor: &mut Compositor, @@ -78,6 +89,16 @@ impl Jobs { Ok(Some(call)) => match call { Callback::EditorCompositor(call) => call(editor, compositor), Callback::Editor(call) => call(editor), + Callback::OnSave(callback_data) => { + on_save_callback( + editor, + callback_data.doc_id, + callback_data.view_id, + callback_data.path, + callback_data.force, + ) + .await + } }, Err(e) => { editor.set_error(format!("Async job failed: {}", e));