Add auto pairs for same-char pairs (#1219)

* Add auto pairs for same-char pairs

* Add unit tests for all existing functionality
* Add auto pairs for same-char pairs (quotes, etc). Account for
  apostrophe in prose by requiring both sides of the cursor to be
  non-pair chars or whitespace. This also incidentally will work for
  avoiding a double single quote in lifetime annotations, at least until
  <> is added
* Slight factor of moving the cursor transform of the selection to
  inside the hooks. This will enable doing auto pairing with selections,
  and fixing the bug where auto pairs destroy the selection.

Fixes #1014
pull/1263/head
Skyler Hawthorne 2021-12-13 10:58:58 -05:00 committed by GitHub
parent 730d3be201
commit 94535fa013
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 352 additions and 80 deletions

View File

@ -2,6 +2,7 @@
//! this module provides the functionality to insert the paired closing character. //! this module provides the functionality to insert the paired closing character.
use crate::{Range, Rope, Selection, Tendril, Transaction}; use crate::{Range, Rope, Selection, Tendril, Transaction};
use log::debug;
use smallvec::SmallVec; use smallvec::SmallVec;
// Heavily based on https://github.com/codemirror/closebrackets/ // Heavily based on https://github.com/codemirror/closebrackets/
@ -15,7 +16,9 @@ pub const PAIRS: &[(char, char)] = &[
('`', '`'), ('`', '`'),
]; ];
const CLOSE_BEFORE: &str = ")]}'\":;> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines // [TODO] build this dynamically in language config. see #992
const OPEN_BEFORE: &str = "([{'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}";
const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines
// insert hook: // insert hook:
// Fn(doc, selection, char) => Option<Transaction> // Fn(doc, selection, char) => Option<Transaction>
@ -25,40 +28,44 @@ const CLOSE_BEFORE: &str = ")]}'\":;> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{202
// //
// to simplify, maybe return Option<Transaction> and just reimplement the default // to simplify, maybe return Option<Transaction> and just reimplement the default
// TODO: delete implementation where it erases the whole bracket (|) -> | // [TODO]
// * delete implementation where it erases the whole bracket (|) -> |
// * do not reduce to cursors; use whole selections, and surround with pair
// * 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] #[must_use]
pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> { pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
debug!("autopairs hook selection: {:#?}", selection);
let cursors = selection.clone().cursors(doc.slice(..));
for &(open, close) in PAIRS { for &(open, close) in PAIRS {
if open == ch { if open == ch {
if open == close { if open == close {
return handle_same(doc, selection, open); return Some(handle_same(doc, &cursors, open, CLOSE_BEFORE, OPEN_BEFORE));
} else { } else {
return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE)); return Some(handle_open(doc, &cursors, open, close, CLOSE_BEFORE));
} }
} }
if close == ch { if close == ch {
// && char_at pos == close // && char_at pos == close
return Some(handle_close(doc, selection, open, close)); return Some(handle_close(doc, &cursors, open, close));
} }
} }
None None
} }
// TODO: special handling for lifetimes in rust: if preceeded by & or < don't auto close ' fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
// for example "&'a mut", or "fn<'a>" if pos == 0 {
fn next_char(doc: &Rope, pos: usize) -> Option<char> {
if pos >= doc.len_chars() {
return None; return None;
} }
Some(doc.char(pos))
}
// TODO: selections should be extended if range, moved if point.
// TODO: if not cursor but selection, wrap on both sides of selection (surround) doc.get_char(pos - 1)
}
fn handle_open( fn handle_open(
doc: &Rope, doc: &Rope,
selection: &Selection, selection: &Selection,
@ -66,98 +73,362 @@ fn handle_open(
close: char, close: char,
close_before: &str, close_before: &str,
) -> Transaction { ) -> Transaction {
let mut ranges = SmallVec::with_capacity(selection.len()); let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0; let mut offs = 0;
let transaction = Transaction::change_by_selection(doc, selection, |range| { let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let pos = range.head; let start_head = start_range.head;
let next = next_char(doc, pos);
let head = pos + offs + open.len_utf8(); let next = doc.get_char(start_head);
// if selection, retain anchor, if cursor, move over let end_head = start_head + offs + open.len_utf8();
ranges.push(Range::new(
if range.is_empty() { let end_anchor = if start_range.is_empty() {
head end_head
} else { } else {
range.anchor + offs start_range.anchor + offs
}, };
head,
)); end_ranges.push(Range::new(end_anchor, end_head));
match next { match next {
Some(ch) if !close_before.contains(ch) => { Some(ch) if !close_before.contains(ch) => {
offs += 1; offs += open.len_utf8();
// TODO: else return (use default handler that inserts open) (start_head, start_head, Some(Tendril::from_char(open)))
(pos, pos, Some(Tendril::from_char(open)))
} }
// None | Some(ch) if close_before.contains(ch) => {} // None | Some(ch) if close_before.contains(ch) => {}
_ => { _ => {
// insert open & close // insert open & close
let mut pair = Tendril::with_capacity(2); let pair = Tendril::from_iter([open, close]);
pair.push_char(open); offs += open.len_utf8() + close.len_utf8();
pair.push_char(close); (start_head, start_head, Some(pair))
offs += 2;
(pos, pos, Some(pair))
} }
} }
}); });
transaction.with_selection(Selection::new(ranges, selection.primary_index())) let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
debug!("auto pair transaction: {:#?}", t);
t
} }
fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction { fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction {
let mut ranges = SmallVec::with_capacity(selection.len()); let mut end_ranges = SmallVec::with_capacity(selection.len());
let mut offs = 0; let mut offs = 0;
let transaction = Transaction::change_by_selection(doc, selection, |range| { let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
let pos = range.head; let start_head = start_range.head;
let next = next_char(doc, pos); let next = doc.get_char(start_head);
let end_head = start_head + offs + close.len_utf8();
let head = pos + offs + close.len_utf8(); let end_anchor = if start_range.is_empty() {
// if selection, retain anchor, if cursor, move over end_head
ranges.push(Range::new( } else {
if range.is_empty() { start_range.anchor + offs
head };
} else {
range.anchor + offs end_ranges.push(Range::new(end_anchor, end_head));
},
head,
));
if next == Some(close) { if next == Some(close) {
// return transaction that moves past close // return transaction that moves past close
(pos, pos, None) // no-op (start_head, start_head, None) // no-op
} else { } else {
offs += close.len_utf8(); offs += close.len_utf8();
(start_head, start_head, Some(Tendril::from_char(close)))
// TODO: else return (use default handler that inserts close)
(pos, pos, Some(Tendril::from_char(close)))
} }
}); });
transaction.with_selection(Selection::new(ranges, selection.primary_index())) transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
} }
// handle cases where open and close is the same, or in triples ("""docstring""") /// handle cases where open and close is the same, or in triples ("""docstring""")
fn handle_same(_doc: &Rope, _selection: &Selection, _token: char) -> Option<Transaction> { fn handle_same(
// if not cursor but selection, wrap doc: &Rope,
// let next = next char selection: &Selection,
token: char,
close_before: &str,
open_before: &str,
) -> Transaction {
let mut end_ranges = SmallVec::with_capacity(selection.len());
// if next == bracket { let mut offs = 0;
// // if start of syntax node, insert token twice (new pair because node is complete)
// // elseif colsedBracketAt let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
// // is_triple == allow triple && next 3 is equal let start_head = start_range.head;
// // cursor jump over let end_head = start_head + offs + token.len_utf8();
// }
//} else if allow_triple && followed by triple { // if selection, retain anchor, if cursor, move over
//} let end_anchor = if start_range.is_empty() {
//} else if next != word char && prev != bracket && prev != word char { end_head
// // condition checks for cases like I' where you don't want I'' (or I'm) } else {
// insert pair ("") start_range.anchor + offs
//} };
None
end_ranges.push(Range::new(end_anchor, end_head));
let next = doc.get_char(start_head);
let prev = prev_char(doc, start_head);
if next == Some(token) {
// return transaction that moves past close
(start_head, start_head, None) // no-op
} else {
let mut pair = Tendril::with_capacity(2 * token.len_utf8() as u32);
pair.push_char(token);
// for equal pairs, don't insert both open and close if either
// side has a non-pair char
if (next.is_none() || close_before.contains(next.unwrap()))
&& (prev.is_none() || open_before.contains(prev.unwrap()))
{
pair.push_char(token);
}
offs += pair.len();
(start_head, start_head, Some(pair))
}
});
transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
}
#[cfg(test)]
mod test {
use super::*;
use smallvec::smallvec;
fn differing_pairs() -> impl Iterator<Item = &'static (char, char)> {
PAIRS.iter().filter(|(open, close)| open != close)
}
fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
PAIRS.iter().filter(|(open, close)| open == close)
}
fn test_hooks(
in_doc: &Rope,
in_sel: &Selection,
ch: char,
expected_doc: &Rope,
expected_sel: &Selection,
) {
let trans = hook(&in_doc, &in_sel, ch).unwrap();
let mut actual_doc = in_doc.clone();
assert!(trans.apply(&mut actual_doc));
assert_eq!(expected_doc, &actual_doc);
assert_eq!(expected_sel, trans.selection().unwrap());
}
fn test_hooks_with_pairs<I, F, R>(
in_doc: &Rope,
in_sel: &Selection,
pairs: I,
get_expected_doc: F,
actual_sel: &Selection,
) where
I: IntoIterator<Item = &'static (char, char)>,
F: Fn(char, char) -> R,
R: Into<Rope>,
Rope: From<R>,
{
pairs.into_iter().for_each(|(open, close)| {
test_hooks(
in_doc,
in_sel,
*open,
&Rope::from(get_expected_doc(*open, *close)),
actual_sel,
)
});
}
// [] indicates range
/// [] -> insert ( -> ([])
#[test]
fn test_insert_blank() {
test_hooks_with_pairs(
&Rope::new(),
&Selection::single(1, 0),
PAIRS,
|open, close| format!("{}{}", open, close),
&Selection::single(1, 1),
);
}
/// [] ([])
/// [] -> insert -> ([])
/// [] ([])
#[test]
fn test_insert_blank_multi_cursor() {
test_hooks_with_pairs(
&Rope::from("\n\n\n"),
&Selection::new(
smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),),
0,
),
PAIRS,
|open, close| {
format!(
"{open}{close}\n{open}{close}\n{open}{close}\n",
open = open,
close = close
)
},
&Selection::new(
smallvec!(Range::point(1), Range::point(4), Range::point(7),),
0,
),
);
}
// [TODO] broken until it works with selections
/// fo[o] -> append ( -> fo[o(])
#[ignore]
#[test]
fn test_append() {
test_hooks_with_pairs(
&Rope::from("foo"),
&Selection::single(2, 4),
PAIRS,
|open, close| format!("foo{}{}", open, close),
&Selection::single(2, 5),
);
}
/// ([]) -> insert ) -> ()[]
#[test]
fn test_insert_close_inside_pair() {
for (open, close) in PAIRS {
let doc = Rope::from(format!("{}{}", open, close));
test_hooks(
&doc,
&Selection::single(2, 1),
*close,
&doc,
&Selection::point(2),
);
}
}
/// ([]) ()[]
/// ([]) -> insert ) -> ()[]
/// ([]) ()[]
#[test]
fn test_insert_close_inside_pair_multi_cursor() {
let sel = Selection::new(
smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),),
0,
);
let expected_sel = Selection::new(
// smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),),
smallvec!(Range::point(2), Range::point(5), Range::point(8),),
0,
);
for (open, close) in PAIRS {
let doc = Rope::from(format!(
"{open}{close}\n{open}{close}\n{open}{close}\n",
open = open,
close = close
));
test_hooks(&doc, &sel, *close, &doc, &expected_sel);
}
}
/// ([]) -> insert ( -> (([]))
#[test]
fn test_insert_open_inside_pair() {
let sel = Selection::single(2, 1);
let expected_sel = Selection::point(2);
for (open, close) in differing_pairs() {
let doc = Rope::from(format!("{}{}", open, close));
let expected_doc = Rope::from(format!(
"{open}{open}{close}{close}",
open = open,
close = close
));
test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel);
}
}
/// ([]) -> insert " -> ("[]")
#[test]
fn test_insert_nested_open_inside_pair() {
let sel = Selection::single(2, 1);
let expected_sel = Selection::point(2);
for (outer_open, outer_close) in differing_pairs() {
let doc = Rope::from(format!("{}{}", outer_open, outer_close,));
for (inner_open, inner_close) in matching_pairs() {
let expected_doc = Rope::from(format!(
"{}{}{}{}",
outer_open, inner_open, inner_close, outer_close
));
test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel);
}
}
}
/// []word -> insert ( -> ([]word
#[test]
fn test_insert_open_before_non_pair() {
test_hooks_with_pairs(
&Rope::from("word"),
&Selection::single(1, 0),
PAIRS,
|open, _| format!("{}word", open),
&Selection::point(1),
)
}
// [TODO] broken until it works with selections
/// [wor]d -> insert ( -> ([wor]d
#[test]
#[ignore]
fn test_insert_open_with_selection() {
test_hooks_with_pairs(
&Rope::from("word"),
&Selection::single(0, 4),
PAIRS,
|open, _| format!("{}word", open),
&Selection::single(1, 5),
)
}
/// we want pairs that are *not* the same char to be inserted after
/// a non-pair char, for cases like functions, but for pairs that are
/// the same char, we want to *not* insert a pair to handle cases like "I'm"
///
/// word[] -> insert ( -> word([])
/// word[] -> insert ' -> word'[]
#[test]
fn test_insert_open_after_non_pair() {
let doc = Rope::from("word");
let sel = Selection::single(5, 4);
let expected_sel = Selection::point(5);
test_hooks_with_pairs(
&doc,
&sel,
differing_pairs(),
|open, close| format!("word{}{}", open, close),
&expected_sel,
);
test_hooks_with_pairs(
&doc,
&sel,
matching_pairs(),
|open, _| format!("word{}", open),
&expected_sel,
);
}
} }

