Skip to content

Commit

Permalink
find closest pairs with tree-sitter when possible
Browse files Browse the repository at this point in the history
  • Loading branch information
woojiq committed Sep 28, 2023
1 parent 13d4463 commit 7c6d268
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 35 deletions.
4 changes: 2 additions & 2 deletions helix-core/src/match_brackets.rs
Expand Up @@ -129,7 +129,7 @@ fn find_pair(

/// Returns the position of the matching bracket under cursor.
/// This function works on plain text and ignores tree-sitter grammar.
/// The search is limited to `MAX_PLAINTEXT_SCAN` characters
/// The search is limited to [`MAX_PLAINTEXT_SCAN`] characters
///
/// If the cursor is on the opening bracket, the position of
/// the closing bracket is returned. If the cursor on the closing
Expand Down Expand Up @@ -220,7 +220,7 @@ fn as_close_pair(doc: RopeSlice, node: &Node) -> Option<char> {
.find_map(|&(open, close_)| (close_ == close).then_some(open))
}

/// Checks if `node` or its siblings (at most MATCH_LIMIT nodes) is the specified closing char
/// Checks if `node` or its siblings (at most [`MATCH_LIMIT`] nodes) is the specified closing char
///
/// # Returns
///
Expand Down
82 changes: 73 additions & 9 deletions helix-core/src/surround.rs
@@ -1,6 +1,11 @@
use std::fmt::Display;

use crate::{movement::Direction, search, Range, Selection};
use crate::{
graphemes::next_grapheme_boundary,
match_brackets::{find_matching_bracket, find_matching_bracket_fuzzy},
movement::Direction,
search, Range, Selection, Syntax,
};
use ropey::RopeSlice;

pub const PAIRS: &[(char, char)] = &[
Expand Down Expand Up @@ -52,7 +57,58 @@ pub fn get_pair(ch: char) -> (char, char) {
.unwrap_or((ch, ch))
}

/// Find the position of surround pairs of any [`PAIRS`] using tree-sitter when possible.
///
/// Returns a 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),
}
}

fn find_nth_closest_pairs_ts(
syntax: &Syntax,
text: RopeSlice,
range: Range,
skip: usize,
) -> Result<(usize, usize)> {
let is_close_pair = |ch| PAIRS.iter().any(|(_, close)| *close == ch);

let cursor = range.cursor(text);
let Some(mut closing) = find_matching_bracket_fuzzy(syntax, text, cursor) else {
return Err(Error::PairNotFound);
};

for _ in 1..skip {
let next = next_grapheme_boundary(text, closing);
// If we have two closing chars in a row, `next_grapheme_boundary` will find next closing
closing = if is_close_pair(text.char(next)) {
next
} else if let Some(idx) = find_matching_bracket_fuzzy(syntax, text, next) {
idx
} else {
return Err(Error::PairNotFound);
}
}
match find_matching_bracket(syntax, text, closing) {
Some(opening) => {
if range.head < range.anchor {
Ok((closing, opening))
} else {
Ok((opening, closing))
}
}
None => Err(Error::PairNotFound),
}
}

fn find_nth_closest_pairs_plain(
text: RopeSlice,
range: Range,
mut skip: usize,
Expand Down Expand Up @@ -157,7 +213,10 @@ pub fn find_nth_pairs_pos(
)
};

Option::zip(open, close).ok_or(Error::PairNotFound)
match range.direction() {
Direction::Forward => Option::zip(open, close).ok_or(Error::PairNotFound),
Direction::Backward => Option::zip(close, open).ok_or(Error::PairNotFound),
}
}

fn find_nth_open_pair(
Expand Down Expand Up @@ -245,6 +304,7 @@ 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>,
Expand All @@ -253,9 +313,13 @@ pub fn get_surround_pos(
let mut change_pos = Vec::new();

for &range in selection {
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)?,
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())
};
if change_pos.contains(&open_pos) || change_pos.contains(&close_pos) {
return Err(Error::CursorOverlap);
Expand Down Expand Up @@ -283,7 +347,7 @@ mod test {
);

assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1).unwrap(),
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1).unwrap(),
expectations
);
}
Expand All @@ -298,7 +362,7 @@ mod test {
);

assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1),
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound)
);
}
Expand All @@ -313,7 +377,7 @@ mod test {
);

assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('('), 1),
get_surround_pos(None, doc.slice(..), &selection, Some('('), 1),
Err(Error::PairNotFound) // overlapping surround chars
);
}
Expand All @@ -328,7 +392,7 @@ mod test {
);

assert_eq!(
get_surround_pos(doc.slice(..), &selection, Some('['), 1),
get_surround_pos(None, doc.slice(..), &selection, Some('['), 1),
Err(Error::CursorOverlap)
);
}
Expand Down
15 changes: 9 additions & 6 deletions helix-core/src/textobject.rs
Expand Up @@ -7,9 +7,9 @@ use crate::chars::{categorize_char, char_is_whitespace, CharCategory};
use crate::graphemes::{next_grapheme_boundary, prev_grapheme_boundary};
use crate::line_ending::rope_is_line_ending;
use crate::movement::Direction;
use crate::surround;
use crate::syntax::LanguageConfiguration;
use crate::Range;
use crate::{surround, Syntax};

