From b8fd09e7688a9790d203babfba8f6a6d6e9b6516 Mon Sep 17 00:00:00 2001 From: Vamsi Raman Deeduvanu <112223319+vamsi10010@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:01:00 -0500 Subject: [PATCH] Add actions to move to start and end of larger syntax node (#45331) Release Notes: - Added two actions `move_to_start_of_larger_syntax_node` and `move_to_end_of_larger_syntax_node` that move cursors to the start or end of the parent tree-sitter node Following up on my PR #41321, this PR only adds the actions that are used to enable code navigation across syntax nodes, without binding them to any keys (such as tab) by default. Both actions use the tree-sitter syntax tree to find parent nodes of the nodes the cursors are currently in. `move_to_start_of_larger_syntax_node` will then move each cursor to the first position of the parent nodes while `move_to_end_of_larger_syntax_node` to a position right after the parent nodes. Related issues and discussions: #22349, #14803, #42828, #13736. This PR doesn't achieve "tab out" functionality in the exact sense as is requested in these issues as it does not bind the actions to the tab key. I hope this PR can start some discussion on what the best way forward for these issues is. In the meantime, users can configure keys to use these actions as they see fit to emulate "tab out" behavior. For example, ``` "context": "Editor && vim_mode == insert && !in_snippet && !showing_completions", "bindings": { "tab": "editor::MoveToEndOfLargerSyntaxNode", "shift-tab": "editor::Tab" } ``` This will enable tab to skip past code structures like brackets when the cursor is not in a snippet or the autocomplete menu is not open. At the same time, shift tab will act as a backup tab. --- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 113 +++++++ crates/editor/src/editor_tests.rs | 472 ++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 2 + 4 files changed, 591 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ddac2b32499fd2c3431aeb3388ac0ae7b8daa956..d17c12ad729a4c07e2ca345870f0f93b3e008617 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -638,6 +638,10 @@ actions!( MoveToEndOfExcerpt, /// Moves cursor to the end of the previous excerpt. MoveToEndOfPreviousExcerpt, + /// Moves cursor to the start of the next larger syntax node. + MoveToStartOfLargerSyntaxNode, + /// Moves cursor to the end of the next larger syntax node. + MoveToEndOfLargerSyntaxNode, /// Moves cursor up. MoveUp, /// Inserts a new line and moves cursor to it. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d3c2b91c7f65a38c02dfcb9eee322aa2a8ff9a65..6ec753791daf8f7d440d71a9452a034118618c11 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16375,6 +16375,119 @@ impl Editor { } } + pub fn move_to_start_of_larger_syntax_node( + &mut self, + _: &MoveToStartOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.move_cursors_to_syntax_nodes(window, cx, false); + } + + pub fn move_to_end_of_larger_syntax_node( + &mut self, + _: &MoveToEndOfLargerSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + self.move_cursors_to_syntax_nodes(window, cx, true); + } + + fn move_cursors_to_syntax_nodes( + &mut self, + window: &mut Window, + cx: &mut Context, + move_to_end: bool, + ) -> bool { + let old_selections: Box<[_]> = self + .selections + .all::(&self.display_snapshot(cx)) + .into(); + if old_selections.is_empty() { + return false; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = self.buffer.read(cx).snapshot(cx); + + let mut any_cursor_moved = false; + let new_selections = old_selections + .iter() + .map(|selection| { + if !selection.is_empty() { + return selection.clone(); + } + + let selection_pos = selection.head(); + let old_range = selection_pos..selection_pos; + + let mut new_pos = selection_pos; + let mut search_range = old_range; + while let Some((node, range)) = buffer.syntax_ancestor(search_range.clone()) { + search_range = range.clone(); + if !node.is_named() + || display_map.intersects_fold(range.start) + || display_map.intersects_fold(range.end) + // If cursor is already at the end of the syntax node, continue searching + || (move_to_end && range.end == selection_pos) + // If cursor is already at the start of the syntax node, continue searching + || (!move_to_end && range.start == selection_pos) + { + continue; + } + + // If we found a string_content node, find the largest parent that is still string_content + // Enables us to skip to the end of strings without taking multiple steps inside the string + let (_, final_range) = if node.kind() == "string_content" { + let mut current_node = node; + let mut current_range = range; + while let Some((parent, parent_range)) = + buffer.syntax_ancestor(current_range.clone()) + { + if parent.kind() == "string_content" { + current_node = parent; + current_range = parent_range; + } else { + break; + } + } + + (current_node, current_range) + } else { + (node, range) + }; + + new_pos = if move_to_end { + final_range.end + } else { + final_range.start + }; + + break; + } + + any_cursor_moved |= new_pos != selection_pos; + + Selection { + id: selection.id, + start: new_pos, + end: new_pos, + goal: SelectionGoal::None, + reversed: false, + } + }) + .collect::>(); + + self.change_selections(Default::default(), window, cx, |s| { + s.select(new_selections); + }); + self.request_autoscroll(Autoscroll::newest(), cx); + + any_cursor_moved + } + fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { if !EditorSettings::get_global(cx).gutter.runnables { self.clear_tasks(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index fe0169cabf0ebdc1246c23c0e2cff2b79feb9bf1..fe761d29c1a2683bf666f44ddd18c3535d4e875e 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -30399,3 +30399,475 @@ async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext) ); }); } + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_single_cursor(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Move to end of syntax nodes + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 16)..DisplayPoint::new(DisplayRow(1), 16) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(ˇ1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1ˇ, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2)ˇ; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2);ˇ + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + }ˇ + "#}, + cx, + ); + }); + + // Test case 2: Move to start of syntax nodes + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2ˇ); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = fooˇ(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = ˇfoo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + ˇlet x = foo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() ˇ{ + let x = foo(1, 2); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + ˇfn main() { + let x = foo(1, 2); + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_two_cursors(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + let y = bar(3, 4); + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: Move to end of syntax nodes with two cursors + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20), + DisplayPoint::new(DisplayRow(2), 20)..DisplayPoint::new(DisplayRow(2), 20), + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2ˇ); + let y = bar(3, 4ˇ); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2)ˇ; + let y = bar(3, 4)ˇ; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2);ˇ + let y = bar(3, 4);ˇ + } + "#}, + cx, + ); + }); + + // Test case 2: Move to start of syntax nodes with two cursors + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 19)..DisplayPoint::new(DisplayRow(1), 19), + DisplayPoint::new(DisplayRow(2), 19)..DisplayPoint::new(DisplayRow(2), 19), + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, ˇ2); + let y = bar(3, ˇ4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = fooˇ(1, 2); + let y = barˇ(3, 4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = ˇfoo(1, 2); + let y = ˇbar(3, 4); + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + ˇlet x = foo(1, 2); + ˇlet y = bar(3, 4); + } + "#}, + cx, + ); + }); +} + +#[gpui::test] +async fn test_move_to_start_end_of_larger_syntax_node_with_selections_and_strings( + cx: &mut TestAppContext, +) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + let text = r#" + fn main() { + let x = foo(1, 2); + let msg = "hello world"; + } + "# + .unindent(); + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + // Test case 1: With existing selection, move_to_end keeps selection + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 12)..DisplayPoint::new(DisplayRow(1), 21) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = «foo(1, 2)ˇ»; + let msg = "hello world"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = «foo(1, 2)ˇ»; + let msg = "hello world"; + } + "#}, + cx, + ); + }); + + // Test case 2: Move to end within a string + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 15)..DisplayPoint::new(DisplayRow(2), 15) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "ˇhello world"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "hello worldˇ"; + } + "#}, + cx, + ); + }); + + // Test case 3: Move to start within a string + editor.update_in(cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(2), 21)..DisplayPoint::new(DisplayRow(2), 21) + ]); + }); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "hello ˇworld"; + } + "#}, + cx, + ); + }); + editor.update_in(cx, |editor, window, cx| { + editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx); + }); + editor.update(cx, |editor, cx| { + assert_text_with_selections( + editor, + indoc! {r#" + fn main() { + let x = foo(1, 2); + let msg = "ˇhello world"; + } + "#}, + cx, + ); + }); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index b865370437caf2e873a056419ba5734c3ca11332..c6c9b6a30045b7c7acb667c1386b7f93dd776edb 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -382,6 +382,8 @@ impl EditorElement { register_action(editor, window, Editor::select_next_syntax_node); register_action(editor, window, Editor::select_prev_syntax_node); register_action(editor, window, Editor::unwrap_syntax_node); + register_action(editor, window, Editor::move_to_start_of_larger_syntax_node); + register_action(editor, window, Editor::move_to_end_of_larger_syntax_node); register_action(editor, window, Editor::select_enclosing_symbol); register_action(editor, window, Editor::move_to_enclosing_bracket); register_action(editor, window, Editor::undo_selection);