View File

@ -409,7 +409,7 @@ impl ChangeSet {
/// Transaction represents a single undoable unit of changes. Several changes can be grouped into /// Transaction represents a single undoable unit of changes. Several changes can be grouped into
/// a single transaction. /// a single transaction.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Transaction { pub struct Transaction {
changes: ChangeSet, changes: ChangeSet,
selection: Option<Selection>, selection: Option<Selection>,

View File

@ -4199,8 +4199,9 @@ pub mod insert {
// The default insert hook: simply insert the character // The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> { fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
let cursors = selection.clone().cursors(doc.slice(..));
let t = Tendril::from_char(ch); let t = Tendril::from_char(ch);
let transaction = Transaction::insert(doc, selection, t); let transaction = Transaction::insert(doc, &cursors, t);
Some(transaction) Some(transaction)
} }
@ -4215,11 +4216,11 @@ pub mod insert {
}; };
let text = doc.text(); let text = doc.text();
let selection = doc.selection(view.id).clone().cursors(text.slice(..)); let selection = doc.selection(view.id);
// run through insert hooks, stopping on the first one that returns Some(t) // run through insert hooks, stopping on the first one that returns Some(t)
for hook in hooks { for hook in hooks {
if let Some(transaction) = hook(text, &selection, c) { if let Some(transaction) = hook(text, selection, c) {
doc.apply(&transaction, view.id); doc.apply(&transaction, view.id);
break; break;
} }