From dba253f1b3f9bf506eca6eedb22a3351dc280994 Mon Sep 17 00:00:00 2001 From: gustaf Date: Sun, 7 Sep 2025 21:05:19 +0200 Subject: [PATCH 1/4] feature: willSave + willSaveWaitUntil --- helix-lsp/src/client.rs | 47 +++++++++++++++++++++++++++- helix-term/src/commands.rs | 4 ++- helix-term/src/commands/typed.rs | 22 +++++++++++-- helix-term/src/handlers/auto_save.rs | 2 ++ helix-term/src/ui/editor.rs | 2 ++ helix-view/src/editor.rs | 40 +++++++++++++++++++++-- 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index afb3b3a56aaf..6223d0fd06ac 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -994,7 +994,52 @@ impl Client { }) } - // will_save / will_save_wait_until + pub fn text_document_will_save( + &self, + text_document: lsp::TextDocumentIdentifier, + reason: lsp::TextDocumentSaveReason + ) -> Option<()> { + let capabilities = self.capabilities.get().unwrap(); + + match &capabilities.text_document_sync.as_ref()? { + lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { + will_save: enabled, + .. + }) => if !*enabled.as_ref()? { + return None + }, + _ => return None + }; + + self.notify::(lsp::WillSaveTextDocumentParams{ + text_document, + reason, + }); + Some(()) + } + + pub fn text_document_will_save_wait_until( + &self, + text_document: lsp::TextDocumentIdentifier, + reason: lsp::TextDocumentSaveReason + ) -> Option>>>> { + let capabilities = self.capabilities.get().unwrap(); + + match &capabilities.text_document_sync.as_ref()? { + lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { + will_save_wait_until: enabled, + .. + }) => if !*enabled.as_ref()? { + return None + }, + _ => return None + }; + + Some(self.call_with_timeout::(&lsp::WillSaveTextDocumentParams{ + text_document, + reason, + }, 5)) + } pub fn text_document_did_save( &self, diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 8edf59441f76..814e26fb5b39 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -6,6 +6,7 @@ pub(crate) mod typed; pub use dap::*; use futures_util::FutureExt; use helix_event::status; +use helix_lsp::lsp::TextDocumentSaveReason; use helix_stdx::{ path::{self, find_paths}, rope::{self, RopeSliceExt}, @@ -3590,6 +3591,7 @@ async fn make_format_callback( view_id: ViewId, format: impl Future> + Send + 'static, write: Option<(Option, bool)>, + reason: TextDocumentSaveReason, ) -> anyhow::Result { let format = format.await; @@ -3624,7 +3626,7 @@ async fn make_format_callback( if let Some((path, force)) = write { let id = doc.id(); - if let Err(err) = editor.save(id, path, force) { + if let Err(err) = editor.save(id, path, force, reason) { editor.set_error(format!("Error saving: {}", err)); } } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index d26b47054aed..0bce4d597dc9 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -10,6 +10,7 @@ use helix_core::command_line::{Args, Flag, Signature, Token, TokenKind}; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::line_ending; +use helix_lsp::lsp::TextDocumentSaveReason; use helix_stdx::path::home_dir; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; @@ -368,6 +369,7 @@ fn write_impl( view.id, fmt, Some((path.map(Into::into), options.force)), + options.reason ); jobs.add(Job::with_callback(callback).wait_before_exiting()); @@ -378,7 +380,7 @@ fn write_impl( if fmt.is_none() { let id = doc.id(); - cx.editor.save(id, path, options.force)?; + cx.editor.save(id, path, options.force, options.reason)?; } Ok(()) @@ -447,6 +449,7 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { pub struct WriteOptions { pub force: bool, pub auto_format: bool, + pub reason: TextDocumentSaveReason } fn write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { @@ -460,6 +463,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), + reason: TextDocumentSaveReason::MANUAL, }, ) } @@ -475,6 +479,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), + reason: TextDocumentSaveReason::MANUAL, }, ) } @@ -494,6 +499,7 @@ fn write_buffer_close( WriteOptions { force: false, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL, }, )?; @@ -516,6 +522,7 @@ fn force_write_buffer_close( WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL, }, )?; @@ -542,7 +549,7 @@ fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh let format = doc.format(cx.editor).context( "A formatter isn't available, and no language server provides formatting capabilities", )?; - 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, None, TextDocumentSaveReason::MANUAL); cx.jobs.callback(callback); Ok(()) @@ -704,6 +711,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), + reason: TextDocumentSaveReason::MANUAL, }, )?; cx.block_try_flush_writes()?; @@ -725,6 +733,7 @@ fn force_write_quit( WriteOptions { force: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL, }, )?; cx.block_try_flush_writes()?; @@ -769,6 +778,7 @@ pub struct WriteAllOptions { pub force: bool, pub write_scratch: bool, pub auto_format: bool, + pub reason: TextDocumentSaveReason, } pub fn write_all_impl( @@ -829,6 +839,7 @@ pub fn write_all_impl( target_view, fmt, Some((None, options.force)), + options.reason ); jobs.add(Job::with_callback(callback).wait_before_exiting()); }) @@ -837,7 +848,7 @@ pub fn write_all_impl( }; if fmt.is_none() { - cx.editor.save::(doc_id, None, options.force)?; + cx.editor.save::(doc_id, None, options.force, options.reason)?; } } @@ -859,6 +870,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), + reason: TextDocumentSaveReason::MANUAL, }, ) } @@ -878,6 +890,7 @@ fn force_write_all( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL }, ) } @@ -896,6 +909,7 @@ fn write_all_quit( force: false, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL }, )?; quit_all_impl(cx, false) @@ -915,6 +929,7 @@ fn force_write_all_quit( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), + reason: TextDocumentSaveReason::MANUAL }, ); quit_all_impl(cx, true) @@ -1478,6 +1493,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), + reason: TextDocumentSaveReason::MANUAL, }, ) } else { diff --git a/helix-term/src/handlers/auto_save.rs b/helix-term/src/handlers/auto_save.rs index 47e2ecfdf13d..e401ffd7fb5d 100644 --- a/helix-term/src/handlers/auto_save.rs +++ b/helix-term/src/handlers/auto_save.rs @@ -10,6 +10,7 @@ use anyhow::Ok; use arc_swap::access::Access; use helix_event::{register_hook, send_blocking}; +use helix_lsp::lsp::TextDocumentSaveReason; use helix_view::{ document::Mode, events::DocumentDidChange, @@ -91,6 +92,7 @@ fn request_auto_save(editor: &mut Editor) { force: false, write_scratch: false, auto_format: false, + reason: TextDocumentSaveReason::AFTER_DELAY, }; if let Err(e) = commands::typed::write_all_impl(context, options) { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b25af107d796..8f6d7a94fe5e 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -22,6 +22,7 @@ use helix_core::{ unicode::width::UnicodeWidthStr, visual_offset_from_block, Change, Position, Range, Selection, Transaction, }; +use helix_lsp::lsp::TextDocumentSaveReason; use helix_view::{ annotations::diagnostics::DiagnosticFilter, document::{Mode, SCRATCH_BUFFER_NAME}, @@ -1511,6 +1512,7 @@ impl Component for EditorView { force: false, write_scratch: false, auto_format: false, + reason: TextDocumentSaveReason::FOCUS_OUT, }; if let Err(e) = commands::typed::write_all_impl(context, options) { context.editor.set_error(format!("{}", e)); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index cd8560e09ae9..3f90e21240f2 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -19,7 +19,7 @@ use helix_vcs::DiffProviderRegistry; use futures_util::stream::select_all::SelectAll; use futures_util::{future, StreamExt}; -use helix_lsp::{Call, LanguageServerId}; +use helix_lsp::{lsp::TextDocumentSaveReason, Call, LanguageServerId}; use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ @@ -1948,14 +1948,50 @@ impl Editor { doc_id: DocumentId, path: Option

