diff --git a/helix-core/src/movement.rs b/helix-core/src/movement.rs index 2b29f36de..6c4f3f535 100644 --- a/helix-core/src/movement.rs +++ b/helix-core/src/movement.rs @@ -16,7 +16,7 @@ syntax::LanguageConfiguration, text_annotations::TextAnnotations, textobject::TextObject, - visual_offset_from_block, Range, RopeSlice, + visual_offset_from_block, Range, RopeSlice, Selection, Syntax, }; #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -556,6 +556,85 @@ pub fn goto_treesitter_object( last_range } +fn find_parent_start(mut node: Node) -> Option { + let start = node.start_byte(); + + while node.start_byte() >= start || !node.is_named() { + node = node.parent()?; + } + + Some(node) +} + +pub fn move_parent_node_end( + syntax: &Syntax, + text: RopeSlice, + selection: Selection, + dir: Direction, + movement: Movement, +) -> Selection { + let tree = syntax.tree(); + + selection.transform(|range| { + let start_from = text.char_to_byte(range.from()); + let start_to = text.char_to_byte(range.to()); + + let mut node = match tree + .root_node() + .named_descendant_for_byte_range(start_from, start_to) + { + Some(node) => node, + None => { + log::debug!( + "no descendant found for byte range: {} - {}", + start_from, + start_to + ); + return range; + } + }; + + let mut end_head = match dir { + // moving forward, we always want to move one past the end of the + // current node, so use the end byte of the current node, which is an exclusive + // end of the range + Direction::Forward => text.byte_to_char(node.end_byte()), + + // moving backward, we want the cursor to land on the start char of + // the current node, or if it is already at the start of a node, to traverse up to + // the parent + Direction::Backward => { + let end_head = text.byte_to_char(node.start_byte()); + + // if we're already on the beginning, look up to the parent + if end_head == range.cursor(text) { + node = find_parent_start(node).unwrap_or(node); + text.byte_to_char(node.start_byte()) + } else { + end_head + } + } + }; + + if movement == Movement::Move { + // preserve direction of original range + if range.direction() == Direction::Forward { + Range::new(end_head, end_head + 1) + } else { + Range::new(end_head + 1, end_head) + } + } else { + // if we end up with a forward range, then adjust it to be one past + // where we want + if end_head >= range.anchor { + end_head += 1; + } + + Range::new(range.anchor, end_head) + } + }) +} + #[cfg(test)] mod test { use ropey::Rope; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index bf60ad71a..fc01eec79 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -247,6 +247,8 @@ pub fn doc(&self) -> &str { move_prev_long_word_start, "Move to start of previous long word", move_next_long_word_end, "Move to end of next long word", move_prev_long_word_end, "Move to end of previous long word", + move_parent_node_end, "Move to end of the parent node", + move_parent_node_start, "Move to beginning of the parent node", extend_next_word_start, "Extend to start of next word", extend_prev_word_start, "Extend to start of previous word", extend_next_word_end, "Extend to end of next word", @@ -255,6 +257,8 @@ pub fn doc(&self) -> &str { extend_prev_long_word_start, "Extend to start of previous long word", extend_next_long_word_end, "Extend to end of next long word", extend_prev_long_word_end, "Extend to end of prev long word", + extend_parent_node_end, "Extend to end of the parent node", + extend_parent_node_start, "Extend to beginning of the parent node", find_till_char, "Move till next occurrence of char", find_next_char, "Move to next occurrence of char", extend_till_char, "Extend till next occurrence of char", @@ -4605,6 +4609,46 @@ fn select_prev_sibling(cx: &mut Context) { select_sibling_impl(cx, &|node| Node::prev_sibling(&node)) } +fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) { + let motion = move |editor: &mut Editor| { + let (view, doc) = current!(editor); + + if let Some(syntax) = doc.syntax() { + let text = doc.text().slice(..); + let current_selection = doc.selection(view.id); + + let selection = movement::move_parent_node_end( + syntax, + text, + current_selection.clone(), + dir, + movement, + ); + + doc.set_selection(view.id, selection); + } + }; + + motion(cx.editor); + cx.editor.last_motion = Some(Motion(Box::new(motion))); +} + +pub fn move_parent_node_end(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Forward, Movement::Move) +} + +pub fn move_parent_node_start(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Backward, Movement::Move) +} + +pub fn extend_parent_node_end(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Forward, Movement::Extend) +} + +pub fn extend_parent_node_start(cx: &mut Context) { + move_node_bound_impl(cx, Direction::Backward, Movement::Extend) +} + fn match_brackets(cx: &mut Context) { let (view, doc) = current!(cx.editor); let is_select = cx.editor.mode == Mode::Select; diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index 379833525..419e376ff 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -88,6 +88,8 @@ pub fn default() -> HashMap { "A-i" | "A-down" => shrink_selection, "A-p" | "A-left" => select_prev_sibling, "A-n" | "A-right" => select_next_sibling, + "A-e" => move_parent_node_end, + "A-b" => move_parent_node_start, "%" => select_all, "x" => extend_line_below, @@ -336,6 +338,9 @@ pub fn default() -> HashMap { "B" => extend_prev_long_word_start, "E" => extend_next_long_word_end, + "A-e" => extend_parent_node_end, + "A-b" => extend_parent_node_start, + "n" => extend_search_next, "N" => extend_search_prev, diff --git a/helix-term/tests/test/commands.rs b/helix-term/tests/test/commands.rs index b13c37bcd..b3e135510 100644 --- a/helix-term/tests/test/commands.rs +++ b/helix-term/tests/test/commands.rs @@ -2,6 +2,7 @@ use super::*; +mod movement; mod write; #[tokio::test(flavor = "multi_thread")] diff --git a/helix-term/tests/test/commands/movement.rs b/helix-term/tests/test/commands/movement.rs new file mode 100644 index 000000000..03dc7ba9c --- /dev/null +++ b/helix-term/tests/test/commands/movement.rs @@ -0,0 +1,199 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_parent_node_end() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + "no#["|]# + } + } + "##}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\" + }#[\n|]# + } + "}), + ), + // select mode extends + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[\"no\" + }\n|]# + } + "}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_move_parent_node_start() -> anyhow::Result<()> { + let tests = vec![ + // single cursor stays single cursor, first goes to end of current + // node, then parent + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + "no#["|]# + } + } + "##}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + #[\"|]#no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else { + \"no\"#[\n|]# + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[{|]# + \"no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[{|]# + \"no\" + } + } + "}), + "", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } #[e|]#lse { + \"no\" + } + } + "}), + ), + // select mode extends + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } else #[|{ + ]#\"no\" + } + } + "}), + ), + ( + helpers::platform_line(indoc! {r##" + fn foo() { + let result = if true { + "yes" + } else { + #["no"|]# + } + } + "##}), + "v", + helpers::platform_line(indoc! {"\ + fn foo() { + let result = if true { + \"yes\" + } #[|else { + ]#\"no\" + } + } + "}), + ), + ]; + + for test in tests { + test_with_config(AppBuilder::new().with_file("foo.rs", None), test).await?; + } + + Ok(()) +}