diff --git a/book/src/configuration.md b/book/src/configuration.md index 7514a3d0fcc3e..3fdb1b929c56b 100644 --- a/book/src/configuration.md +++ b/book/src/configuration.md @@ -52,6 +52,7 @@ on unix operating systems. | `auto-save` | Enable automatic saving on focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal. | `false` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant. | `400` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | +| `completion-replacer` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | | `auto-info` | Whether to display infoboxes | `true` | | `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative. | `false` | | `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file. | `[]` | diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index d2f4de07dbe74..8e24efad6a791 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 @@ -513,6 +549,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 341d4a547b350..b1e76b47ceb90 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -271,10 +271,17 @@ 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); + let start = cursor as i128 + start_offset; + let end = (cursor as i128 + end_offset) as usize; + // if inserting a completion at this cursor would go out of bounds + // return a trivial edit + if start < 0 || end > doc.len_chars() { + return (0, 0, None); + } ( - (cursor as i128 + start_offset) as usize, + start as usize, (cursor as i128 + end_offset) as usize, replacement.clone(), ) diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index f8d212d14d753..df0fcf9a4436a 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -8,7 +8,7 @@ use tui::{buffer::Buffer as Surface, text::Span}; use std::borrow::Cow; -use helix_core::{Change, Transaction}; +use helix_core::{chars, Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; use crate::commands; @@ -106,6 +106,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)); @@ -118,13 +119,18 @@ 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) => { - // TODO: support using "insert" instead of "replace" via user config - lsp::TextEdit::new(item.replace, item.new_text.clone()) + let range = if replace_mode { + item.replace + } else { + item.insert + }; + lsp::TextEdit::new(range, item.new_text.clone()) } }; @@ -136,21 +142,44 @@ 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); + // if inserting a completion at this cursor would go out of bounds + // return a trivial edit + if cursor < start_offset { + return (0, 0, None); + } + 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 @@ -184,6 +213,7 @@ impl Completion { offset_encoding, start_offset, trigger_offset, + replace_mode, ); // initialize a savepoint @@ -206,6 +236,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 50da3ddeac2df..e856971db96af 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -249,6 +249,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, @@ -772,6 +775,7 @@ impl Default for Config { indent_guides: IndentGuidesConfig::default(), color_modes: false, soft_wrap: SoftWrap::default(), + completion_replace: false, } } }