mirror of
https://github.com/ilyakooo0/helix.git
synced 2024-09-11 16:07:00 +03:00
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
This commit is contained in:
parent
730d3be201
commit
94535fa013
@ -2,6 +2,7 @@
|
||||
//! this module provides the functionality to insert the paired closing character.
|
||||
|
||||
use crate::{Range, Rope, Selection, Tendril, Transaction};
|
||||
use log::debug;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
// Heavily based on https://github.com/codemirror/closebrackets/
|
||||
@ -15,7 +16,9 @@
|
||||
('`', '`'),
|
||||
];
|
||||
|
||||
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:
|
||||
// Fn(doc, selection, char) => Option<Transaction>
|
||||
@ -25,40 +28,44 @@
|
||||
//
|
||||
// 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]
|
||||
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 {
|
||||
if open == ch {
|
||||
if open == close {
|
||||
return handle_same(doc, selection, open);
|
||||
return Some(handle_same(doc, &cursors, open, CLOSE_BEFORE, OPEN_BEFORE));
|
||||
} else {
|
||||
return Some(handle_open(doc, selection, open, close, CLOSE_BEFORE));
|
||||
return Some(handle_open(doc, &cursors, open, close, CLOSE_BEFORE));
|
||||
}
|
||||
}
|
||||
|
||||
if close == ch {
|
||||
// && char_at pos == close
|
||||
return Some(handle_close(doc, selection, open, close));
|
||||
return Some(handle_close(doc, &cursors, open, close));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// TODO: special handling for lifetimes in rust: if preceeded by & or < don't auto close '
|
||||
// for example "&'a mut", or "fn<'a>"
|
||||
|
||||
fn next_char(doc: &Rope, pos: usize) -> Option<char> {
|
||||
if pos >= doc.len_chars() {
|
||||
fn prev_char(doc: &Rope, pos: usize) -> Option<char> {
|
||||
if pos == 0 {
|
||||
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(
|
||||
doc: &Rope,
|
||||
selection: &Selection,
|
||||
@ -66,98 +73,362 @@ fn handle_open(
|
||||
close: char,
|
||||
close_before: &str,
|
||||
) -> Transaction {
|
||||
let mut ranges = SmallVec::with_capacity(selection.len());
|
||||
let mut end_ranges = SmallVec::with_capacity(selection.len());
|
||||
|
||||
let mut offs = 0;
|
||||
|
||||
let transaction = Transaction::change_by_selection(doc, selection, |range| {
|
||||
let pos = range.head;
|
||||
let next = next_char(doc, pos);
|
||||
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
|
||||
let start_head = start_range.head;
|
||||
|
||||
let head = pos + offs + open.len_utf8();
|
||||
// if selection, retain anchor, if cursor, move over
|
||||
ranges.push(Range::new(
|
||||
if range.is_empty() {
|
||||
head
|
||||
} else {
|
||||
range.anchor + offs
|
||||
},
|
||||
head,
|
||||
));
|
||||
let next = doc.get_char(start_head);
|
||||
let end_head = start_head + offs + open.len_utf8();
|
||||
|
||||
let end_anchor = if start_range.is_empty() {
|
||||
end_head
|
||||
} else {
|
||||
start_range.anchor + offs
|
||||
};
|
||||
|
||||
end_ranges.push(Range::new(end_anchor, end_head));
|
||||
|
||||
match next {
|
||||
Some(ch) if !close_before.contains(ch) => {
|
||||
offs += 1;
|
||||
// TODO: else return (use default handler that inserts open)
|
||||
(pos, pos, Some(Tendril::from_char(open)))
|
||||
offs += open.len_utf8();
|
||||
(start_head, start_head, Some(Tendril::from_char(open)))
|
||||
}
|
||||
// None | Some(ch) if close_before.contains(ch) => {}
|
||||
_ => {
|
||||
// insert open & close
|
||||
let mut pair = Tendril::with_capacity(2);
|
||||
pair.push_char(open);
|
||||
pair.push_char(close);
|
||||
|
||||
offs += 2;
|
||||
|
||||
(pos, pos, Some(pair))
|
||||
let pair = Tendril::from_iter([open, close]);
|
||||
offs += open.len_utf8() + close.len_utf8();
|
||||
(start_head, start_head, 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 {
|
||||
let mut ranges = SmallVec::with_capacity(selection.len());
|
||||
let mut end_ranges = SmallVec::with_capacity(selection.len());
|
||||
|
||||
let mut offs = 0;
|
||||
|
||||
let transaction = Transaction::change_by_selection(doc, selection, |range| {
|
||||
let pos = range.head;
|
||||
let next = next_char(doc, pos);
|
||||
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
|
||||
let start_head = start_range.head;
|
||||
let next = doc.get_char(start_head);
|
||||
let end_head = start_head + offs + close.len_utf8();
|
||||
|
||||
let head = pos + offs + close.len_utf8();
|
||||
// if selection, retain anchor, if cursor, move over
|
||||
ranges.push(Range::new(
|
||||
if range.is_empty() {
|
||||
head
|
||||
} else {
|
||||
range.anchor + offs
|
||||
},
|
||||
head,
|
||||
));
|
||||
let end_anchor = if start_range.is_empty() {
|
||||
end_head
|
||||
} else {
|
||||
start_range.anchor + offs
|
||||
};
|
||||
|
||||
end_ranges.push(Range::new(end_anchor, end_head));
|
||||
|
||||
if next == Some(close) {
|
||||
// return transaction that moves past close
|
||||
(pos, pos, None) // no-op
|
||||
// return transaction that moves past close
|
||||
(start_head, start_head, None) // no-op
|
||||
} else {
|
||||
offs += close.len_utf8();
|
||||
|
||||
// TODO: else return (use default handler that inserts close)
|
||||
(pos, pos, Some(Tendril::from_char(close)))
|
||||
(start_head, start_head, 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""")
|
||||
fn handle_same(_doc: &Rope, _selection: &Selection, _token: char) -> Option<Transaction> {
|
||||
// if not cursor but selection, wrap
|
||||
// let next = next char
|
||||
/// handle cases where open and close is the same, or in triples ("""docstring""")
|
||||
fn handle_same(
|
||||
doc: &Rope,
|
||||
selection: &Selection,
|
||||
token: char,
|
||||
close_before: &str,
|
||||
open_before: &str,
|
||||
) -> Transaction {
|
||||
let mut end_ranges = SmallVec::with_capacity(selection.len());
|
||||
|
||||
// if next == bracket {
|
||||
// // if start of syntax node, insert token twice (new pair because node is complete)
|
||||
// // elseif colsedBracketAt
|
||||
// // is_triple == allow triple && next 3 is equal
|
||||
// // cursor jump over
|
||||
// }
|
||||
//} else if allow_triple && followed by triple {
|
||||
//}
|
||||
//} else if next != word char && prev != bracket && prev != word char {
|
||||
// // condition checks for cases like I' where you don't want I'' (or I'm)
|
||||
// insert pair ("")
|
||||
//}
|
||||
None
|
||||
let mut offs = 0;
|
||||
|
||||
let transaction = Transaction::change_by_selection(doc, selection, |start_range| {
|
||||
let start_head = start_range.head;
|
||||
let end_head = start_head + offs + token.len_utf8();
|
||||
|
||||
// if selection, retain anchor, if cursor, move over
|
||||
let end_anchor = if start_range.is_empty() {
|
||||
end_head
|
||||
} else {
|
||||
start_range.anchor + offs
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -409,7 +409,7 @@ pub fn changes_iter(&self) -> ChangeIterator {
|
||||
|
||||
/// Transaction represents a single undoable unit of changes. Several changes can be grouped into
|
||||
/// a single transaction.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Transaction {
|
||||
changes: ChangeSet,
|
||||
selection: Option<Selection>,
|
||||
|
@ -4199,8 +4199,9 @@ fn signature_help(cx: &mut Context, ch: char) {
|
||||
// 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 t = Tendril::from_char(ch);
|
||||
let transaction = Transaction::insert(doc, selection, t);
|
||||
let transaction = Transaction::insert(doc, &cursors, t);
|
||||
Some(transaction)
|
||||
}
|
||||
|
||||
@ -4215,11 +4216,11 @@ pub fn insert_char(cx: &mut Context, c: char) {
|
||||
};
|
||||
|
||||
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)
|
||||
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);
|
||||
break;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user