diff --git a/book/src/languages.md b/book/src/languages.md index 016969e8..845b496a 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -75,6 +75,7 @@ These configuration keys are available: | `workspace-lsp-roots` | Directories (relative to the workspace root) that stop the upward root search early. Meant for project-specific hard overrides in a local `.helix/config.toml`; | | `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. | `rainbow-brackets` | Overrides the `editor.rainbow-brackets` config key for the language | +| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` | ## Project and LSP root selection diff --git a/helix-core/src/syntax/config.rs b/helix-core/src/syntax/config.rs index 5cef5ce1..f4e22ad9 100644 --- a/helix-core/src/syntax/config.rs +++ b/helix-core/src/syntax/config.rs @@ -56,6 +56,9 @@ pub struct LanguageConfiguration { #[serde(default)] pub auto_format: bool, + #[serde(default)] + pub code_actions_on_save: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub formatter: Option, diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index c1795a5a..9aa4b2c4 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -327,7 +327,9 @@ impl Application { self.handle_terminal_events(event).await; } Some(callback) = self.jobs.callbacks.recv() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))); + if let Some(job) = self.jobs.handle_callback(&mut self.editor, &mut self.compositor, Ok(Some(callback))) { + self.jobs.add(job); + } self.render().await; } Some(msg) = self.jobs.status_messages.recv() => { @@ -342,7 +344,9 @@ impl Application { helix_event::request_redraw(); } Some(callback) = self.jobs.wait_futures.next() => { - self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback); + if let Some(job) = self.jobs.handle_callback(&mut self.editor, &mut self.compositor, callback) { + self.jobs.add(job); + } self.render().await; } event = self.editor.wait_event() => { diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 66cfbfc6..abade06f 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -2,8 +2,8 @@ use futures_util::{stream::FuturesUnordered, FutureExt}; use helix_lsp::{ block_on, lsp::{ - self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, - NumberOrString, + self, CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionTriggerKind, + DiagnosticSeverity, NumberOrString, }, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, Client, LanguageServerId, OffsetEncoding, @@ -23,16 +23,22 @@ use helix_view::{ editor::Action, handlers::lsp::SignatureHelpInvoked, theme::Style, - Document, View, + Document, DocumentId, View, }; use crate::{ compositor::{self, Compositor}, - job::Callback, + job::{Callback, Job}, ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, }; -use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path}; +use std::{ + cmp::Ordering, + collections::{HashSet, VecDeque}, + fmt::Display, + future::Future, + path::Path, +}; /// Gets the first language server that is attached to a document which supports a specific feature. /// If there is no configured language server that supports the feature, this displays a status message. @@ -659,34 +665,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, None) + .into_iter() .map(|(request, ls_id)| async move { let Some(mut actions) = request.await? else { return anyhow::Ok(Vec::new()); @@ -788,19 +768,12 @@ pub fn code_action(cx: &mut Context) { } 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) { - if let Ok(code_action) = helix_lsp::block_on(future) { - resolved_code_action = Some(code_action); - } - } - } let resolved_code_action = - resolved_code_action.as_ref().unwrap_or(code_action); + resolve_code_action_blocking(code_action, language_server); - if let Some(ref workspace_edit) = resolved_code_action.edit { + if let Some(ref workspace_edit) = + resolved_code_action.as_ref().unwrap_or(code_action).edit + { let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit); } @@ -825,6 +798,186 @@ pub fn code_action(cx: &mut Context) { }); } +// Extracting this to a type alias would require boxing this future +#[allow(clippy::type_complexity)] +fn code_actions_for_range( + doc: &Document, + range: helix_core::Range, + only: Option>, +) -> Vec<( + impl Future>, helix_lsp::Error>>, + LanguageServerId, +)> { + 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 language_server_id = language_server.id(); + let lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding); + // Filter and convert overlapping diagnostics + let code_action_context = 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: only.clone(), + trigger_kind: Some(CodeActionTriggerKind::INVOKED), + }; + let code_action_request = + language_server.code_actions(doc.identifier(), lsp_range, code_action_context)?; + Some((code_action_request, language_server_id)) + }) + .collect::>() +} + +/// Build the job chain that runs a document's configured `code-actions-on-save` +/// kinds, in order, each against the latest document state, before running +/// `tail` (the auto-format / save job). Returns `tail` unchanged when nothing is +/// configured. +pub fn code_actions_on_save( + cx: &compositor::Context, + doc_id: DocumentId, + tail: Option, +) -> Option { + let kinds = doc!(cx.editor, &doc_id) + .language_config() + .and_then(|config| config.code_actions_on_save.clone()); + let Some(kinds) = kinds else { + return tail; + }; + let kinds: VecDeque = kinds.into_iter().map(CodeActionKind::from).collect(); + code_action_on_save_step(doc_id, kinds, tail) +} + +/// One link of the on-save chain: request the next kind's actions on the current +/// document, apply them, then recurse for the rest. Each link is built lazily (as +/// a `Followup`) so it reads the document after the previous link's edits, and +/// runs the configured kinds strictly in order. +fn code_action_on_save_step( + doc_id: DocumentId, + mut kinds: VecDeque, + tail: Option, +) -> Option { + let Some(kind) = kinds.pop_front() else { + // All kinds applied. Fall through to auto-format / save. + return tail; + }; + + // Runs with `&mut Editor`, so it sees the document the previous link left. + let build_request = move |editor: &mut Editor| -> Option { + let doc = doc!(editor, &doc_id); + let version = doc.version(); + let full_range = helix_core::Range::new(0, doc.text().len_chars()); + let Some((request, ls_id)) = + code_actions_for_range(doc, full_range, Some(vec![kind.clone()])) + .into_iter() + .next() + else { + // No server offers this kind for the document: skip to the next. + return code_action_on_save_step(doc_id, kinds, tail); + }; + + let future = async move { + let actions = request.await?; + let apply = move |editor: &mut Editor| -> Option { + apply_code_actions_of_kind(editor, doc_id, version, ls_id, &kind, actions); + code_action_on_save_step(doc_id, kinds, tail) + }; + Ok(Callback::Followup(Box::new(apply))) + }; + Some(Job::with_callback(future).wait_before_exiting()) + }; + + Some( + Job::with_callback(async move { Ok(Callback::Followup(Box::new(build_request))) }) + .wait_before_exiting(), + ) +} + +/// An action matches a requested kind when its kind equals the request or is a +/// sub-kind (`source.fixAll.eslint` matches `source.fixAll`, per the LSP kind +/// hierarchy). Actions without a kind are skipped, so a server that ignores the +/// `only` filter can't slip an unrelated action into a save. +fn code_action_kind_matches(action: &CodeAction, requested: &CodeActionKind) -> bool { + action.kind.as_ref().is_some_and(|kind| { + let (kind, requested) = (kind.as_str(), requested.as_str()); + kind == requested + || (kind.starts_with(requested) && kind[requested.len()..].starts_with('.')) + }) +} + +/// Apply every returned action whose kind matches `kind` (servers may ignore the +/// `only` filter, and `source.fixAll` legitimately resolves to several actions), +/// resolving any that need it. Skips entirely if the document changed between the +/// request and now, so a stale edit can't be applied at positions that moved. +fn apply_code_actions_of_kind( + editor: &mut Editor, + doc_id: DocumentId, + version: i32, + ls_id: LanguageServerId, + kind: &CodeActionKind, + actions: Option>, +) { + let Some(actions) = actions else { + return; + }; + if doc!(editor, &doc_id).version() != version { + log::debug!("code-actions-on-save: document changed, skipping {kind:?}"); + return; + } + let Some(language_server) = editor.language_server_by_id(ls_id) else { + return; + }; + let offset_encoding = language_server.offset_encoding(); + + // Collect the (resolved) edits while holding the immutable language-server + // borrow, then apply them once the borrow is dropped. + let edits: Vec = actions + .iter() + .filter_map(|action| match action { + CodeActionOrCommand::CodeAction(code_action) if code_action.disabled.is_none() => { + Some(code_action) + } + _ => None, + }) + .filter(|code_action| code_action_kind_matches(code_action, kind)) + .filter_map(|code_action| { + match resolve_code_action_blocking(code_action, language_server) { + Some(resolved) => resolved.edit, + None => code_action.edit.clone(), + } + }) + .collect(); + + for edit in edits { + if let Err(err) = editor.apply_workspace_edit(offset_encoding, &edit) { + log::error!("code-actions-on-save: failed to apply workspace edit: {err:?}"); + } + } +} + +pub fn resolve_code_action_blocking( + code_action: &CodeAction, + language_server: &Client, +) -> Option { + 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) { + if let Ok(code_action) = helix_lsp::block_on(future) { + resolved_code_action = Some(code_action); + } + } + } + resolved_code_action +} + #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index e393bc08..92ee9a38 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -80,6 +80,7 @@ fn exit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow: WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; } @@ -99,6 +100,7 @@ fn force_exit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> a WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; } @@ -381,42 +383,76 @@ fn write_impl( options: WriteOptions, ) -> anyhow::Result<()> { let config = cx.editor.config(); - let jobs = &mut cx.jobs; let (view, doc) = current!(cx.editor); + let doc_id = doc.id(); + let view_id = view.id; if doc.trim_trailing_whitespace() { - trim_trailing_whitespace(doc, view.id); + trim_trailing_whitespace(doc, view_id); } if config.trim_final_newlines { - trim_final_newlines(doc, view.id); + trim_final_newlines(doc, view_id); } if doc.insert_final_newline() { - insert_final_newline(doc, view.id); + insert_final_newline(doc, view_id); } // Save an undo checkpoint for any outstanding changes. doc.append_changes_to_history(view); - let (view, doc) = current_ref!(cx.editor); - let fmt = if config.auto_format && options.auto_format { - doc.auto_format(cx.editor).map(|fmt| { - let callback = make_format_callback( - doc.id(), - doc.version(), - view.id, - fmt, - Some((path.map(Into::into), options.force)), - ); + let auto_format = config.auto_format && options.auto_format; + let force = options.force; + let path: Option = path.map(Into::into); + + // Does the document configure any code actions to run on save? + let run_code_actions = options.code_actions + && doc!(cx.editor, &doc_id) + .language_config() + .and_then(|c| c.code_actions_on_save.as_deref()) + .is_some_and(|kinds| !kinds.is_empty()); + + // The tail of the on-save chain: re-build the auto-format job against the + // latest document (so it formats after any code-action edits), or save + // directly when there's no formatter. Deferred via `Followup`, and always + // saves, so code-actions-on-save works even with auto-format off. Only + // built when there is pre-save work. A plain `:w` saves synchronously below. + let tail = (auto_format || run_code_actions).then(|| { + let path = path.clone(); + let callback = Callback::Followup(Box::new(move |editor| { + let doc = doc!(editor, &doc_id); + let fmt_job = auto_format + .then(|| doc.auto_format(editor)) + .flatten() + .map(|fmt| { + let call = make_format_callback( + doc_id, + doc.version(), + view_id, + fmt, + Some((path.clone(), force)), + ); + Job::with_callback(call).wait_before_exiting() + }); + if fmt_job.is_none() { + if let Err(err) = editor.save(doc_id, path, force) { + editor.set_error(format!("Error saving: {}", err)); + } + } + fmt_job + })); + Job::with_callback(async { Ok(callback) }).wait_before_exiting() + }); - jobs.add(Job::with_callback(callback).wait_before_exiting()); - }) + let job = if run_code_actions { + code_actions_on_save(cx, doc_id, tail) } else { - None + tail }; - if fmt.is_none() { - let id = doc.id(); - cx.editor.save(id, path, options.force)?; + if let Some(job) = job { + cx.jobs.add(job); + } else { + cx.editor.save(doc_id, path, force)?; } Ok(()) @@ -485,6 +521,7 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { pub struct WriteOptions { pub force: bool, pub auto_format: bool, + pub code_actions: bool, } fn write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { @@ -498,6 +535,7 @@ fn write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ) } @@ -513,6 +551,7 @@ fn force_write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ) } @@ -532,6 +571,7 @@ fn write_buffer_close( WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; @@ -554,6 +594,7 @@ fn force_write_buffer_close( WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; @@ -742,6 +783,7 @@ fn write_quit(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> a WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; cx.block_try_flush_writes()?; @@ -763,6 +805,7 @@ fn force_write_quit( WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; cx.block_try_flush_writes()?; @@ -807,6 +850,7 @@ pub struct WriteAllOptions { pub force: bool, pub write_scratch: bool, pub auto_format: bool, + pub code_actions: bool, } pub fn write_all_impl( @@ -815,7 +859,6 @@ pub fn write_all_impl( ) -> anyhow::Result<()> { let mut errors: Vec<&'static str> = Vec::new(); let config = cx.editor.config(); - let jobs = &mut cx.jobs; let saves: Vec<_> = cx .editor .documents @@ -858,24 +901,53 @@ pub fn write_all_impl( // Save an undo checkpoint for any outstanding changes. doc.append_changes_to_history(view); - let fmt = if options.auto_format && config.auto_format { - let doc = doc!(cx.editor, &doc_id); - doc.auto_format(cx.editor).map(|fmt| { - let callback = make_format_callback( - doc_id, - doc.version(), - target_view, - fmt, - Some((None, options.force)), - ); - jobs.add(Job::with_callback(callback).wait_before_exiting()); - }) + let auto_format = config.auto_format && options.auto_format; + let force = options.force; + + let run_code_actions = options.code_actions + && doc!(cx.editor, &doc_id) + .language_config() + .and_then(|c| c.code_actions_on_save.as_deref()) + .is_some_and(|kinds| !kinds.is_empty()); + + // See `write_impl`: deferred format-or-save tail that always saves, only + // built when there is pre-save work; otherwise a synchronous save below. + let tail = (auto_format || run_code_actions).then(|| { + let callback: job::Callback = Callback::Followup(Box::new(move |editor| { + let doc = doc!(editor, &doc_id); + let fmt_job = auto_format + .then(|| doc.auto_format(editor)) + .flatten() + .map(|fmt| { + let call = make_format_callback( + doc_id, + doc.version(), + target_view, + fmt, + Some((None, force)), + ); + Job::with_callback(call).wait_before_exiting() + }); + if fmt_job.is_none() { + if let Err(err) = editor.save::(doc_id, None, force) { + editor.set_error(format!("Error saving: {}", err)); + } + } + fmt_job + })); + Job::with_callback(async { Ok(callback) }).wait_before_exiting() + }); + + let job = if run_code_actions { + code_actions_on_save(cx, doc_id, tail) } else { - None + tail }; - if fmt.is_none() { - cx.editor.save::(doc_id, None, options.force)?; + if let Some(job) = job { + cx.jobs.add(job); + } else { + cx.editor.save::(doc_id, None, force)?; } } @@ -897,6 +969,7 @@ fn write_all(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> an force: false, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ) } @@ -916,6 +989,7 @@ fn force_write_all( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ) } @@ -934,6 +1008,7 @@ fn write_all_quit( force: false, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, )?; quit_all_impl(cx, false) @@ -953,6 +1028,7 @@ fn force_write_all_quit( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ); quit_all_impl(cx, true) @@ -1607,6 +1683,7 @@ fn update(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyho WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + code_actions: !args.has_flag(WRITE_NO_CODE_ACTIONS_FLAG.name), }, ) } else { @@ -2912,6 +2989,12 @@ const WRITE_NO_FORMAT_FLAG: Flag = Flag { ..Flag::DEFAULT }; +const WRITE_NO_CODE_ACTIONS_FLAG: Flag = Flag { + name: "no-code-actions", + doc: "skip code actions on save", + ..Flag::DEFAULT +}; + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "exit", @@ -2921,7 +3004,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -2933,7 +3016,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3060,7 +3143,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3072,7 +3155,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG,WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3084,7 +3167,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG,WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3096,7 +3179,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG,WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3177,7 +3260,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3189,7 +3272,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::positional(&[completers::filename]), signature: Signature { positionals: (0, Some(1)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3201,7 +3284,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::none(), signature: Signature { positionals: (0, Some(0)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3213,7 +3296,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::none(), signature: Signature { positionals: (0, Some(0)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3225,7 +3308,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::none(), signature: Signature { positionals: (0, Some(0)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, @@ -3237,7 +3320,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ completer: CommandCompleter::none(), signature: Signature { positionals: (0, Some(0)), - flags: &[WRITE_NO_FORMAT_FLAG], + flags: &[WRITE_NO_FORMAT_FLAG, WRITE_NO_CODE_ACTIONS_FLAG], ..Signature::DEFAULT }, }, diff --git a/helix-term/src/handlers/auto_save.rs b/helix-term/src/handlers/auto_save.rs index 47e2ecfd..f169e19b 100644 --- a/helix-term/src/handlers/auto_save.rs +++ b/helix-term/src/handlers/auto_save.rs @@ -91,6 +91,7 @@ fn request_auto_save(editor: &mut Editor) { force: false, write_scratch: false, auto_format: false, + code_actions: false, }; if let Err(e) = commands::typed::write_all_impl(context, options) { diff --git a/helix-term/src/job.rs b/helix-term/src/job.rs index 72ed892d..9dce802e 100644 --- a/helix-term/src/job.rs +++ b/helix-term/src/job.rs @@ -11,6 +11,7 @@ use tokio::sync::mpsc::{channel, Receiver, Sender}; pub type EditorCompositorCallback = Box; pub type EditorCallback = Box; +pub type EditorCallbackFollowup = Box Option + Send>; runtime_local! { static JOB_QUEUE: OnceCell> = OnceCell::new(); @@ -35,6 +36,7 @@ pub fn dispatch_blocking(job: impl FnOnce(&mut Editor, &mut Compositor) + Send + pub enum Callback { EditorCompositor(EditorCompositorCallback), Editor(EditorCallback), + Followup(EditorCallbackFollowup), } pub type JobFuture = BoxFuture<'static, anyhow::Result>>; @@ -104,15 +106,23 @@ impl Jobs { editor: &mut Editor, compositor: &mut Compositor, call: anyhow::Result>, - ) { + ) -> Option { match call { - Ok(None) => {} + Ok(None) => None, Ok(Some(call)) => match call { - Callback::EditorCompositor(call) => call(editor, compositor), - Callback::Editor(call) => call(editor), + Callback::EditorCompositor(call) => { + call(editor, compositor); + None + } + Callback::Editor(call) => { + call(editor); + None + } + Callback::Followup(call) => call(editor), }, Err(e) => { editor.set_error(format!("Async job failed: {}", e)); + None } } } @@ -148,14 +158,23 @@ impl Jobs { if let Some(callback) = callback { // clippy doesn't realize this is an error without the derefs #[allow(clippy::needless_option_as_deref)] - match callback { + if let Some(job) = match callback { Callback::EditorCompositor(call) if compositor.is_some() => { - call(editor, compositor.as_deref_mut().unwrap()) + call(editor, compositor.as_deref_mut().unwrap()); + None } - Callback::Editor(call) => call(editor), + Callback::Editor(call) => { + call(editor); + None + } + Callback::Followup(call) => call(editor), // skip callbacks for which we don't have the necessary references - _ => (), + _ => None, + } { + if job.wait { + wait_futures.push(job.future); + } } } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 37869218..5127a7be 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -1603,6 +1603,7 @@ impl Component for EditorView { force: false, write_scratch: false, auto_format: false, + code_actions: false, }; if let Err(e) = commands::typed::write_all_impl(context, options) { context.editor.set_error(format!("{}", e)); diff --git a/helix-term/tests/test/commands/write.rs b/helix-term/tests/test/commands/write.rs index 5b4dd148..fa6c97c5 100644 --- a/helix-term/tests/test/commands/write.rs +++ b/helix-term/tests/test/commands/write.rs @@ -451,6 +451,56 @@ async fn test_write_auto_format_fails_still_writes() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread")] +async fn test_write_quit_auto_format_exits_after_format() -> anyhow::Result<()> { + let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?; + + let lang_conf = indoc! {r#" + [[language]] + name = "rust" + formatter = { command = "bash", args = [ "-c", "echo new content" ] } + "#}; + + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .with_input_text("#[l|]#et foo = 0;\n") + .with_lang_loader(helpers::test_syntax_loader(Some(lang_conf.into()))) + .build()?; + + test_key_sequences(&mut app, vec![(Some(":x"), None)], true).await?; + + // file saves with new content and editor exits after save + helpers::assert_file_has_content(&mut file, "new content\n")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_write_code_actions_on_save_without_server_still_saves() -> anyhow::Result<()> { + let mut file = tempfile::Builder::new().suffix(".rs").tempfile()?; + + // Code actions are configured but no language server is attached in tests, so none run. With + // auto-format also off the on-save chain has no formatter tail either. The save should still + // occur. + let lang_conf = indoc! {r#" + [[language]] + name = "rust" + code-actions-on-save = ["source.organizeImports"] + "#}; + + let mut app = helpers::AppBuilder::new() + .with_file(file.path(), None) + .with_input_text("#[l|]#et foo = 0;\n") + .with_lang_loader(helpers::test_syntax_loader(Some(lang_conf.into()))) + .build()?; + + test_key_sequences(&mut app, vec![(Some(":w --no-format"), None)], false).await?; + + helpers::assert_file_has_content(&mut file, "let foo = 0;\n")?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn test_write_new_path() -> anyhow::Result<()> { let mut file1 = tempfile::NamedTempFile::new().unwrap();