diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 482fd6d97e5e7..280da8ee95455 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -466,6 +466,42 @@ impl Transaction { self } + /// Generate a transaction from a set of potentially overallping changes. + /// Changes that overlap are ignored + pub fn change_ignore_overlapping(doc: &Rope, changes: I) -> Self + where + I: Iterator, + { + let len = doc.len_chars(); + + let (lower, upper) = changes.size_hint(); + let size = upper.unwrap_or(lower); + let mut changeset = ChangeSet::with_capacity(2 * size + 1); // rough estimate + + let mut last = 0; + for (from, to, tendril) in changes { + if last > from { + continue; + } + + // Retain from last "to" to current "from" + changeset.retain(from - last); + let span = to - from; + match tendril { + Some(text) => { + changeset.insert(text); + changeset.delete(span); + } + None => changeset.delete(span), + } + last = to; + } + + changeset.retain(len - last); + + Self::from(changeset) + } + /// Generate a transaction from a set of changes. pub fn change(doc: &Rope, changes: I) -> Self where @@ -508,6 +544,19 @@ impl Transaction { Self::change(doc, selection.iter().map(f)) } + /// Generate a transaction with a change per selection range. + /// Overlapping changes are ignored + pub fn change_by_selection_ignore_overlapping( + doc: &Rope, + selection: &Selection, + f: F, + ) -> Self + where + F: FnMut(&Range) -> Change, + { + Self::change_ignore_overlapping(doc, selection.iter().map(f)) + } + /// Insert text at each selection head. pub fn insert(doc: &Rope, selection: &Selection, text: Tendril) -> Self { Self::change_by_selection(doc, selection, |range| { diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 8418896cbb735..0fe4dbca1471a 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -222,7 +222,7 @@ pub mod util { None => return Transaction::new(doc), }; - Transaction::change_by_selection(doc, selection, |range| { + Transaction::change_by_selection_ignore_overlapping(doc, selection, |range| { let cursor = range.cursor(text); ( (cursor as i128 + start_offset) as usize, diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index f344d981450ff..cc48f8688b67f 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -4,7 +4,7 @@ use tui::buffer::Buffer as Surface; use std::borrow::Cow; -use helix_core::{Change, Transaction}; +use helix_core::{chars, Change, Transaction}; use helix_view::{ graphics::Rect, input::{KeyCode, KeyEvent}, @@ -95,6 +95,7 @@ impl Completion { start_offset: usize, trigger_offset: usize, ) -> Self { + let replace_mode = editor.config().completion_replace; // Sort completion items according to their preselect status (given by the LSP server) items.sort_by_key(|item| !item.preselect.unwrap_or(false)); @@ -107,13 +108,19 @@ impl Completion { offset_encoding: helix_lsp::OffsetEncoding, start_offset: usize, trigger_offset: usize, + replace_mode: bool, ) -> Transaction { let transaction = if let Some(edit) = &item.text_edit { let edit = match edit { lsp::CompletionTextEdit::Edit(edit) => edit.clone(), lsp::CompletionTextEdit::InsertAndReplace(item) => { + let range = if replace_mode { + item.replace + } else { + item.insert + }; // TODO: support using "insert" instead of "replace" via user config - lsp::TextEdit::new(item.replace, item.new_text.clone()) + lsp::TextEdit::new(range, item.new_text.clone()) } }; @@ -125,21 +132,39 @@ impl Completion { ) } else { let text = item.insert_text.as_ref().unwrap_or(&item.label); + let doc_text = doc.text().slice(..); + let primary_cursor = doc.selection(view_id).primary().cursor(doc_text); - // TODO: this needs to be true for the numbers to work out correctly - // in the closure below. It's passed in to a callback as this same + // TODO: this needs to be true for the closure below to work out + // It's passed in to a callback as this same // formula, but can the value change between the LSP request and // response? If it does, can we recover? - debug_assert!( - doc.selection(view_id) - .primary() - .cursor(doc.text().slice(..)) - == trigger_offset - ); + debug_assert!(primary_cursor == trigger_offset); + let start_offset = primary_cursor - start_offset; - Transaction::change_by_selection(doc.text(), doc.selection(view_id), |_| { - (start_offset, trigger_offset, Some(text.into())) - }) + Transaction::change_by_selection_ignore_overlapping( + doc.text(), + doc.selection(view_id), + |range| { + let cursor = range.cursor(doc_text); + let end_offset = if replace_mode { + // in replace mode replace the rest of the word + doc_text + .chars_at(primary_cursor) + .take_while(|ch| chars::char_is_word(*ch)) + .count() + } else { + // otherwise only replace up to the current edit + 0 + }; + + ( + cursor - start_offset, + cursor + end_offset, + Some(text.into()), + ) + }, + ) }; transaction @@ -173,6 +198,7 @@ impl Completion { offset_encoding, start_offset, trigger_offset, + replace_mode, ); // initialize a savepoint @@ -195,6 +221,7 @@ impl Completion { offset_encoding, start_offset, trigger_offset, + replace_mode, ); doc.apply(&transaction, view.id); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index eef4a3f99e910..86b88573bab5f 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -244,6 +244,9 @@ pub struct Config { )] pub idle_timeout: Duration, pub completion_trigger_len: u8, + /// Whether to instruct the LSP to replace the entire word when applying a completion + /// or to only insert new text + pub completion_replace: bool, /// Whether to display infoboxes. Defaults to true. pub auto_info: bool, pub file_picker: FilePickerConfig, @@ -717,6 +720,7 @@ impl Default for Config { bufferline: BufferLine::default(), indent_guides: IndentGuidesConfig::default(), color_modes: false, + completion_replace: false, } } }