on master help sign in
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

commit94535fa013469abf6e5ab2fa52f3deced9d46de0 authorSkyler Hawthorne <skyler@dead10ck.com> dateMon 13 Dec 2021, 10:58 (5 years ago) parent730d3be
3 files changed+351−79
helix-core/src/auto_pairs.rs+346−75 helix-core/src/transaction.rs+1−1 helix-term/src/commands.rs+4−3
helix-core/src/auto_pairs.rs+346−75
@@ -2,6 +2,7 @@
22//! this module provides the functionality to insert the paired closing character.
33
44use crate::{Range, Rope, Selection, Tendril, Transaction};
5+use log::debug;
56use smallvec::SmallVec;
67
78// Heavily based on https://github.com/codemirror/closebrackets/
@@ -15,7 +16,9 @@ pub const PAIRS: &[(char, char)] = &[
1516 ('`', '`'),
1617];
1718
18−const CLOSE_BEFORE: &str = ")]}'\":;> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines
19+// [TODO] build this dynamically in language config. see #992
20+const OPEN_BEFORE: &str = "([{'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}";
21+const CLOSE_BEFORE: &str = ")]}'\":;,> \n\r\u{000B}\u{000C}\u{0085}\u{2028}\u{2029}"; // includes space and newlines
1922
2023// insert hook:
2124// 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
2528//
2629// to simplify, maybe return Option<Transaction> and just reimplement the default
2730
28−// TODO: delete implementation where it erases the whole bracket (|) -> |
31+// [TODO]
32+// * delete implementation where it erases the whole bracket (|) -> |
33+// * do not reduce to cursors; use whole selections, and surround with pair
34+// * change to multi character pairs to handle cases like placing the cursor in the
35+// middle of triple quotes, and more exotic pairs like Jinja's {% %}
2936
3037#[must_use]
3138pub fn hook(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
39+ debug!("autopairs hook selection: {:#?}", selection);
40+
41+ let cursors = selection.clone().cursors(doc.slice(..));
42+
3243 for &(open, close) in PAIRS {
3344 if open == ch {
3445 if open == close {
35− return handle_same(doc, selection, open);
46+ return Some(handle_same(doc, &cursors, open, CLOSE_BEFORE, OPEN_BEFORE));
3647 } else {
37− return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE));
48+ return Some(handle_open(doc, &cursors, open, close, CLOSE_BEFORE));
3849 }
3950 }
4051
4152 if close == ch {
4253 // && char_at pos == close
43− return Some(handle_close(doc, selection, open, close));
54+ return Some(handle_close(doc, &cursors, open, close));
4455 }
4556 }
4657
4758 None
4859}
4960
50−// TODO: special handling for lifetimes in rust: if preceeded by & or < don't auto close '
51−// for example "&'a mut", or "fn<'a>"
52−
53−fn next_char(doc: &Rope, pos: usize) -> Option<char> {
54− if pos >= doc.len_chars() {
61+fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
62+ if pos == 0 {
5563 return None;
5664 }
57− Some(doc.char(pos))
65+
66+ doc.get_char(pos - 1)
5867}
59−// TODO: selections should be extended if range, moved if point.
6068
61−// TODO: if not cursor but selection, wrap on both sides of selection (surround)
6269fn handle_open(
6370 doc: &Rope,
6471 selection: &Selection,
@@ -66,98 +73,362 @@ fn handle_open(
6673 close: char,
6774 close_before: &str,
6875) -> Transaction {
69− let mut ranges = SmallVec::with_capacity(selection.len());
76+ let mut end_ranges = SmallVec::with_capacity(selection.len());
7077
7178 let mut offs = 0;
7279
73− let transaction = Transaction::change_by_selection(doc, selection, |range| {
74− let pos = range.head;
75− let next = next_char(doc, pos);
80+ let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
81+ let start_head = start_range.head;
7682
77− let head = pos + offs + open.len_utf8();
78− // if selection, retain anchor, if cursor, move over
79− ranges.push(Range::new(
80− if range.is_empty() {
81− head
82− } else {
83− range.anchor + offs
84− },
85− head,
86− ));
83+ let next = doc.get_char(start_head);
84+ let end_head = start_head + offs + open.len_utf8();
85+
86+ let end_anchor = if start_range.is_empty() {
87+ end_head
88+ } else {
89+ start_range.anchor + offs
90+ };
91+
92+ end_ranges.push(Range::new(end_anchor, end_head));
8793
8894 match next {
8995 Some(ch) if !close_before.contains(ch) => {
90− offs += 1;
91− // TODO: else return (use default handler that inserts open)
92− (pos, pos, Some(Tendril::from_char(open)))
96+ offs += open.len_utf8();
97+ (start_head, start_head, Some(Tendril::from_char(open)))
9398 }
9499 // None | Some(ch) if close_before.contains(ch) => {}
95100 _ => {
96101 // insert open & close
97− let mut pair = Tendril::with_capacity(2);
98− pair.push_char(open);
99− pair.push_char(close);
100−
101− offs += 2;
102−
103− (pos, pos, Some(pair))
102+ let pair = Tendril::from_iter([open, close]);
103+ offs += open.len_utf8() + close.len_utf8();
104+ (start_head, start_head, Some(pair))
104105 }
105106 }
106107 });
107108
108− transaction.with_selection(Selection::new(ranges, selection.primary_index()))
109+ let t = transaction.with_selection(Selection::new(end_ranges, selection.primary_index()));
110+ debug!("auto pair transaction: {:#?}", t);
111+ t
109112}
110113
111114fn handle_close(doc: &Rope, selection: &Selection, _open: char, close: char) -> Transaction {
112− let mut ranges = SmallVec::with_capacity(selection.len());
115+ let mut end_ranges = SmallVec::with_capacity(selection.len());
113116
114117 let mut offs = 0;
115118
116− let transaction = Transaction::change_by_selection(doc, selection, |range| {
117− let pos = range.head;
118− let next = next_char(doc, pos);
119+ let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
120+ let start_head = start_range.head;
121+ let next = doc.get_char(start_head);
122+ let end_head = start_head + offs + close.len_utf8();
119123
120− let head = pos + offs + close.len_utf8();
121− // if selection, retain anchor, if cursor, move over
122− ranges.push(Range::new(
123− if range.is_empty() {
124− head
125− } else {
126− range.anchor + offs
127− },
128− head,
129− ));
124+ let end_anchor = if start_range.is_empty() {
125+ end_head
126+ } else {
127+ start_range.anchor + offs
128+ };
129+
130+ end_ranges.push(Range::new(end_anchor, end_head));
130131
131132 if next == Some(close) {
132− // return transaction that moves past close
133− (pos, pos, None) // no-op
133+ // return transaction that moves past close
134+ (start_head, start_head, None) // no-op
134135 } else {
135136 offs += close.len_utf8();
137+ (start_head, start_head, Some(Tendril::from_char(close)))
138+ }
139+ });
140+
141+ transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
142+}
136143
137− // TODO: else return (use default handler that inserts close)
138− (pos, pos, Some(Tendril::from_char(close)))
144+/// handle cases where open and close is the same, or in triples ("""docstring""")
145+fn handle_same(
146+ doc: &Rope,
147+ selection: &Selection,
148+ token: char,
149+ close_before: &str,
150+ open_before: &str,
151+) -> Transaction {
152+ let mut end_ranges = SmallVec::with_capacity(selection.len());
153+
154+ let mut offs = 0;
155+
156+ let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
157+ let start_head = start_range.head;
158+ let end_head = start_head + offs + token.len_utf8();
159+
160+ // if selection, retain anchor, if cursor, move over
161+ let end_anchor = if start_range.is_empty() {
162+ end_head
163+ } else {
164+ start_range.anchor + offs
165+ };
166+
167+ end_ranges.push(Range::new(end_anchor, end_head));
168+
169+ let next = doc.get_char(start_head);
170+ let prev = prev_char(doc, start_head);
171+
172+ if next == Some(token) {
173+ // return transaction that moves past close
174+ (start_head, start_head, None) // no-op
175+ } else {
176+ let mut pair = Tendril::with_capacity(2 * token.len_utf8() as u32);
177+ pair.push_char(token);
178+
179+ // for equal pairs, don't insert both open and close if either
180+ // side has a non-pair char
181+ if (next.is_none() || close_before.contains(next.unwrap()))
182+ && (prev.is_none() || open_before.contains(prev.unwrap()))
183+ {
184+ pair.push_char(token);
185+ }
186+
187+ offs += pair.len();
188+ (start_head, start_head, Some(pair))
139189 }
140190 });
141191
142− transaction.with_selection(Selection::new(ranges, selection.primary_index()))
192+ transaction.with_selection(Selection::new(end_ranges, selection.primary_index()))
143193}
144194
145−// handle cases where open and close is the same, or in triples ("""docstring""")
146−fn handle_same(_doc: &Rope, _selection: &Selection, _token: char) -> Option<Transaction> {
147− // if not cursor but selection, wrap
148− // let next = next char
149−
150− // if next == bracket {
151− // // if start of syntax node, insert token twice (new pair because node is complete)
152− // // elseif colsedBracketAt
153− // // is_triple == allow triple && next 3 is equal
154− // // cursor jump over
155− // }
156− //} else if allow_triple && followed by triple {
157− //}
158− //} else if next != word char && prev != bracket && prev != word char {
159− // // condition checks for cases like I' where you don't want I'' (or I'm)
160− // insert pair ("")
161− //}
162− None
195+#[cfg(test)]
196+mod test {
197+ use super::*;
198+ use smallvec::smallvec;
199+
200+ fn differing_pairs() -> impl Iterator<Item = &'static (char, char)> {
201+ PAIRS.iter().filter(|(open, close)| open != close)
202+ }
203+
204+ fn matching_pairs() -> impl Iterator<Item = &'static (char, char)> {
205+ PAIRS.iter().filter(|(open, close)| open == close)
206+ }
207+
208+ fn test_hooks(
209+ in_doc: &Rope,
210+ in_sel: &Selection,
211+ ch: char,
212+ expected_doc: &Rope,
213+ expected_sel: &Selection,
214+ ) {
215+ let trans = hook(&in_doc, &in_sel, ch).unwrap();
216+ let mut actual_doc = in_doc.clone();
217+ assert!(trans.apply(&mut actual_doc));
218+ assert_eq!(expected_doc, &actual_doc);
219+ assert_eq!(expected_sel, trans.selection().unwrap());
220+ }
221+
222+ fn test_hooks_with_pairs<I, F, R>(
223+ in_doc: &Rope,
224+ in_sel: &Selection,
225+ pairs: I,
226+ get_expected_doc: F,
227+ actual_sel: &Selection,
228+ ) where
229+ I: IntoIterator<Item = &'static (char, char)>,
230+ F: Fn(char, char) -> R,
231+ R: Into<Rope>,
232+ Rope: From<R>,
233+ {
234+ pairs.into_iter().for_each(|(open, close)| {
235+ test_hooks(
236+ in_doc,
237+ in_sel,
238+ *open,
239+ &Rope::from(get_expected_doc(*open, *close)),
240+ actual_sel,
241+ )
242+ });
243+ }
244+
245+ // [] indicates range
246+
247+ /// [] -> insert ( -> ([])
248+ #[test]
249+ fn test_insert_blank() {
250+ test_hooks_with_pairs(
251+ &Rope::new(),
252+ &Selection::single(1, 0),
253+ PAIRS,
254+ |open, close| format!("{}{}", open, close),
255+ &Selection::single(1, 1),
256+ );
257+ }
258+
259+ /// [] ([])
260+ /// [] -> insert -> ([])
261+ /// [] ([])
262+ #[test]
263+ fn test_insert_blank_multi_cursor() {
264+ test_hooks_with_pairs(
265+ &Rope::from("\n\n\n"),
266+ &Selection::new(
267+ smallvec!(Range::new(1, 0), Range::new(2, 1), Range::new(3, 2),),
268+ 0,
269+ ),
270+ PAIRS,
271+ |open, close| {
272+ format!(
273+ "{open}{close}\n{open}{close}\n{open}{close}\n",
274+ open = open,
275+ close = close
276+ )
277+ },
278+ &Selection::new(
279+ smallvec!(Range::point(1), Range::point(4), Range::point(7),),
280+ 0,
281+ ),
282+ );
283+ }
284+
285+ // [TODO] broken until it works with selections
286+ /// fo[o] -> append ( -> fo[o(])
287+ #[ignore]
288+ #[test]
289+ fn test_append() {
290+ test_hooks_with_pairs(
291+ &Rope::from("foo"),
292+ &Selection::single(2, 4),
293+ PAIRS,
294+ |open, close| format!("foo{}{}", open, close),
295+ &Selection::single(2, 5),
296+ );
297+ }
298+
299+ /// ([]) -> insert ) -> ()[]
300+ #[test]
301+ fn test_insert_close_inside_pair() {
302+ for (open, close) in PAIRS {
303+ let doc = Rope::from(format!("{}{}", open, close));
304+
305+ test_hooks(
306+ &doc,
307+ &Selection::single(2, 1),
308+ *close,
309+ &doc,
310+ &Selection::point(2),
311+ );
312+ }
313+ }
314+
315+ /// ([]) ()[]
316+ /// ([]) -> insert ) -> ()[]
317+ /// ([]) ()[]
318+ #[test]
319+ fn test_insert_close_inside_pair_multi_cursor() {
320+ let sel = Selection::new(
321+ smallvec!(Range::new(2, 1), Range::new(5, 4), Range::new(8, 7),),
322+ 0,
323+ );
324+
325+ let expected_sel = Selection::new(
326+ // smallvec!(Range::new(3, 2), Range::new(6, 5), Range::new(9, 8),),
327+ smallvec!(Range::point(2), Range::point(5), Range::point(8),),
328+ 0,
329+ );
330+
331+ for (open, close) in PAIRS {
332+ let doc = Rope::from(format!(
333+ "{open}{close}\n{open}{close}\n{open}{close}\n",
334+ open = open,
335+ close = close
336+ ));
337+
338+ test_hooks(&doc, &sel, *close, &doc, &expected_sel);
339+ }
340+ }
341+
342+ /// ([]) -> insert ( -> (([]))
343+ #[test]
344+ fn test_insert_open_inside_pair() {
345+ let sel = Selection::single(2, 1);
346+ let expected_sel = Selection::point(2);
347+
348+ for (open, close) in differing_pairs() {
349+ let doc = Rope::from(format!("{}{}", open, close));
350+ let expected_doc = Rope::from(format!(
351+ "{open}{open}{close}{close}",
352+ open = open,
353+ close = close
354+ ));
355+
356+ test_hooks(&doc, &sel, *open, &expected_doc, &expected_sel);
357+ }
358+ }
359+
360+ /// ([]) -> insert " -> ("[]")
361+ #[test]
362+ fn test_insert_nested_open_inside_pair() {
363+ let sel = Selection::single(2, 1);
364+ let expected_sel = Selection::point(2);
365+
366+ for (outer_open, outer_close) in differing_pairs() {
367+ let doc = Rope::from(format!("{}{}", outer_open, outer_close,));
368+
369+ for (inner_open, inner_close) in matching_pairs() {
370+ let expected_doc = Rope::from(format!(
371+ "{}{}{}{}",
372+ outer_open, inner_open, inner_close, outer_close
373+ ));
374+
375+ test_hooks(&doc, &sel, *inner_open, &expected_doc, &expected_sel);
376+ }
377+ }
378+ }
379+
380+ /// []word -> insert ( -> ([]word
381+ #[test]
382+ fn test_insert_open_before_non_pair() {
383+ test_hooks_with_pairs(
384+ &Rope::from("word"),
385+ &Selection::single(1, 0),
386+ PAIRS,
387+ |open, _| format!("{}word", open),
388+ &Selection::point(1),
389+ )
390+ }
391+
392+ // [TODO] broken until it works with selections
393+ /// [wor]d -> insert ( -> ([wor]d
394+ #[test]
395+ #[ignore]
396+ fn test_insert_open_with_selection() {
397+ test_hooks_with_pairs(
398+ &Rope::from("word"),
399+ &Selection::single(0, 4),
400+ PAIRS,
401+ |open, _| format!("{}word", open),
402+ &Selection::single(1, 5),
403+ )
404+ }
405+
406+ /// we want pairs that are *not* the same char to be inserted after
407+ /// a non-pair char, for cases like functions, but for pairs that are
408+ /// the same char, we want to *not* insert a pair to handle cases like "I'm"
409+ ///
410+ /// word[] -> insert ( -> word([])
411+ /// word[] -> insert ' -> word'[]
412+ #[test]
413+ fn test_insert_open_after_non_pair() {
414+ let doc = Rope::from("word");
415+ let sel = Selection::single(5, 4);
416+ let expected_sel = Selection::point(5);
417+
418+ test_hooks_with_pairs(
419+ &doc,
420+ &sel,
421+ differing_pairs(),
422+ |open, close| format!("word{}{}", open, close),
423+ &expected_sel,
424+ );
425+
426+ test_hooks_with_pairs(
427+ &doc,
428+ &sel,
429+ matching_pairs(),
430+ |open, _| format!("word{}", open),
431+ &expected_sel,
432+ );
433+ }
163434}
helix-core/src/transaction.rs+1−1
@@ -409,7 +409,7 @@ impl ChangeSet {
409409
410410/// Transaction represents a single undoable unit of changes. Several changes can be grouped into
411411/// a single transaction.
412−#[derive(Debug, Default, Clone)]
412+#[derive(Debug, Default, Clone, PartialEq, Eq)]
413413pub struct Transaction {
414414 changes: ChangeSet,
415415 selection: Option<Selection>,
helix-term/src/commands.rs+4−3
@@ -4199,8 +4199,9 @@ pub mod insert {
41994199 // The default insert hook: simply insert the character
42004200 #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
42014201 fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
4202+ let cursors = selection.clone().cursors(doc.slice(..));
42024203 let t = Tendril::from_char(ch);
4203− let transaction = Transaction::insert(doc, selection, t);
4204+ let transaction = Transaction::insert(doc, &cursors, t);
42044205 Some(transaction)
42054206 }
42064207
@@ -4215,11 +4216,11 @@ pub mod insert {
42154216 };
42164217
42174218 let text = doc.text();
4218− let selection = doc.selection(view.id).clone().cursors(text.slice(..));
4219+ let selection = doc.selection(view.id);
42194220
42204221 // run through insert hooks, stopping on the first one that returns Some(t)
42214222 for hook in hooks {
4222− if let Some(transaction) = hook(text, &selection, c) {
4223+ if let Some(transaction) = hook(text, selection, c) {
42234224 doc.apply(&transaction, view.id);
42244225 break;
42254226 }