fn find_word_boundary(slice: RopeSlice, mut pos: usize, direction: Direction, long: bool) -> usize {
use CharCategory::{Eol, Whitespace};
Expand Down Expand Up @@ -199,25 +199,28 @@ pub fn textobject_paragraph(
}

pub fn textobject_pair_surround(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
ch: char,
count: usize,
) -> Range {
textobject_pair_surround_impl(slice, range, textobject, Some(ch), count)
textobject_pair_surround_impl(syntax, slice, range, textobject, Some(ch), count)
}

pub fn textobject_pair_surround_closest(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
count: usize,
) -> Range {
textobject_pair_surround_impl(slice, range, textobject, None, count)
textobject_pair_surround_impl(syntax, slice, range, textobject, None, count)
}

fn textobject_pair_surround_impl(
syntax: Option<&Syntax>,
slice: RopeSlice,
range: Range,
textobject: TextObject,
Expand All @@ -226,8 +229,7 @@ fn textobject_pair_surround_impl(
) -> Range {
let pair_pos = match ch {
Some(ch) => surround::find_nth_pairs_pos(slice, ch, range, count),
// Automatically find the closest surround pairs
None => surround::find_nth_closest_pairs_pos(slice, range, count),
None => surround::find_nth_closest_pairs_pos(syntax, slice, range, count),
};
pair_pos
.map(|(anchor, head)| match textobject {
Expand Down Expand Up @@ -574,7 +576,8 @@ mod test {
let slice = doc.slice(..);
for &case in scenario {
let (pos, objtype, expected_range, ch, count) = case;
let result = textobject_pair_surround(slice, Range::point(pos), objtype, ch, count);
let result =
textobject_pair_surround(None, slice, Range::point(pos), objtype, ch, count);
assert_eq!(
result,
expected_range.into(),
Expand Down
47 changes: 29 additions & 18 deletions helix-term/src/commands.rs
Expand Up @@ -5161,13 +5161,22 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
'T' => textobject_treesitter("test", range),
'p' => textobject::textobject_paragraph(text, range, objtype, count),
'm' => textobject::textobject_pair_surround_closest(
text, range, objtype, count,
doc.syntax(),
text,
range,
objtype,
count,
),
'g' => textobject_change(range),
// TODO: cancel new ranges if inconsistent surround matches across lines
ch if !ch.is_ascii_alphanumeric() => {
textobject::textobject_pair_surround(text, range, objtype, ch, count)
}
ch if !ch.is_ascii_alphanumeric() => textobject::textobject_pair_surround(
doc.syntax(),
text,
range,
objtype,
ch,
count,
),
_ => range,
}
});
Expand Down Expand Up @@ -5255,13 +5264,14 @@ fn surround_replace(cx: &mut Context) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);

let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) {
Ok(c) => c,
Err(err) => {
cx.editor.set_error(err.to_string());
return;
}
};
let change_pos =
match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) {
Ok(c) => c,
Err(err) => {
cx.editor.set_error(err.to_string());
return;
}
};

let selection = selection.clone();
let ranges: SmallVec<[Range; 1]> = change_pos.iter().map(|&p| Range::point(p)).collect();
Expand Down Expand Up @@ -5304,13 +5314,14 @@ fn surround_delete(cx: &mut Context) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);

let change_pos = match surround::get_surround_pos(text, selection, surround_ch, count) {
Ok(c) => c,
Err(err) => {
cx.editor.set_error(err.to_string());
return;
}
};
let change_pos =
match surround::get_surround_pos(doc.syntax(), text, selection, surround_ch, count) {
Ok(c) => c,
Err(err) => {
cx.editor.set_error(err.to_string());
return;
}
};

let transaction =
Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None)));
Expand Down
60 changes: 60 additions & 0 deletions helix-term/tests/test/commands.rs
Expand Up @@ -480,3 +480,63 @@ fn bar() {#(\n|)#\

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn surround_delete() -> anyhow::Result<()> {
// Test `surround_delete` when head < anchor
test(("(#[| ]#)", "mdm", "#[| ]#")).await?;
test(("(#[| ]#)", "md(", "#[| ]#")).await?;

Ok(())
}

#[tokio::test(flavor = "multi_thread")]
async fn surround_replace_ts() -> anyhow::Result<()> {
const INPUT: &str = r#"\
fn foo() {
if let Some(_) = None {
todo!("f#[|o]#o)");
}
}
"#;
test((
INPUT,
":lang rust<ret>mrm'",
r#"\
fn foo() {
if let Some(_) = None {
todo!('f#[|o]#o)');
}
}
"#,
))
.await?;

test((
INPUT,
":lang rust<ret>3mrm[",
r#"\
fn foo() {
if let Some(_) = None [
todo!("f#[|o]#o)");
]
}
"#,
))
.await?;

test((
INPUT,
":lang rust<ret>2mrm{",
r#"\
fn foo() {
if let Some(_) = None {
todo!{"f#[|o]#o)"};
}
}
"#,
))
.await?;

Ok(())
}
8 changes: 8 additions & 0 deletions helix-term/tests/test/movement.rs
Expand Up @@ -106,6 +106,14 @@ async fn surround_by_character() -> anyhow::Result<()> {
))
.await?;

// Selection direction is preserved
test((
"(so [many {go#[|od]#} text] here)",
"mi{",
"(so [many {#[|good]#} text] here)",
))
.await?;

Ok(())
}

Expand Down

0 comments on commit 7c6d268

Please sign in to comment.