diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs index 853290404..5811186cd 100644 --- a/helix-core/src/auto_pairs.rs +++ b/helix-core/src/auto_pairs.rs @@ -1,11 +1,9 @@ //! When typing the opening character of one of the possible pairs defined below, //! this module provides the functionality to insert the paired closing character. -use crate::{graphemes, movement::Direction, Range, Rope, Selection, Tendril, Transaction}; +use crate::{graphemes, movement::Direction, Change, Deletion, Range, Rope, Tendril}; use std::collections::HashMap; -use smallvec::SmallVec; - // Heavily based on https://github.com/codemirror/closebrackets/ pub const DEFAULT_PAIRS: &[(char, char)] = &[ ('(', ')'), @@ -106,37 +104,128 @@ impl Default for AutoPairs { } } -// insert hook: -// Fn(doc, selection, char) => Option -// problem is, we want to do this per range, so we can call default handler for some ranges -// so maybe ret Vec> -// but we also need to be able to return transactions... -// -// to simplify, maybe return Option and just reimplement the default - // [TODO] // * delete implementation where it erases the whole bracket (|) -> | // * change to multi character pairs to handle cases like placing the cursor in the // middle of triple quotes, and more exotic pairs like Jinja's {% %} #[must_use] -pub fn hook(doc: &Rope, selection: &Selection, ch: char, pairs: &AutoPairs) -> Option { - log::trace!("autopairs hook selection: {:#?}", selection); +pub fn hook_insert( + doc: &Rope, + range: &Range, + ch: char, + pairs: &AutoPairs, +) -> Option<(Change, Range)> { + log::trace!("autopairs hook range: {:#?}", range); if let Some(pair) = pairs.get(ch) { if pair.same() { - return Some(handle_same(doc, selection, pair)); + return handle_insert_same(doc, range, pair); } else if pair.open == ch { - return Some(handle_open(doc, selection, pair)); + return handle_insert_open(doc, range, pair); } else if pair.close == ch { // && char_at pos == close - return Some(handle_close(doc, selection, pair)); + return handle_insert_close(doc, range, pair); } + } else if ch.is_whitespace() { + return handle_insert_whitespace(doc, range, ch, pairs); } None } +#[must_use] +pub fn hook_delete(doc: &Rope, range: &Range, pairs: &AutoPairs) -> Option<(Deletion, Range)> { + log::trace!("autopairs delete hook range: {:#?}", range); + + let text = doc.slice(..); + let cursor = range.cursor(text); + + let cur = doc.get_char(cursor)?; + let prev = prev_char(doc, cursor)?; + + // check for whitespace surrounding a pair + if doc.len_chars() >= 4 && prev.is_whitespace() && cur.is_whitespace() { + let second_prev = doc.get_char(graphemes::nth_prev_grapheme_boundary(text, cursor, 2))?; + let second_next = doc.get_char(graphemes::next_grapheme_boundary(text, cursor))?; + log::debug!("second_prev: {}, second_next: {}", second_prev, second_next); + + if let Some(pair) = pairs.get(second_prev) { + if pair.open == second_prev && pair.close == second_next { + return handle_delete(doc, range); + } + } + } + + let pair = pairs.get(cur)?; + + if pair.open != prev || pair.close != cur { + return None; + } + + handle_delete(doc, range) +} + +pub fn handle_delete(doc: &Rope, range: &Range) -> Option<(Deletion, Range)> { + let text = doc.slice(..); + let cursor = range.cursor(text); + + let end_next = graphemes::next_grapheme_boundary(text, cursor); + let end_prev = graphemes::prev_grapheme_boundary(text, cursor); + + let delete = (end_prev, end_next); + let size_delete = end_next - end_prev; + let next_head = graphemes::next_grapheme_boundary(text, range.head) - size_delete; + + // if the range is a single grapheme cursor, we do not want to shrink the + // range, just move it, so we only subtract the size of the closing pair char + let next_anchor = match (range.direction(), range.is_single_grapheme(text)) { + // single grapheme forward needs to move, but only the width of the + // character under the cursor, which is the closer + (Direction::Forward, true) => range.anchor - (end_next - cursor), + (Direction::Backward, true) => range.anchor - (cursor - end_prev), + + (Direction::Forward, false) => range.anchor, + (Direction::Backward, false) => range.anchor - size_delete, + }; + + let next_range = Range::new(next_anchor, next_head); + + log::trace!( + "auto pair delete: {:?}, range: {:?}, next_range: {:?}, text len: {}", + delete, + range, + next_range, + text.len_chars() + ); + + Some((delete, next_range)) +} + +fn handle_insert_whitespace( + doc: &Rope, + range: &Range, + ch: char, + pairs: &AutoPairs, +) -> Option<(Change, Range)> { + let text = doc.slice(..); + let cursor = range.cursor(text); + let cur = doc.get_char(cursor)?; + let prev = prev_char(doc, cursor)?; + let pair = pairs.get(cur)?; + + if pair.open != prev || pair.close != cur { + return None; + } + + let whitespace_pair = Pair { + open: ch, + close: ch, + }; + + handle_insert_same(doc, range, &whitespace_pair) +} + fn prev_char(doc: &Rope, pos: usize) -> Option { if pos == 0 { return None; @@ -146,7 +235,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option { } /// calculate what the resulting range should be for an auto pair insertion -fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: usize) -> Range { +fn get_next_range(doc: &Rope, start_range: &Range, len_inserted: usize) -> Range { // When the character under the cursor changes due to complete pair // insertion, we must look backward a grapheme and then add the length // of the insertion to put the resulting cursor in the right place, e.g. @@ -165,10 +254,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: // inserting at the very end of the document after the last newline if start_range.head == doc.len_chars() && start_range.anchor == doc.len_chars() { - return Range::new( - start_range.anchor + offset + 1, - start_range.head + offset + 1, - ); + return Range::new(start_range.anchor + 1, start_range.head + 1); } let doc_slice = doc.slice(..); @@ -177,7 +263,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: // just skip over graphemes if len_inserted == 0 { let end_anchor = if single_grapheme { - graphemes::next_grapheme_boundary(doc_slice, start_range.anchor) + offset + graphemes::next_grapheme_boundary(doc_slice, start_range.anchor) // even for backward inserts with multiple grapheme selections, // we want the anchor to stay where it is so that the relative @@ -185,42 +271,38 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: // // foo([) wor]d -> insert ) -> foo()[ wor]d } else { - start_range.anchor + offset + start_range.anchor }; return Range::new( end_anchor, - graphemes::next_grapheme_boundary(doc_slice, start_range.head) + offset, + graphemes::next_grapheme_boundary(doc_slice, start_range.head), ); } // trivial case: only inserted a single-char opener, just move the selection if len_inserted == 1 { let end_anchor = if single_grapheme || start_range.direction() == Direction::Backward { - start_range.anchor + offset + 1 + start_range.anchor + 1 } else { - start_range.anchor + offset + start_range.anchor }; - return Range::new(end_anchor, start_range.head + offset + 1); + return Range::new(end_anchor, start_range.head + 1); } // If the head = 0, then we must be in insert mode with a backward // cursor, which implies the head will just move let end_head = if start_range.head == 0 || start_range.direction() == Direction::Backward { - start_range.head + offset + 1 + start_range.head + 1 } else { // We must have a forward cursor, which means we must move to the // other end of the grapheme to get to where the new characters // are inserted, then move the head to where it should be let prev_bound = graphemes::prev_grapheme_boundary(doc_slice, start_range.head); - log::trace!( - "prev_bound: {}, offset: {}, len_inserted: {}", - prev_bound, - offset, - len_inserted - ); - prev_bound + offset + len_inserted + log::trace!("prev_bound: {}, len_inserted: {}", prev_bound, len_inserted); + + prev_bound + len_inserted }; let end_anchor = match (start_range.len(), start_range.direction()) { @@ -239,7 +321,7 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: // if we are appending, the anchor stays where it is; only offset // for multiple range insertions } else { - start_range.anchor + offset + start_range.anchor } } @@ -248,13 +330,11 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: // if we're backward, then the head is at the first char // of the typed char, so we need to add the length of // the closing char - graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) - + len_inserted - + offset + graphemes::prev_grapheme_boundary(doc.slice(..), start_range.anchor) + len_inserted } else { // when we are inserting in front of a selection, we need to move // the anchor over by however many characters were inserted overall - start_range.anchor + offset + len_inserted + start_range.anchor + len_inserted } } }; @@ -262,112 +342,76 @@ fn get_next_range(doc: &Rope, start_range: &Range, offset: usize, len_inserted: Range::new(end_anchor, end_head) } -fn handle_open(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { - let mut end_ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; +fn handle_insert_open(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> { + let cursor = range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); + let len_inserted; - let transaction = Transaction::change_by_selection(doc, selection, |start_range| { - let cursor = start_range.cursor(doc.slice(..)); - let next_char = doc.get_char(cursor); - let len_inserted; + // Since auto pairs are currently limited to single chars, we're either + // inserting exactly one or two chars. When arbitrary length pairs are + // added, these will need to be changed. + let change = match next_char { + Some(_) if !pair.should_close(doc, range) => { + return None; + } + _ => { + // insert open & close + let pair_str = Tendril::from_iter([pair.open, pair.close]); + len_inserted = 2; + (cursor, cursor, Some(pair_str)) + } + }; - // Since auto pairs are currently limited to single chars, we're either - // inserting exactly one or two chars. When arbitrary length pairs are - // added, these will need to be changed. - let change = match next_char { - Some(_) if !pair.should_close(doc, start_range) => { - len_inserted = 1; - let mut tendril = Tendril::new(); - tendril.push(pair.open); - (cursor, cursor, Some(tendril)) - } - _ => { - // insert open & close - let pair_str = Tendril::from_iter([pair.open, pair.close]); - len_inserted = 2; - (cursor, cursor, Some(pair_str)) - } - }; + let next_range = get_next_range(doc, range, len_inserted); + let result = (change, next_range); - let next_range = get_next_range(doc, start_range, offs, len_inserted); - end_ranges.push(next_range); - offs += len_inserted; + log::debug!("auto pair change: {:#?}", &result); - change - }); - - let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); - log::debug!("auto pair transaction: {:#?}", t); - t + Some(result) } -fn handle_close(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { - let mut end_ranges = SmallVec::with_capacity(selection.len()); - let mut offs = 0; +fn handle_insert_close(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> { + let cursor = range.cursor(doc.slice(..)); + let next_char = doc.get_char(cursor); - let transaction = Transaction::change_by_selection(doc, selection, |start_range| { - let cursor = start_range.cursor(doc.slice(..)); - let next_char = doc.get_char(cursor); - let mut len_inserted = 0; + let change = if next_char == Some(pair.close) { + // return transaction that moves past close + (cursor, cursor, None) // no-op + } else { + return None; + }; - let change = if next_char == Some(pair.close) { - // return transaction that moves past close - (cursor, cursor, None) // no-op - } else { - len_inserted = 1; - let mut tendril = Tendril::new(); - tendril.push(pair.close); - (cursor, cursor, Some(tendril)) - }; + let next_range = get_next_range(doc, range, 0); + let result = (change, next_range); - let next_range = get_next_range(doc, start_range, offs, len_inserted); - end_ranges.push(next_range); - offs += len_inserted; + log::debug!("auto pair change: {:#?}", &result); - change - }); - - let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); - log::debug!("auto pair transaction: {:#?}", t); - t + Some(result) } /// handle cases where open and close is the same, or in triples ("""docstring""") -fn handle_same(doc: &Rope, selection: &Selection, pair: &Pair) -> Transaction { - let mut end_ranges = SmallVec::with_capacity(selection.len()); +fn handle_insert_same(doc: &Rope, range: &Range, pair: &Pair) -> Option<(Change, Range)> { + let cursor = range.cursor(doc.slice(..)); + let mut len_inserted = 0; + let next_char = doc.get_char(cursor); - let mut offs = 0; + let change = if next_char == Some(pair.open) { + // return transaction that moves past close + (cursor, cursor, None) // no-op + } else { + if !pair.should_close(doc, range) { + return None; + } - let transaction = Transaction::change_by_selection(doc, selection, |start_range| { - let cursor = start_range.cursor(doc.slice(..)); - let mut len_inserted = 0; - let next_char = doc.get_char(cursor); + let pair_str = Tendril::from_iter([pair.open, pair.close]); + len_inserted = 2; + (cursor, cursor, Some(pair_str)) + }; - let change = if next_char == Some(pair.open) { - // return transaction that moves past close - (cursor, cursor, None) // no-op - } else { - let mut pair_str = Tendril::new(); - pair_str.push(pair.open); + let next_range = get_next_range(doc, range, len_inserted); + let result = (change, next_range); - // for equal pairs, don't insert both open and close if either - // side has a non-pair char - if pair.should_close(doc, start_range) { - pair_str.push(pair.close); - } + log::debug!("auto pair change: {:#?}", &result); - len_inserted += pair_str.chars().count(); - (cursor, cursor, Some(pair_str)) - }; - - let next_range = get_next_range(doc, start_range, offs, len_inserted); - end_ranges.push(next_range); - offs += len_inserted; - - change - }); - - let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index())); - log::debug!("auto pair transaction: {:#?}", t); - t + Some(result) } diff --git a/helix-core/src/transaction.rs b/helix-core/src/transaction.rs index 21b72b5f5..b419ccf87 100644 --- a/helix-core/src/transaction.rs +++ b/helix-core/src/transaction.rs @@ -523,6 +523,49 @@ impl ChangeSet { pub fn changes_iter(&self) -> ChangeIterator { ChangeIterator::new(self) } + + pub fn from_change(doc: &Rope, change: Change) -> Self { + Self::from_changes(doc, std::iter::once(change)) + } + + /// Generate a ChangeSet from a set of changes. + pub fn from_changes(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 { + // Verify ranges are ordered and not overlapping + debug_assert!(last <= from); + // Verify ranges are correct + debug_assert!( + from <= to, + "Edit end must end before it starts (should {from} <= {to})" + ); + + // 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); + + changeset + } } /// Transaction represents a single undoable unit of changes. Several changes can be grouped into @@ -616,38 +659,7 @@ impl Transaction { 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 { - // Verify ranges are ordered and not overlapping - debug_assert!(last <= from); - // Verify ranges are correct - debug_assert!( - from <= to, - "Edit end must end before it starts (should {from} <= {to})" - ); - - // 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) + Self::from(ChangeSet::from_changes(doc, changes)) } /// Generate a transaction from a set of potentially overlapping deletions @@ -736,9 +748,60 @@ impl Transaction { ) } + /// Generate a transaction with a change per selection range, which + /// generates a new selection as well. Each range is operated upon by + /// the given function and can optionally produce a new range. If none + /// is returned by the function, that range is mapped through the change + /// as usual. + pub fn change_by_and_with_selection(doc: &Rope, selection: &Selection, mut f: F) -> Self + where + F: FnMut(&Range) -> (Change, Option), + { + let mut end_ranges = SmallVec::with_capacity(selection.len()); + let mut offset = 0; + + let transaction = Transaction::change_by_selection(doc, selection, |start_range| { + let ((from, to, replacement), end_range) = f(start_range); + let mut change_size = to as isize - from as isize; + + if let Some(ref text) = replacement { + change_size = text.chars().count() as isize - change_size; + } else { + change_size = -change_size; + } + + let new_range = if let Some(end_range) = end_range { + end_range + } else { + let changeset = ChangeSet::from_change(doc, (from, to, replacement.clone())); + start_range.map(&changeset) + }; + + let offset_range = Range::new( + (new_range.anchor as isize + offset) as usize, + (new_range.head as isize + offset) as usize, + ); + + end_ranges.push(offset_range); + offset += change_size; + + log::trace!( + "from: {}, to: {}, replacement: {:?}, offset: {}", + from, + to, + replacement, + offset + ); + + (from, to, replacement) + }); + + transaction.with_selection(Selection::new(end_ranges, selection.primary_index())) + } + /// Generate a transaction with a deletion per selection range. /// Compared to using `change_by_selection` directly these ranges may overlap. - /// In that case they are merged + /// In that case they are merged. pub fn delete_by_selection(doc: &Rope, selection: &Selection, f: F) -> Self where F: FnMut(&Range) -> Deletion, @@ -746,6 +809,59 @@ impl Transaction { Self::delete(doc, selection.iter().map(f)) } + /// Generate a transaction with a delete per selection range, which + /// generates a new selection as well. Each range is operated upon by + /// the given function and can optionally produce a new range. If none + /// is returned by the function, that range is mapped through the change + /// as usual. + /// + /// Compared to using `change_by_and_with_selection` directly these ranges + /// may overlap. In that case they are merged. + pub fn delete_by_and_with_selection(doc: &Rope, selection: &Selection, mut f: F) -> Self + where + F: FnMut(&Range) -> (Deletion, Option), + { + let mut end_ranges = SmallVec::with_capacity(selection.len()); + let mut offset = 0; + let mut last = 0; + + let transaction = Transaction::delete_by_selection(doc, selection, |start_range| { + let ((from, to), end_range) = f(start_range); + + // must account for possibly overlapping deletes + let change_size = if last > from { to - last } else { to - from }; + + let new_range = if let Some(end_range) = end_range { + end_range + } else { + let changeset = ChangeSet::from_change(doc, (from, to, None)); + start_range.map(&changeset) + }; + + let offset_range = Range::new( + new_range.anchor.saturating_sub(offset), + new_range.head.saturating_sub(offset), + ); + + log::trace!( + "delete from: {}, to: {}, offset: {}, new_range: {:?}, offset_range: {:?}", + from, + to, + offset, + new_range, + offset_range + ); + + end_ranges.push(offset_range); + offset += change_size; + last = to; + + (from, to) + }); + + transaction.with_selection(Selection::new(end_ranges, selection.primary_index())) + } + /// 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-term/src/commands.rs b/helix-term/src/commands.rs index a3417ea1b..7e8199391 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -4121,16 +4121,6 @@ pub mod insert { } } - // The default insert hook: simply insert the character - #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature - fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option { - let cursors = selection.clone().cursors(doc.slice(..)); - let mut t = Tendril::new(); - t.push(ch); - let transaction = Transaction::insert(doc, &cursors, t); - Some(transaction) - } - use helix_core::auto_pairs; use helix_view::editor::SmartTabConfig; @@ -4140,15 +4130,25 @@ pub mod insert { let selection = doc.selection(view.id); let auto_pairs = doc.auto_pairs(cx.editor); - let transaction = auto_pairs - .as_ref() - .and_then(|ap| auto_pairs::hook(text, selection, c, ap)) - .or_else(|| insert(text, selection, c)); + let insert_char = |range: Range, ch: char| { + let cursor = range.cursor(text.slice(..)); + let t = Tendril::from_iter([ch]); + ((cursor, cursor, Some(t)), None) + }; - let (view, doc) = current!(cx.editor); - if let Some(t) = transaction { - doc.apply(&t, view.id); - } + let transaction = Transaction::change_by_and_with_selection(text, selection, |range| { + auto_pairs + .as_ref() + .and_then(|ap| { + auto_pairs::hook_insert(text, range, c, ap) + .map(|(change, range)| (change, Some(range))) + .or_else(|| Some(insert_char(*range, c))) + }) + .unwrap_or_else(|| insert_char(*range, c)) + }); + + let doc = doc_mut!(cx.editor, &doc.id()); + doc.apply(&transaction, view.id); helix_event::dispatch(PostInsertChar { c, cx }); } @@ -4345,82 +4345,96 @@ pub mod insert { doc.apply(&transaction, view.id); } + fn dedent(doc: &Document, range: &Range) -> Option { + let text = doc.text().slice(..); + let pos = range.cursor(text); + let line_start_pos = text.line_to_char(range.cursor_line(text)); + + // consider to delete by indent level if all characters before `pos` are indent units. + let fragment = Cow::from(text.slice(line_start_pos..pos)); + + if fragment.is_empty() || !fragment.chars().all(|ch| ch == ' ' || ch == '\t') { + return None; + } + + if text.get_char(pos.saturating_sub(1)) == Some('\t') { + // fast path, delete one char + return Some((graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos)); + } + + let tab_width = doc.tab_width(); + let indent_width = doc.indent_width(); + + let width: usize = fragment + .chars() + .map(|ch| { + if ch == '\t' { + tab_width + } else { + // it can be none if it still meet control characters other than '\t' + // here just set the width to 1 (or some value better?). + ch.width().unwrap_or(1) + } + }) + .sum(); + + // round down to nearest unit + let mut drop = width % indent_width; + + // if it's already at a unit, consume a whole unit + if drop == 0 { + drop = indent_width + }; + + let mut chars = fragment.chars().rev(); + let mut start = pos; + + for _ in 0..drop { + // delete up to `drop` spaces + match chars.next() { + Some(' ') => start -= 1, + _ => break, + } + } + + Some((start, pos)) // delete! + } + pub fn delete_char_backward(cx: &mut Context) { let count = cx.count(); let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); - let tab_width = doc.tab_width(); - let indent_width = doc.indent_width(); - let auto_pairs = doc.auto_pairs(cx.editor); - let transaction = - Transaction::delete_by_selection(doc.text(), doc.selection(view.id), |range| { + let transaction = Transaction::delete_by_and_with_selection( + doc.text(), + doc.selection(view.id), + |range| { let pos = range.cursor(text); + + log::debug!("cursor: {}, len: {}", pos, text.len_chars()); + if pos == 0 { - return (pos, pos); + return ((pos, pos), None); } - let line_start_pos = text.line_to_char(range.cursor_line(text)); - // consider to delete by indent level if all characters before `pos` are indent units. - let fragment = Cow::from(text.slice(line_start_pos..pos)); - if !fragment.is_empty() && fragment.chars().all(|ch| ch == ' ' || ch == '\t') { - if text.get_char(pos.saturating_sub(1)) == Some('\t') { - // fast path, delete one char - (graphemes::nth_prev_grapheme_boundary(text, pos, 1), pos) - } else { - let width: usize = fragment - .chars() - .map(|ch| { - if ch == '\t' { - tab_width - } else { - // it can be none if it still meet control characters other than '\t' - // here just set the width to 1 (or some value better?). - ch.width().unwrap_or(1) - } - }) - .sum(); - let mut drop = width % indent_width; // round down to nearest unit - if drop == 0 { - drop = indent_width - }; // if it's already at a unit, consume a whole unit - let mut chars = fragment.chars().rev(); - let mut start = pos; - for _ in 0..drop { - // delete up to `drop` spaces - match chars.next() { - Some(' ') => start -= 1, - _ => break, - } - } - (start, pos) // delete! - } - } else { - match ( - text.get_char(pos.saturating_sub(1)), - text.get_char(pos), - auto_pairs, - ) { - (Some(_x), Some(_y), Some(ap)) - if range.is_single_grapheme(text) - && ap.get(_x).is_some() - && ap.get(_x).unwrap().open == _x - && ap.get(_x).unwrap().close == _y => - // delete both autopaired characters - { - ( - graphemes::nth_prev_grapheme_boundary(text, pos, count), - graphemes::nth_next_grapheme_boundary(text, pos, count), - ) - } - _ => - // delete 1 char - { - (graphemes::nth_prev_grapheme_boundary(text, pos, count), pos) - } - } - } - }); - let (view, doc) = current!(cx.editor); + + dedent(doc, range) + .map(|dedent| (dedent, None)) + .or_else(|| { + auto_pairs::hook_delete(doc.text(), range, doc.auto_pairs(cx.editor)?) + .map(|(delete, new_range)| (delete, Some(new_range))) + }) + .unwrap_or_else(|| { + ( + (graphemes::nth_prev_grapheme_boundary(text, pos, count), pos), + None, + ) + }) + }, + ); + + log::debug!("delete_char_backward transaction: {:?}", transaction); + + let doc = doc_mut!(cx.editor, &doc.id()); doc.apply(&transaction, view.id); } diff --git a/helix-term/tests/test/auto_pairs.rs b/helix-term/tests/test/auto_pairs.rs index c921e2ae7..8048d97f1 100644 --- a/helix-term/tests/test/auto_pairs.rs +++ b/helix-term/tests/test/auto_pairs.rs @@ -16,10 +16,119 @@ fn matching_pairs() -> impl Iterator { async fn insert_basic() -> anyhow::Result<()> { for pair in DEFAULT_PAIRS { test(( - format!("#[{}|]#", LINE_END), + "#[\n|]#", format!("i{}", pair.0), - format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END), - LineFeedHandling::AsIs, + format!("{}#[|{}]#", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_whitespace() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("{}#[|{}]#", pair.0, pair.1), + "i ", + format!("{} #[| ]#{}", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_whitespace_multi() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!( + indoc! {"\ + {open}#[|{close}]# + {open}#(|{open})#{close}{close} + {open}{open}#(|{close}{close})# + foo#(|\n)# + "}, + open = pair.0, + close = pair.1, + ), + "i ", + format!( + indoc! {"\ + {open} #[| ]#{close} + {open} #(|{open})#{close}{close} + {open}{open} #(| {close}{close})# + foo #(|\n)# + "}, + open = pair.0, + close = pair.1, + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_whitespace_multi() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!( + indoc! {"\ + #[|{open}]#{close} + #(|{open})#{open}{close}{close} + #(|{open}{open})#{close}{close} + #(|foo)# + "}, + open = pair.0, + close = pair.1, + ), + "a ", + format!( + indoc! {"\ + #[{open} |]#{close} + #({open} {open}|)#{close}{close} + #({open}{open} |)#{close}{close} + #(foo \n|)# + "}, + open = pair.0, + close = pair.1, + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_whitespace_no_pair() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + // sanity check - do not insert extra whitespace unless immediately + // surrounded by a pair + test(( + format!("{} #[|{}]#", pair.0, pair.1), + "i ", + format!("{} #[|{}]#", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_whitespace_no_matching_pair() -> anyhow::Result<()> { + for pair in differing_pairs() { + // sanity check - verify whitespace does not insert unless both pairs + // are matches, i.e. no two different openers + test(( + format!("{}#[|{}]#", pair.0, pair.0), + "i ", + format!("{} #[|{}]#", pair.0, pair.0), )) .await?; } @@ -567,3 +676,760 @@ async fn append_inside_nested_pair_multi() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn delete_basic() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END), + "i", + format!("#[|{}]#", LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + indoc! {"\ + {open}#[|{close}]# + {open}#(|{close})# + {open}#(|{close})# + "}, + open = pair.0, + close = pair.1, + ), + "i", + indoc! {"\ + #[|\n]# + #(|\n)# + #(|\n)# + "}, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_whitespace() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("{} #[| ]#{}", pair.0, pair.1), + "i", + format!("{}#[|{}]#", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_whitespace_after_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("foo{} #[| ]#{}", pair.0, pair.1), + "i", + format!("foo{}#[|{}]#", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_whitespace_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + indoc! {"\ + {open} #[| ]#{close} + {open} #(|{open})#{close}{close} + {open}{open} #(| {close}{close})# + foo #(|\n)# + "}, + open = pair.0, + close = pair.1, + ), + "i", + format!( + indoc! {"\ + {open}#[|{close}]# + {open}#(|{open})#{close}{close} + {open}{open}#(|{close}{close})# + foo#(|\n)# + "}, + open = pair.0, + close = pair.1, + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_whitespace_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + indoc! {"\ + #[{open} |]# {close} + #({open} |)#{open}{close}{close} + #({open}{open} |)# {close}{close} + #(foo |)# + "}, + open = pair.0, + close = pair.1, + ), + "a", + format!( + indoc! {"\ + #[{open}{close}|]# + #({open}{open}|)#{close}{close} + #({open}{open}{close}|)#{close} + #(foo\n|)# + "}, + open = pair.0, + close = pair.1, + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_whitespace_no_pair() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("{} #[|{}]#", pair.0, pair.1), + "i", + format!("{} #[|{}]#", pair.0, pair.1), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_whitespace_no_matching_pair() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!("{} #[|{}]#", pair.0, pair.0), + "i", + format!("{}#[|{}]#", pair.0, pair.0), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_configured_multi_byte_chars() -> anyhow::Result<()> { + // NOTE: these are multi-byte Unicode characters + let pairs = hashmap!('„' => '“', '‚' => '‘', '「' => '」'); + + let config = Config { + editor: helix_view::editor::Config { + auto_pairs: AutoPairConfig::Pairs(pairs.clone()), + ..Default::default() + }, + ..Default::default() + }; + + for (open, close) in pairs.iter() { + test_with_config( + AppBuilder::new().with_config(config.clone()), + ( + format!("{}#[|{}]#{}", open, close, LINE_END), + "i", + format!("#[|{}]#", LINE_END), + LineFeedHandling::AsIs, + ), + ) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_after_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + &format!("foo{}#[|{}]#", pair.0, pair.1), + "i", + "foo#[|\n]#", + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_then_delete() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + "#[\n|]#\n", + format!("ofoo{}", pair.0), + "\nfoo#[\n|]#\n", + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_then_delete_whitespace() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + "foo#[\n|]#", + format!("i{}", pair.0), + "foo#[|\n]#", + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn insert_then_delete_multi() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + indoc! {"\ + through a day#[\n|]# + in and out of weeks#(\n|)# + over a year#(\n|)# + "}, + format!("i{}", pair.0), + indoc! {"\ + through a day#[|\n]# + in and out of weeks#(|\n)# + over a year#(|\n)# + "}, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_then_delete() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + "fo#[o|]#", + format!("a{}", pair.0), + "fo#[o\n|]#", + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn append_then_delete_multi() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + indoc! {"\ + #[through a day|]# + #(in and out of weeks|)# + #(over a year|)# + "}, + format!("a{}", pair.0), + indoc! {"\ + #[through a day\n|]# + #(in and out of weeks\n|)# + #(over a year\n|)# + "}, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_before_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + // sanity check unclosed pair delete + test(( + format!("{}#[|f]#oo{}", pair.0, LINE_END), + "i", + format!("#[|f]#oo{}", LINE_END), + )) + .await?; + + // deleting the closing pair should NOT delete the whole pair + test(( + format!("{}{}#[|f]#oo{}", pair.0, pair.1, LINE_END), + "i", + format!("{}#[|f]#oo{}", pair.0, LINE_END), + )) + .await?; + + // deleting whole pair before word + test(( + format!("{}#[|{}]#foo{}", pair.0, pair.1, LINE_END), + "i", + format!("#[|f]#oo{}", LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_before_word_selection() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + // sanity check unclosed pair delete + test(( + format!("{}#[|foo]#{}", pair.0, LINE_END), + "i", + format!("#[|foo]#{}", LINE_END), + )) + .await?; + + // deleting the closing pair should NOT delete the whole pair + test(( + format!("{}{}#[|foo]#{}", pair.0, pair.1, LINE_END), + "i", + format!("{}#[|foo]#{}", pair.0, LINE_END), + )) + .await?; + + // deleting whole pair before word + test(( + format!("{}#[|{}foo]#{}", pair.0, pair.1, LINE_END), + "i", + format!("#[|foo]#{}", LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_before_word_selection_trailing_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("foo{}#[|{} wor]#{}", pair.0, pair.1, LINE_END), + "i", + format!("foo#[| wor]#{}", LINE_END), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_before_eol() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "{eol}{open}#[|{close}]#{eol}", + eol = LINE_END, + open = pair.0, + close = pair.1 + ), + "i", + format!("{0}#[|{0}]#", LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_auto_pairs_disabled() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test_with_config( + AppBuilder::new().with_config(Config { + editor: helix_view::editor::Config { + auto_pairs: AutoPairConfig::Enable(false), + ..Default::default() + }, + ..Default::default() + }), + ( + format!("{}#[|{}]#{}", pair.0, pair.1, LINE_END), + "i", + format!("#[|{}]#{}", pair.1, LINE_END), + ), + ) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_before_multi_code_point_graphemes() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.1, LINE_END), + "i", + format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END), + )) + .await?; + + test(( + format!( + "hello {}{}#[|👨‍👩‍👧‍👦]# goodbye{}", + pair.0, pair.1, LINE_END + ), + "i", + format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.0, LINE_END), + )) + .await?; + + test(( + format!( + "hello {}#[|{}]#👨‍👩‍👧‍👦 goodbye{}", + pair.0, pair.1, LINE_END + ), + "i", + format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END), + )) + .await?; + + test(( + format!( + "hello {}#[|{}👨‍👩‍👧‍👦]# goodbye{}", + pair.0, pair.1, LINE_END + ), + "i", + format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END), + )) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_at_end_of_document() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(TestCase { + in_text: format!("{}{}{}", LINE_END, pair.0, pair.1), + in_selection: Selection::single(LINE_END.len() + 1, LINE_END.len() + 2), + in_keys: String::from("i"), + out_text: String::from(LINE_END), + out_selection: Selection::single(LINE_END.len(), LINE_END.len()), + line_feed_handling: LineFeedHandling::AsIs, + }) + .await?; + + test(TestCase { + in_text: format!("foo{}{}{}", LINE_END, pair.0, pair.1), + in_selection: Selection::single(LINE_END.len() + 4, LINE_END.len() + 5), + in_keys: String::from("i"), + out_text: format!("foo{}", LINE_END), + out_selection: Selection::single(3 + LINE_END.len(), 3 + LINE_END.len()), + line_feed_handling: LineFeedHandling::AsIs, + }) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_nested_open_inside_pair() -> anyhow::Result<()> { + for pair in differing_pairs() { + test(( + format!( + "{open}{open}#[|{close}]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "i", + format!( + "{open}#[|{close}]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_nested_open_inside_pair_multi() -> anyhow::Result<()> { + for outer_pair in DEFAULT_PAIRS { + for inner_pair in DEFAULT_PAIRS { + if inner_pair.0 == outer_pair.0 { + continue; + } + + test(( + format!( + "{outer_open}{inner_open}#[|{inner_close}]#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}{outer_open}{inner_open}#(|{inner_close})#{outer_close}{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + inner_open = inner_pair.0, + inner_close = inner_pair.1, + eol = LINE_END + ), + "i", + format!( + "{outer_open}#[|{outer_close}]#{eol}{outer_open}#(|{outer_close})#{eol}{outer_open}#(|{outer_close})#{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + eol = LINE_END + ), + )) + .await?; + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_basic() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "#[{eol}{open}|]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!("#[{eol}{eol}|]#", eol = LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_multi_range() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "#[ {open}|]#{close}{eol}#( {open}|)#{close}{eol}#( {open}|)#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!("#[ {eol}|]##( {eol}|)##( {eol}|)#", eol = LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_end_of_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "fo#[o{open}|]#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!("fo#[o{}|]#", LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_mixed_dedent() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + indoc! {"\ + bar = {}#[|{}]# + #(|\n)# + foo#(|\n)# + "}, + pair.0, pair.1, + ), + "i", + indoc! {"\ + bar = #[|\n]# + #(|\n)# + fo#(|\n)# + "}, + )) + .await?; + + test(( + format!( + indoc! {"\ + bar = {}#[|{}woop]# + #(|word)# + fo#(|o)# + "}, + pair.0, pair.1, + ), + "i", + indoc! {"\ + bar = #[|woop]# + #(|word)# + f#(|o)# + "}, + )) + .await?; + + // delete from the right with append + test(( + format!( + indoc! {"\ + bar = #[|woop{}]#{} + #(| )#word + #(|fo)#o + "}, + pair.0, pair.1, + ), + "a", + indoc! {"\ + bar = #[woop\n|]# + #(w|)#ord + #(fo|)# + "}, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_end_of_word_multi() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "fo#[o{open}|]#{close}{eol}fo#(o{open}|)#{close}{eol}fo#(o{open}|)#{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!("fo#[o{eol}|]#fo#(o{eol}|)#fo#(o{eol}|)#", eol = LINE_END), + LineFeedHandling::AsIs, + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_inside_nested_pair() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "f#[oo{open}{open}|]#{close}{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!( + "f#[oo{open}{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_middle_of_word() -> anyhow::Result<()> { + for pair in DEFAULT_PAIRS { + test(( + format!( + "f#[oo{open}{open}|]#{close}{close}{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + "a", + format!( + "f#[oo{open}{close}|]#{eol}", + open = pair.0, + close = pair.1, + eol = LINE_END + ), + )) + .await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn delete_append_inside_nested_pair_multi() -> anyhow::Result<()> { + for outer_pair in DEFAULT_PAIRS { + for inner_pair in DEFAULT_PAIRS { + if inner_pair.0 == outer_pair.0 { + continue; + } + + test(( + format!( + "f#[oo{outer_open}{inner_open}|]#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}f#(oo{outer_open}{inner_open}|)#{inner_close}{outer_close}{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + inner_open = inner_pair.0, + inner_close = inner_pair.1, + eol = LINE_END + ), + "a", + format!( + "f#[oo{outer_open}{outer_close}|]#{eol}f#(oo{outer_open}{outer_close}|)#{eol}f#(oo{outer_open}{outer_close}|)#{eol}", + outer_open = outer_pair.0, + outer_close = outer_pair.1, + eol = LINE_END + ), + )) + .await?; + } + } + + Ok(()) +}