Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-core/src/surround.rs')
| -rw-r--r-- | helix-core/src/surround.rs | 392 |
1 files changed, 153 insertions, 239 deletions
diff --git a/helix-core/src/surround.rs b/helix-core/src/surround.rs index e45346c9..a3de3cd1 100644 --- a/helix-core/src/surround.rs +++ b/helix-core/src/surround.rs @@ -1,16 +1,18 @@ use std::fmt::Display; -use crate::{ - graphemes::next_grapheme_boundary, - match_brackets::{ - find_matching_bracket, find_matching_bracket_fuzzy, get_pair, is_close_bracket, - is_open_bracket, - }, - movement::Direction, - search, Range, Selection, Syntax, -}; +use crate::{search, Range, Selection}; use ropey::RopeSlice; +pub const PAIRS: &[(char, char)] = &[ + ('(', ')'), + ('[', ']'), + ('{', '}'), + ('<', '>'), + ('«', '»'), + ('「', '」'), + ('(', ')'), +]; + #[derive(Debug, PartialEq, Eq)] pub enum Error { PairNotFound, @@ -32,122 +34,57 @@ impl Display for Error { type Result<T> = std::result::Result<T, Error>; -/// Finds the position of surround pairs of any [`crate::match_brackets::PAIRS`] -/// using tree-sitter when possible. +/// Given any char in [PAIRS], return the open and closing chars. If not found in +/// [PAIRS] return (ch, ch). /// -/// # Returns +/// ``` +/// use helix_core::surround::get_pair; /// -/// Tuple `(anchor, head)`, meaning it is not always ordered. -pub fn find_nth_closest_pairs_pos( - syntax: Option<&Syntax>, - text: RopeSlice, - range: Range, - skip: usize, -) -> Result<(usize, usize)> { - match syntax { - Some(syntax) => find_nth_closest_pairs_ts(syntax, text, range, skip), - None => find_nth_closest_pairs_plain(text, range, skip), - } +/// assert_eq!(get_pair('['), ('[', ']')); +/// assert_eq!(get_pair('}'), ('{', '}')); +/// assert_eq!(get_pair('"'), ('"', '"')); +/// ``` +pub fn get_pair(ch: char) -> (char, char) { + PAIRS + .iter() + .find(|(open, close)| *open == ch || *close == ch) + .copied() + .unwrap_or((ch, ch)) } -fn find_nth_closest_pairs_ts( - syntax: &Syntax, +pub fn find_nth_closest_pairs_pos( text: RopeSlice, range: Range, - mut skip: usize, + n: usize, ) -> Result<(usize, usize)> { - let mut opening = range.from(); - // We want to expand the selection if we are already on the found pair, - // otherwise we would need to subtract "-1" from "range.to()". - let mut closing = range.to(); - - while skip > 0 { - closing = find_matching_bracket_fuzzy(syntax, text, closing).ok_or(Error::PairNotFound)?; - opening = find_matching_bracket(syntax, text, closing).ok_or(Error::PairNotFound)?; - // If we're already on a closing bracket "find_matching_bracket_fuzzy" will return - // the position of the opening bracket. - if closing < opening { - (opening, closing) = (closing, opening); - } - - // In case found brackets are partially inside current selection. - if range.from() < opening || closing < range.to() - 1 { - closing = next_grapheme_boundary(text, closing); - } else { - skip -= 1; - if skip != 0 { - closing = next_grapheme_boundary(text, closing); - } - } - } + let is_open_pair = |ch| PAIRS.iter().any(|(open, _)| *open == ch); + let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch); - // Keep the original direction. - if let Direction::Forward = range.direction() { - Ok((opening, closing)) - } else { - Ok((closing, opening)) - } -} - -fn find_nth_closest_pairs_plain( - text: RopeSlice, - range: Range, - mut skip: usize, -) -> Result<(usize, usize)> { let mut stack = Vec::with_capacity(2); - let pos = range.from(); - let mut close_pos = pos.saturating_sub(1); + let pos = range.cursor(text); for ch in text.chars_at(pos) { - close_pos += 1; - - if is_open_bracket(ch) { + if is_open_pair(ch) { // Track open pairs encountered so that we can step over // the corresponding close pairs that will come up further // down the loop. We want to find a lone close pair whose // open pair is before the cursor position. stack.push(ch); continue; - } - - if !is_close_bracket(ch) { - // We don't care if this character isn't a brace pair item, - // so short circuit here. - continue; - } - - let (open, close) = get_pair(ch); - - if stack.last() == Some(&open) { - // If we are encountering the closing pair for an opener - // we just found while traversing, then its inside the - // selection and should be skipped over. - stack.pop(); - continue; - } - - match find_nth_open_pair(text, open, close, close_pos, 1) { - // Before we accept this pair, we want to ensure that the - // pair encloses the range rather than just the cursor. - Some(open_pos) - if open_pos <= pos.saturating_add(1) - && close_pos >= range.to().saturating_sub(1) => - { - // Since we have special conditions for when to - // accept, we can't just pass the skip parameter on - // through to the find_nth_*_pair methods, so we - // track skips manually here. - if skip > 1 { - skip -= 1; - continue; - } - - return match range.direction() { - Direction::Forward => Ok((open_pos, close_pos)), - Direction::Backward => Ok((close_pos, open_pos)), - }; + } else if is_close_pair(ch) { + let (open, _) = get_pair(ch); + if stack.last() == Some(&open) { + stack.pop(); + continue; + } else { + // In the ideal case the stack would be empty here and the + // current character would be the close pair that we are + // looking for. It could also be the case that the pairs + // are unbalanced and we encounter a close pair that doesn't + // close the last seen open pair. In either case use this + // char as the auto-detected closest pair. + return find_nth_pairs_pos(text, ch, range, n); } - _ => continue, } } @@ -191,11 +128,7 @@ pub fn find_nth_pairs_pos( ) }; - // preserve original direction - match range.direction() { - Direction::Forward => Option::zip(open, close).ok_or(Error::PairNotFound), - Direction::Backward => Option::zip(close, open).ok_or(Error::PairNotFound), - } + Option::zip(open, close).ok_or(Error::PairNotFound) } fn find_nth_open_pair( @@ -205,10 +138,6 @@ fn find_nth_open_pair( mut pos: usize, n: usize, ) -> Option<usize> { - if pos >= text.len_chars() { - return None; - } - let mut chars = text.chars_at(pos + 1); // Adjusts pos for the first iteration, and handles the case of the @@ -287,7 +216,6 @@ fn find_nth_close_pair( /// are automatically detected around each cursor (note that this may result /// in them selecting different surround characters for each selection). pub fn get_surround_pos( - syntax: Option<&Syntax>, text: RopeSlice, selection: &Selection, ch: Option<char>, @@ -296,19 +224,14 @@ pub fn get_surround_pos( let mut change_pos = Vec::new(); for &range in selection { - let (open_pos, close_pos) = { - let range_raw = match ch { - Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?, - None => find_nth_closest_pairs_pos(syntax, text, range, skip)?, - }; - let range = Range::new(range_raw.0, range_raw.1); - (range.from(), range.to()) + let (open_pos, close_pos) = match ch { + Some(ch) => find_nth_pairs_pos(text, ch, range, skip)?, + None => find_nth_closest_pairs_pos(text, range, skip)?, }; if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) { return Err(Error::CursorOverlap); } - // ensure the positions are always paired in the forward direction - change_pos.extend_from_slice(&[open_pos.min(close_pos), close_pos.max(open_pos)]); + change_pos.extend_from_slice(&[open_pos, close_pos]); } Ok(change_pos) } @@ -321,150 +244,141 @@ mod test { use ropey::Rope; use smallvec::SmallVec; - #[test] - fn test_get_surround_pos() { - #[rustfmt::skip] - let (doc, selection, expectations) = - rope_with_selections_and_expectations( - "(some) (chars)\n(newline)", - "_ ^ _ _ ^ _\n_ ^ _" + #[allow(clippy::type_complexity)] + fn check_find_nth_pair_pos( + text: &str, + cases: Vec<(usize, char, usize, Result<(usize, usize)>)>, + ) { + let doc = Rope::from(text); + let slice = doc.slice(..); + + for (cursor_pos, ch, n, expected_range) in cases { + let range = find_nth_pairs_pos(slice, ch, (cursor_pos, cursor_pos + 1).into(), n); + assert_eq!( + range, expected_range, + "Expected {:?}, got {:?}", + expected_range, range ); - - assert_eq!( - get_surround_pos(None, doc.slice(..), &selection, Some('('), 1).unwrap(), - expectations - ); + } } #[test] - fn test_get_surround_pos_bail_different_surround_chars() { - #[rustfmt::skip] - let (doc, selection, _) = - rope_with_selections_and_expectations( - "[some]\n(chars)xx\n(newline)", - " ^ \n ^ \n " - ); - - assert_eq!( - get_surround_pos(None, doc.slice(..), &selection, Some('('), 1), - Err(Error::PairNotFound) + fn test_find_nth_pairs_pos() { + check_find_nth_pair_pos( + "some (text) here", + vec![ + // cursor on [t]ext + (6, '(', 1, Ok((5, 10))), + (6, ')', 1, Ok((5, 10))), + // cursor on so[m]e + (2, '(', 1, Err(Error::PairNotFound)), + // cursor on bracket itself + (5, '(', 1, Ok((5, 10))), + (10, '(', 1, Ok((5, 10))), + ], ); } #[test] - fn test_get_surround_pos_bail_overlapping_surround_chars() { - #[rustfmt::skip] - let (doc, selection, _) = - rope_with_selections_and_expectations( - "[some]\n(chars)xx\n(newline)", - " \n ^ \n ^ " - ); - - assert_eq!( - get_surround_pos(None, doc.slice(..), &selection, Some('('), 1), - Err(Error::PairNotFound) // overlapping surround chars + fn test_find_nth_pairs_pos_skip() { + check_find_nth_pair_pos( + "(so (many (good) text) here)", + vec![ + // cursor on go[o]d + (13, '(', 1, Ok((10, 15))), + (13, '(', 2, Ok((4, 21))), + (13, '(', 3, Ok((0, 27))), + ], ); } #[test] - fn test_get_surround_pos_bail_cursor_overlap() { - #[rustfmt::skip] - let (doc, selection, _) = - rope_with_selections_and_expectations( - "[some]\n(chars)xx\n(newline)", - " ^^ \n \n " - ); - - assert_eq!( - get_surround_pos(None, doc.slice(..), &selection, Some('['), 1), - Err(Error::CursorOverlap) - ); + fn test_find_nth_pairs_pos_same() { + check_find_nth_pair_pos( + "'so 'many 'good' text' here'", + vec![ + // cursor on go[o]d + (13, '\'', 1, Ok((10, 15))), + (13, '\'', 2, Ok((4, 21))), + (13, '\'', 3, Ok((0, 27))), + // cursor on the quotes + (10, '\'', 1, Err(Error::CursorOnAmbiguousPair)), + ], + ) } #[test] - fn test_find_nth_pairs_pos_quote_success() { - #[rustfmt::skip] - let (doc, selection, expectations) = - rope_with_selections_and_expectations( - "some 'quoted text' on this 'line'\n'and this one'", - " _ ^ _ \n " - ); - - assert_eq!(2, expectations.len()); - assert_eq!( - find_nth_pairs_pos(doc.slice(..), '\'', selection.primary(), 1) - .expect("find should succeed"), - (expectations[0], expectations[1]) + fn test_find_nth_pairs_pos_step() { + check_find_nth_pair_pos( + "((so)((many) good (text))(here))", + vec![ + // cursor on go[o]d + (15, '(', 1, Ok((5, 24))), + (15, '(', 2, Ok((0, 31))), + ], ) } #[test] - fn test_find_nth_pairs_pos_nested_quote_success() { - #[rustfmt::skip] - let (doc, selection, expectations) = - rope_with_selections_and_expectations( - "some 'nested 'quoted' text' on this 'line'\n'and this one'", - " _ ^ _ \n " - ); - - assert_eq!(2, expectations.len()); - assert_eq!( - find_nth_pairs_pos(doc.slice(..), '\'', selection.primary(), 2) - .expect("find should succeed"), - (expectations[0], expectations[1]) + fn test_find_nth_pairs_pos_mixed() { + check_find_nth_pair_pos( + "(so [many {good} text] here)", + vec![ + // cursor on go[o]d + (13, '{', 1, Ok((10, 15))), + (13, '[', 1, Ok((4, 21))), + (13, '(', 1, Ok((0, 27))), + ], ) } #[test] - fn test_find_nth_pairs_pos_inside_quote_ambiguous() { - #[rustfmt::skip] - let (doc, selection, _) = - rope_with_selections_and_expectations( - "some 'nested 'quoted' text' on this 'line'\n'and this one'", - " ^ \n " - ); + fn test_get_surround_pos() { + let doc = Rope::from("(some) (chars)\n(newline)"); + let slice = doc.slice(..); + let selection = Selection::new( + SmallVec::from_slice(&[Range::point(2), Range::point(9), Range::point(20)]), + 0, + ); + // cursor on s[o]me, c[h]ars, newl[i]ne assert_eq!( - find_nth_pairs_pos(doc.slice(..), '\'', selection.primary(), 1), - Err(Error::CursorOnAmbiguousPair) - ) + get_surround_pos(slice, &selection, Some('('), 1) + .unwrap() + .as_slice(), + &[0, 5, 7, 13, 15, 23] + ); } #[test] - fn test_find_nth_closest_pairs_pos_index_range_panic() { - #[rustfmt::skip] - let (doc, selection, _) = - rope_with_selections_and_expectations( - "(a)c)", - "^^^^^" - ); + fn test_get_surround_pos_bail() { + let doc = Rope::from("[some]\n(chars)xx\n(newline)"); + let slice = doc.slice(..); + let selection = + Selection::new(SmallVec::from_slice(&[Range::point(2), Range::point(9)]), 0); + // cursor on s[o]me, c[h]ars assert_eq!( - find_nth_closest_pairs_pos(None, doc.slice(..), selection.primary(), 1), - Err(Error::PairNotFound) - ) - } - - // Create a Rope and a matching Selection using a specification language. - // ^ is a single-point selection. - // _ is an expected index. These are returned as a Vec<usize> for use in assertions. - fn rope_with_selections_and_expectations( - text: &str, - spec: &str, - ) -> (Rope, Selection, Vec<usize>) { - if text.len() != spec.len() { - panic!("specification must match text length -- are newlines aligned?"); - } - - let rope = Rope::from(text); - - let selections: SmallVec<[Range; 1]> = spec - .match_indices('^') - .map(|(i, _)| Range::point(i)) - .collect(); + get_surround_pos(slice, &selection, Some('('), 1), + Err(Error::PairNotFound) // different surround chars + ); - let expectations: Vec<usize> = spec.match_indices('_').map(|(i, _)| i).collect(); + let selection = Selection::new( + SmallVec::from_slice(&[Range::point(14), Range::point(24)]), + 0, + ); + // cursor on [x]x, newli[n]e + assert_eq!( + get_surround_pos(slice, &selection, Some('('), 1), + Err(Error::PairNotFound) // overlapping surround chars + ); - (rope, Selection::new(selections, 0), expectations) + let selection = + Selection::new(SmallVec::from_slice(&[Range::point(2), Range::point(3)]), 0); + // cursor on s[o][m]e + assert_eq!( + get_surround_pos(slice, &selection, Some('['), 1), + Err(Error::CursorOverlap) + ); } } |