From ae54a4e1b8e50e5f96f58702c8347e56dcd82fb5 Mon Sep 17 00:00:00 2001 From: Ivan Danov Date: Tue, 9 Sep 2025 00:11:53 +0100 Subject: [PATCH] Add commands to select next/previous siblings in the syntax tree (#35053) Closes #5133 and discussion https://github.com/zed-industries/zed/discussions/33493 This PR adds two new commands to select next/previous siblings in the syntax tree. These commands were modelled after the existing ones about expand/shrink selection. With this PR I've added new key bindings inspired by `helix` for previous / next / expand / shrink selections. https://github.com/user-attachments/assets/4ef7fadb-0b82-4897-95c7-1737827bf4ac Release Notes: - Add commands to select next/previous siblings in the syntax tree --------- Co-authored-by: Joseph T. Lyons --- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 98 +++++++++++++ crates/editor/src/editor_tests.rs | 95 +++++++++++++ crates/editor/src/element.rs | 2 + crates/language/src/buffer.rs | 180 ++++++++++++++++++++---- crates/multi_buffer/src/multi_buffer.rs | 22 +++ crates/vim/src/visual.rs | 61 ++++++++ crates/zed/src/zed/app_menus.rs | 5 + 8 files changed, 440 insertions(+), 27 deletions(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 5b92f138c7..df467c4d5d 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -640,6 +640,10 @@ actions!( SelectEnclosingSymbol, /// Selects the next larger syntax node. SelectLargerSyntaxNode, + /// Selects the next syntax node sibling. + SelectNextSyntaxNode, + /// Selects the previous syntax node sibling. + SelectPreviousSyntaxNode, /// Extends selection left. SelectLeft, /// Selects the current line. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4580cd8a34..b54c2c11b1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -15138,6 +15138,104 @@ impl Editor { }); } + pub fn select_next_syntax_node( + &mut self, + _: &SelectNextSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections: Box<[_]> = self.selections.all::(cx).into(); + if old_selections.is_empty() { + return; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; + + if let Some(node) = buffer.syntax_next_sibling(old_range) { + let new_range = node.byte_range(); + selected_sibling = true; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + selection.clone() + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( + SelectionEffects::scroll(Autoscroll::fit()), + window, + cx, + |s| { + s.select(new_selections); + }, + ); + } + } + + pub fn select_prev_syntax_node( + &mut self, + _: &SelectPreviousSyntaxNode, + window: &mut Window, + cx: &mut Context, + ) { + let old_selections: Box<[_]> = self.selections.all::(cx).into(); + if old_selections.is_empty() { + return; + } + + self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx); + + let buffer = self.buffer.read(cx).snapshot(cx); + let mut selected_sibling = false; + + let new_selections = old_selections + .iter() + .map(|selection| { + let old_range = selection.start..selection.end; + + if let Some(node) = buffer.syntax_prev_sibling(old_range) { + let new_range = node.byte_range(); + selected_sibling = true; + Selection { + id: selection.id, + start: new_range.start, + end: new_range.end, + goal: SelectionGoal::None, + reversed: selection.reversed, + } + } else { + selection.clone() + } + }) + .collect::>(); + + if selected_sibling { + self.change_selections( + SelectionEffects::scroll(Autoscroll::fit()), + window, + cx, + |s| { + s.select(new_selections); + }, + ); + } + } + 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 5be6f685d1..9f578b8b9d 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -25330,6 +25330,101 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_select_next_prev_syntax_node(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::LANGUAGE.into()), + )); + + // Test hierarchical sibling navigation + let text = r#" + fn outer() { + if condition { + let a = 1; + } + let b = 2; + } + + fn another() { + let c = 3; + } + "#; + + 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)); + + // Wait for parsing to complete + editor + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .await; + + editor.update_in(cx, |editor, window, cx| { + // Start by selecting "let a = 1;" inside the if block + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(3), 16)..DisplayPoint::new(DisplayRow(3), 26) + ]); + }); + + let initial_selection = editor.selections.display_ranges(cx); + assert_eq!(initial_selection.len(), 1, "Should have one selection"); + + // Test select next sibling - should move up levels to find the next sibling + // Since "let a = 1;" has no siblings in the if block, it should move up + // to find "let b = 2;" which is a sibling of the if block + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let next_selection = editor.selections.display_ranges(cx); + + // Should have a selection and it should be different from the initial + assert_eq!( + next_selection.len(), + 1, + "Should have one selection after next" + ); + assert_ne!( + next_selection[0], initial_selection[0], + "Next sibling selection should be different" + ); + + // Test hierarchical navigation by going to the end of the current function + // and trying to navigate to the next function + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(5), 12)..DisplayPoint::new(DisplayRow(5), 22) + ]); + }); + + editor.select_next_syntax_node(&SelectNextSyntaxNode, window, cx); + let function_next_selection = editor.selections.display_ranges(cx); + + // Should move to the next function + assert_eq!( + function_next_selection.len(), + 1, + "Should have one selection after function next" + ); + + // Test select previous sibling navigation + editor.select_prev_syntax_node(&SelectPreviousSyntaxNode, window, cx); + let prev_selection = editor.selections.display_ranges(cx); + + // Should have a selection and it should be different + assert_eq!( + prev_selection.len(), + 1, + "Should have one selection after prev" + ); + assert_ne!( + prev_selection[0], function_next_selection[0], + "Previous sibling selection should be different from next" + ); + }); +} + #[track_caller] fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec { editor diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9822ec23d5..d8260dd239 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -365,6 +365,8 @@ impl EditorElement { register_action(editor, window, Editor::toggle_comments); register_action(editor, window, Editor::select_larger_syntax_node); register_action(editor, window, Editor::select_smaller_syntax_node); + 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::select_enclosing_symbol); register_action(editor, window, Editor::move_to_enclosing_bracket); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2a303bb9a0..496f9d60d1 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -3459,46 +3459,66 @@ impl BufferSnapshot { } /// Returns the closest syntax node enclosing the given range. + /// Positions a tree cursor at the leaf node that contains or touches the given range. + /// This is shared logic used by syntax navigation methods. + fn position_cursor_at_range(cursor: &mut tree_sitter::TreeCursor, range: &Range) { + // Descend to the first leaf that touches the start of the range. + // + // If the range is non-empty and the current node ends exactly at the start, + // move to the next sibling to find a node that extends beyond the start. + // + // If the range is empty and the current node starts after the range position, + // move to the previous sibling to find the node that contains the position. + while cursor.goto_first_child_for_byte(range.start).is_some() { + if !range.is_empty() && cursor.node().end_byte() == range.start { + cursor.goto_next_sibling(); + } + if range.is_empty() && cursor.node().start_byte() > range.start { + cursor.goto_previous_sibling(); + } + } + } + + /// Moves the cursor to find a node that contains the given range. + /// Returns true if such a node is found, false otherwise. + /// This is shared logic used by syntax navigation methods. + fn find_containing_node( + cursor: &mut tree_sitter::TreeCursor, + range: &Range, + strict: bool, + ) -> bool { + loop { + let node_range = cursor.node().byte_range(); + + if node_range.start <= range.start + && node_range.end >= range.end + && (!strict || node_range.len() > range.len()) + { + return true; + } + if !cursor.goto_parent() { + return false; + } + } + } + pub fn syntax_ancestor<'a, T: ToOffset>( &'a self, range: Range, ) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut result: Option> = None; - 'outer: for layer in self + for layer in self .syntax .layers_for_range(range.clone(), &self.text, true) { let mut cursor = layer.node().walk(); - // Descend to the first leaf that touches the start of the range. - // - // If the range is non-empty and the current node ends exactly at the start, - // move to the next sibling to find a node that extends beyond the start. - // - // If the range is empty and the current node starts after the range position, - // move to the previous sibling to find the node that contains the position. - while cursor.goto_first_child_for_byte(range.start).is_some() { - if !range.is_empty() && cursor.node().end_byte() == range.start { - cursor.goto_next_sibling(); - } - if range.is_empty() && cursor.node().start_byte() > range.start { - cursor.goto_previous_sibling(); - } - } + Self::position_cursor_at_range(&mut cursor, &range); // Ascend to the smallest ancestor that strictly contains the range. - loop { - let node_range = cursor.node().byte_range(); - if node_range.start <= range.start - && node_range.end >= range.end - && node_range.len() > range.len() - { - break; - } - if !cursor.goto_parent() { - continue 'outer; - } + if !Self::find_containing_node(&mut cursor, &range, true) { + continue; } let left_node = cursor.node(); @@ -3541,6 +3561,112 @@ impl BufferSnapshot { result } + /// Find the previous sibling syntax node at the given range. + /// + /// This function locates the syntax node that precedes the node containing + /// the given range. It searches hierarchically by: + /// 1. Finding the node that contains the given range + /// 2. Looking for the previous sibling at the same tree level + /// 3. If no sibling is found, moving up to parent levels and searching for siblings + /// + /// Returns `None` if there is no previous sibling at any ancestor level. + pub fn syntax_prev_sibling<'a, T: ToOffset>( + &'a self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut result: Option> = None; + + for layer in self + .syntax + .layers_for_range(range.clone(), &self.text, true) + { + let mut cursor = layer.node().walk(); + + Self::position_cursor_at_range(&mut cursor, &range); + + // Find the node that contains the range + if !Self::find_containing_node(&mut cursor, &range, false) { + continue; + } + + // Look for the previous sibling, moving up ancestor levels if needed + loop { + if cursor.goto_previous_sibling() { + let layer_result = cursor.node(); + + if let Some(previous_result) = &result { + if previous_result.byte_range().end < layer_result.byte_range().end { + continue; + } + } + result = Some(layer_result); + break; + } + + // No sibling found at this level, try moving up to parent + if !cursor.goto_parent() { + break; + } + } + } + + result + } + + /// Find the next sibling syntax node at the given range. + /// + /// This function locates the syntax node that follows the node containing + /// the given range. It searches hierarchically by: + /// 1. Finding the node that contains the given range + /// 2. Looking for the next sibling at the same tree level + /// 3. If no sibling is found, moving up to parent levels and searching for siblings + /// + /// Returns `None` if there is no next sibling at any ancestor level. + pub fn syntax_next_sibling<'a, T: ToOffset>( + &'a self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut result: Option> = None; + + for layer in self + .syntax + .layers_for_range(range.clone(), &self.text, true) + { + let mut cursor = layer.node().walk(); + + Self::position_cursor_at_range(&mut cursor, &range); + + // Find the node that contains the range + if !Self::find_containing_node(&mut cursor, &range, false) { + continue; + } + + // Look for the next sibling, moving up ancestor levels if needed + loop { + if cursor.goto_next_sibling() { + let layer_result = cursor.node(); + + if let Some(previous_result) = &result { + if previous_result.byte_range().start > layer_result.byte_range().start { + continue; + } + } + result = Some(layer_result); + break; + } + + // No sibling found at this level, try moving up to parent + if !cursor.goto_parent() { + break; + } + } + } + + result + } + /// Returns the root syntax node within the given row pub fn syntax_root_ancestor(&self, position: Anchor) -> Option> { let start_offset = position.to_offset(self); diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 4535d57d77..f5955ec176 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -6095,6 +6095,28 @@ impl MultiBufferSnapshot { Some((node, range)) } + pub fn syntax_next_sibling( + &self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut excerpt = self.excerpt_containing(range.clone())?; + excerpt + .buffer() + .syntax_next_sibling(excerpt.map_range_to_buffer(range)) + } + + pub fn syntax_prev_sibling( + &self, + range: Range, + ) -> Option> { + let range = range.start.to_offset(self)..range.end.to_offset(self); + let mut excerpt = self.excerpt_containing(range.clone())?; + excerpt + .buffer() + .syntax_prev_sibling(excerpt.map_range_to_buffer(range)) + } + pub fn outline(&self, theme: Option<&SyntaxTheme>) -> Option> { let (excerpt_id, _, buffer) = self.as_singleton()?; let outline = buffer.outline(theme)?; diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index c62712af31..5fbc04fbee 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -53,6 +53,10 @@ actions!( SelectSmallerSyntaxNode, /// Selects the next larger syntax node. SelectLargerSyntaxNode, + /// Selects the next syntax node sibling. + SelectNextSyntaxNode, + /// Selects the previous syntax node sibling. + SelectPreviousSyntaxNode, /// Restores the previous visual selection. RestoreVisualSelection, /// Inserts at the end of each line in visual selection. @@ -110,6 +114,30 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } }); + Vim::action(editor, cx, |vim, _: &SelectNextSyntaxNode, window, cx| { + let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); + for _ in 0..count { + vim.update_editor(cx, |_, editor, cx| { + editor.select_next_syntax_node(&Default::default(), window, cx); + }); + } + }); + + Vim::action( + editor, + cx, + |vim, _: &SelectPreviousSyntaxNode, window, cx| { + let count = Vim::take_count(cx).unwrap_or(1); + Vim::take_forced_motion(cx); + for _ in 0..count { + vim.update_editor(cx, |_, editor, cx| { + editor.select_prev_syntax_node(&Default::default(), window, cx); + }); + } + }, + ); + Vim::action( editor, cx, @@ -1839,4 +1867,37 @@ mod test { fˇ»ox" }); } + + #[gpui::test] + async fn test_visual_syntax_sibling_selection(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + fn test() { + let ˇa = 1; + let b = 2; + let c = 3; + } + "}, + Mode::Normal, + ); + + // Enter visual mode and select the statement + cx.simulate_keystrokes("v w w w"); + cx.assert_state( + indoc! {" + fn test() { + let «a = 1;ˇ» + let b = 2; + let c = 3; + } + "}, + Mode::Visual, + ); + + // The specific behavior of syntax sibling selection in vim mode + // would depend on the key bindings configured, but the actions + // are now available for use + } } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 935c340038..50b88dd4ed 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -127,6 +127,11 @@ pub fn app_menus() -> Vec { ), MenuItem::action("Expand Selection", editor::actions::SelectLargerSyntaxNode), MenuItem::action("Shrink Selection", editor::actions::SelectSmallerSyntaxNode), + MenuItem::action("Select Next Sibling", editor::actions::SelectNextSyntaxNode), + MenuItem::action( + "Select Previous Sibling", + editor::actions::SelectPreviousSyntaxNode, + ), MenuItem::separator(), MenuItem::action("Add Cursor Above", editor::actions::AddSelectionAbove), MenuItem::action("Add Cursor Below", editor::actions::AddSelectionBelow),