, force: bool, + reason: TextDocumentSaveReason ) -> anyhow::Result<()> { // convert a channel of futures to pipe into main queue one by one // via stream.then() ? then push into main future let path = path.map(|path| path.into()); + let doc = self.documents.get(&doc_id).unwrap(); + let identifier = doc.identifier().clone(); + let url = doc.url(); + + let language_servers: Vec<_> = self + .language_servers + .iter_clients() + .filter(|client| client.is_initialized()) + .cloned() + .collect(); + for language_server in language_servers { + language_server.text_document_will_save(identifier.clone(), reason); + + let Some(url) = url.clone() else { + continue; + }; + let Some(request) = language_server.text_document_will_save_wait_until(identifier.clone(), reason) else { + continue; + }; + let edits = match helix_lsp::block_on(request) { + Ok(edits) => edits.unwrap_or_default(), + Err(err) => { + log::error!("invalid willSaveWaitUntil response: {err:?}"); + continue; + } + }; + let edit = lsp::WorkspaceEdit { + changes: Some(HashMap::from([(url, edits)])), + document_changes: None, + change_annotations: None, + }; + if let Err(err) = self.apply_workspace_edit(language_server.offset_encoding(), &edit) { + log::error!("failed to apply workspace edit: {err:?}") + } + } + let doc = doc_mut!(self, &doc_id); let doc_save_future = doc.save(path, force)?; - // When a file is written to, notify the file event handler. // Note: This can be removed once proper file watching is implemented. let handler = self.language_servers.file_event_handler.clone(); From 819c02e7e55832f8bd90378b6865b0a3ea9b5bad Mon Sep 17 00:00:00 2001 From: gustaf Date: Sun, 7 Sep 2025 21:14:21 +0200 Subject: [PATCH 2/4] fix: better response handling --- helix-view/src/editor.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 3f90e21240f2..45cbf0b00317 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1974,7 +1974,8 @@ impl Editor { continue; }; let edits = match helix_lsp::block_on(request) { - Ok(edits) => edits.unwrap_or_default(), + Ok(Some(edits)) => edits, + Ok(None) => continue, Err(err) => { log::error!("invalid willSaveWaitUntil response: {err:?}"); continue; From 078485b4a309e72741e99debcbfa93c90c382f92 Mon Sep 17 00:00:00 2001 From: gustaf Date: Mon, 8 Sep 2025 20:02:29 +0200 Subject: [PATCH 3/4] fix: linting + identifier for new documents --- helix-lsp/src/client.rs | 37 ++++++++++------- helix-term/src/commands/typed.rs | 24 +++++++---- helix-view/src/editor.rs | 71 +++++++++++++++++--------------- 3 files changed, 76 insertions(+), 56 deletions(-) diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 6223d0fd06ac..2ff211b63f9d 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -997,7 +997,7 @@ impl Client { pub fn text_document_will_save( &self, text_document: lsp::TextDocumentIdentifier, - reason: lsp::TextDocumentSaveReason + reason: lsp::TextDocumentSaveReason, ) -> Option<()> { let capabilities = self.capabilities.get().unwrap(); @@ -1005,13 +1005,15 @@ impl Client { lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { will_save: enabled, .. - }) => if !*enabled.as_ref()? { - return None - }, - _ => return None + }) => { + if !*enabled.as_ref()? { + return None; + } + } + _ => return None, }; - self.notify::(lsp::WillSaveTextDocumentParams{ + self.notify::(lsp::WillSaveTextDocumentParams { text_document, reason, }); @@ -1021,7 +1023,7 @@ impl Client { pub fn text_document_will_save_wait_until( &self, text_document: lsp::TextDocumentIdentifier, - reason: lsp::TextDocumentSaveReason + reason: lsp::TextDocumentSaveReason, ) -> Option>>>> { let capabilities = self.capabilities.get().unwrap(); @@ -1029,16 +1031,21 @@ impl Client { lsp::TextDocumentSyncCapability::Options(lsp::TextDocumentSyncOptions { will_save_wait_until: enabled, .. - }) => if !*enabled.as_ref()? { - return None - }, - _ => return None + }) => { + if !*enabled.as_ref()? { + return None; + } + } + _ => return None, }; - Some(self.call_with_timeout::(&lsp::WillSaveTextDocumentParams{ - text_document, - reason, - }, 5)) + Some(self.call_with_timeout::( + &lsp::WillSaveTextDocumentParams { + text_document, + reason, + }, + 5, + )) } pub fn text_document_did_save( diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 0bce4d597dc9..9def9c6a9dfa 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -369,7 +369,7 @@ fn write_impl( view.id, fmt, Some((path.map(Into::into), options.force)), - options.reason + options.reason, ); jobs.add(Job::with_callback(callback).wait_before_exiting()); @@ -449,7 +449,7 @@ fn insert_final_newline(doc: &mut Document, view_id: ViewId) { pub struct WriteOptions { pub force: bool, pub auto_format: bool, - pub reason: TextDocumentSaveReason + pub reason: TextDocumentSaveReason, } fn write(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> anyhow::Result<()> { @@ -549,7 +549,14 @@ fn format(cx: &mut compositor::Context, _args: Args, event: PromptEvent) -> anyh let format = doc.format(cx.editor).context( "A formatter isn't available, and no language server provides formatting capabilities", )?; - let callback = make_format_callback(doc.id(), doc.version(), view.id, format, None, TextDocumentSaveReason::MANUAL); + let callback = make_format_callback( + doc.id(), + doc.version(), + view.id, + format, + None, + TextDocumentSaveReason::MANUAL, + ); cx.jobs.callback(callback); Ok(()) @@ -839,7 +846,7 @@ pub fn write_all_impl( target_view, fmt, Some((None, options.force)), - options.reason + options.reason, ); jobs.add(Job::with_callback(callback).wait_before_exiting()); }) @@ -848,7 +855,8 @@ pub fn write_all_impl( }; if fmt.is_none() { - cx.editor.save::(doc_id, None, options.force, options.reason)?; + cx.editor + .save::(doc_id, None, options.force, options.reason)?; } } @@ -890,7 +898,7 @@ fn force_write_all( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), - reason: TextDocumentSaveReason::MANUAL + reason: TextDocumentSaveReason::MANUAL, }, ) } @@ -909,7 +917,7 @@ fn write_all_quit( force: false, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), - reason: TextDocumentSaveReason::MANUAL + reason: TextDocumentSaveReason::MANUAL, }, )?; quit_all_impl(cx, false) @@ -929,7 +937,7 @@ fn force_write_all_quit( force: true, write_scratch: true, auto_format: !args.has_flag(WRITE_NO_FORMAT_FLAG.name), - reason: TextDocumentSaveReason::MANUAL + reason: TextDocumentSaveReason::MANUAL, }, ); quit_all_impl(cx, true) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 45cbf0b00317..51af355e5127 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1948,46 +1948,51 @@ impl Editor { doc_id: DocumentId, path: Option

, force: bool, - reason: TextDocumentSaveReason + reason: TextDocumentSaveReason, ) -> anyhow::Result<()> { // convert a channel of futures to pipe into main queue one by one // via stream.then() ? then push into main future let path = path.map(|path| path.into()); - let doc = self.documents.get(&doc_id).unwrap(); - let identifier = doc.identifier().clone(); - let url = doc.url(); - - let language_servers: Vec<_> = self - .language_servers - .iter_clients() - .filter(|client| client.is_initialized()) - .cloned() - .collect(); - for language_server in language_servers { - language_server.text_document_will_save(identifier.clone(), reason); - - let Some(url) = url.clone() else { - continue; - }; - let Some(request) = language_server.text_document_will_save_wait_until(identifier.clone(), reason) else { - continue; - }; - let edits = match helix_lsp::block_on(request) { - Ok(Some(edits)) => edits, - Ok(None) => continue, - Err(err) => { - log::error!("invalid willSaveWaitUntil response: {err:?}"); + let doc = doc!(self, &doc_id); + let url = match &path { + Some(path) => url::Url::from_file_path(path).ok(), + None => doc.url(), + }; + if let Some(url) = url { + let identifier = lsp::TextDocumentIdentifier::new(url.clone()); + let language_servers: Vec<_> = self + .language_servers + .iter_clients() + .filter(|client| client.is_initialized()) + .cloned() + .collect(); + for language_server in language_servers { + language_server.text_document_will_save(identifier.clone(), reason); + + let Some(request) = + language_server.text_document_will_save_wait_until(identifier.clone(), reason) + else { continue; + }; + let edits = match helix_lsp::block_on(request) { + Ok(Some(edits)) => edits, + Ok(None) => continue, + Err(err) => { + log::error!("invalid willSaveWaitUntil response: {err:?}"); + continue; + } + }; + let edit = lsp::WorkspaceEdit { + changes: Some(HashMap::from([(url.clone(), edits)])), + document_changes: None, + change_annotations: None, + }; + if let Err(err) = + self.apply_workspace_edit(language_server.offset_encoding(), &edit) + { + log::error!("failed to apply workspace edit: {err:?}") } - }; - let edit = lsp::WorkspaceEdit { - changes: Some(HashMap::from([(url, edits)])), - document_changes: None, - change_annotations: None, - }; - if let Err(err) = self.apply_workspace_edit(language_server.offset_encoding(), &edit) { - log::error!("failed to apply workspace edit: {err:?}") } } From edc3e2da1070514f6c07d7a0292dc02d1db2680b Mon Sep 17 00:00:00 2001 From: gustaf Date: Tue, 9 Sep 2025 21:51:45 +0200 Subject: [PATCH 4/4] fix: use transactions --- helix-view/src/editor.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 51af355e5127..109d1c58d316 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1954,7 +1954,9 @@ impl Editor { // via stream.then() ? then push into main future let path = path.map(|path| path.into()); - let doc = doc!(self, &doc_id); + let view_id = self.get_synced_view_id(doc_id); + let doc = doc_mut!(self, &doc_id); + let view = view_mut!(self, view_id); let url = match &path { Some(path) => url::Url::from_file_path(path).ok(), None => doc.url(), @@ -1983,21 +1985,19 @@ impl Editor { continue; } }; - let edit = lsp::WorkspaceEdit { - changes: Some(HashMap::from([(url.clone(), edits)])), - document_changes: None, - change_annotations: None, - }; - if let Err(err) = - self.apply_workspace_edit(language_server.offset_encoding(), &edit) - { - log::error!("failed to apply workspace edit: {err:?}") - } + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + edits, + language_server.offset_encoding(), + ); + + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); } } - let doc = doc_mut!(self, &doc_id); let doc_save_future = doc.save(path, force)?; + // When a file is written to, notify the file event handler. // Note: This can be removed once proper file watching is implemented. let handler = self.language_servers.file_event_handler.clone();