Unnamed repository; edit this file 'description' to name the repository.
dead10ck
Skyler Hawthorne 5 months ago
parent 699391d · parent 09de695 · parent d015eff · commit fdd3f8b
-rw-r--r--helix-core/src/auto_pairs.rs308
-rw-r--r--helix-core/src/transaction.rs182
-rw-r--r--helix-term/src/commands.rs186
-rw-r--r--helix-term/tests/test/auto_pairs.rs872
4 files changed, 1294 insertions, 254 deletions
diff --git a/helix-core/src/auto_pairs.rs b/helix-core/src/auto_pairs.rs
index 85329040..5811186c 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<Transaction>
-// problem is, we want to do this per range, so we can call default handler for some ranges
-// so maybe ret Vec<Option<Change>>
-// but we also need to be able to return transactions...
-//
-// to simplify, maybe return Option<Transaction> 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<Transaction> {
- 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<char> {
if pos == 0 {
return None;
@@ -146,7 +235,7 @@ fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
}
/// 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;
-
- 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, 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))
- }
- };
+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;
+
+ // 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))
+ }
+ };
- let next_range = get_next_range(doc, start_range, offs, len_inserted);
- end_ranges.push(next_range);
- offs += len_inserted;
+ let next_range = get_next_range(doc, range, len_inserted);
+ let result = (change, next_range);
- change
- });
+ log::debug!("auto pair change: {:#?}", &result);
- 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;
-
- 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;
+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 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 change = if next_char == Some(pair.close) {
+ // return transaction that moves past close
+ (cursor, cursor, None) // no-op
+ } else {
+ return None;
+ };
- let next_range = get_next_range(doc, start_range, offs, len_inserted);
- end_ranges.push(next_range);
- offs += len_inserted;
+ let next_range = get_next_range(doc, range, 0);
+ let result = (change, next_range);
- change
- });
+ log::debug!("auto pair change: {:#?}", &result);
- 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());
-
- let mut offs = 0;
-
- 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 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);
-
- // 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);
- }
+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 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;
+ }
- len_inserted += pair_str.chars().count();
- (cursor, cursor, Some(pair_str))
- };
+ 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, start_range, offs, len_inserted);
- end_ranges.push(next_range);
- offs += len_inserted;
+ let next_range = get_next_range(doc, range, len_inserted);
+ let result = (change, next_range);
- change
- });
+ log::debug!("auto pair change: {:#?}", &result);
- 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 21b72b5f..b419ccf8 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<I>(doc: &Rope, changes: I) -> Self
+ where
+ I: Iterator<Item = Change>,
+ {
+ 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<Item = Change>,
{
- 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<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
+ where
+ F: FnMut(&Range) -> (Change, Option<Range>),
+ {
+ 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<F>(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<F>(doc: &Rope, selection: &Selection, mut f: F) -> Self
+ where
+ F: FnMut(&Range) -> (Deletion, Option<Range>),
+ {
+ 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 48c9fc99..c74c7429 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -4131,16 +4131,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<Transaction> {
- 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;
@@ -4150,15 +4140,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 });
}
@@ -4402,82 +4402,96 @@ pub mod insert {
doc.apply(&transaction, view.id);
}
+ fn dedent(doc: &Document, range: &Range) -> Option<Deletion> {
+ 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 c921e2ae..8048d97f 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<Item = &'static (char, char)> {
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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ "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{}<backspace>", 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{}<space><backspace><backspace>", 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{}<space><backspace><backspace>", 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{}<space><backspace><backspace>", 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{}<space><backspace><backspace>", 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<backspace>",
+ 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<backspace>",
+ format!("{}#[|f]#oo{}", pair.0, LINE_END),
+ ))
+ .await?;
+
+ // deleting whole pair before word
+ test((
+ format!("{}#[|{}]#foo{}", pair.0, pair.1, LINE_END),
+ "i<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ format!("{}#[|foo]#{}", pair.0, LINE_END),
+ ))
+ .await?;
+
+ // deleting whole pair before word
+ test((
+ format!("{}#[|{}foo]#{}", pair.0, pair.1, LINE_END),
+ "i<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
+ ))
+ .await?;
+
+ test((
+ format!(
+ "hello {}{}#[|👨‍👩‍👧‍👦]# goodbye{}",
+ pair.0, pair.1, LINE_END
+ ),
+ "i<backspace>",
+ format!("hello {}#[|👨‍👩‍👧‍👦]# goodbye{}", pair.0, LINE_END),
+ ))
+ .await?;
+
+ test((
+ format!(
+ "hello {}#[|{}]#👨‍👩‍👧‍👦 goodbye{}",
+ pair.0, pair.1, LINE_END
+ ),
+ "i<backspace>",
+ format!("hello #[|👨‍👩‍👧‍👦]# goodbye{}", LINE_END),
+ ))
+ .await?;
+
+ test((
+ format!(
+ "hello {}#[|{}👨‍👩‍👧‍👦]# goodbye{}",
+ pair.0, pair.1, LINE_END
+ ),
+ "i<backspace>",
+ 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<backspace>"),
+ 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<backspace>"),
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ indoc! {"\
+ bar = #[|\n]#
+ #(|\n)#
+ fo#(|\n)#
+ "},
+ ))
+ .await?;
+
+ test((
+ format!(
+ indoc! {"\
+ bar = {}#[|{}woop]#
+ #(|word)#
+ fo#(|o)#
+ "},
+ pair.0, pair.1,
+ ),
+ "i<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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<backspace>",
+ 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(())
+}