Add commands to select next/previous siblings in the syntax tree (#35053)

Ivan Danov and Joseph T. Lyons created

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 <JosephTLyons@gmail.com>

Change summary

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(-)

Detailed changes

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.

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<Self>,
+    ) {
+        let old_selections: Box<[_]> = self.selections.all::<usize>(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::<Vec<_>>();
+
+        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<Self>,
+    ) {
+        let old_selections: Box<[_]> = self.selections.all::<usize>(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::<Vec<_>>();
+
+        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<Self>) -> Task<()> {
         if !EditorSettings::get_global(cx).gutter.runnables {
             self.clear_tasks();

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::<crate::EditorEvent>(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<Rgba> {
     editor

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);

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<usize>) {
+        // 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<usize>,
+        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<T>,
     ) -> Option<tree_sitter::Node<'a>> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut result: Option<tree_sitter::Node<'a>> = 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<T>,
+    ) -> Option<tree_sitter::Node<'a>> {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        let mut result: Option<tree_sitter::Node<'a>> = 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<T>,
+    ) -> Option<tree_sitter::Node<'a>> {
+        let range = range.start.to_offset(self)..range.end.to_offset(self);
+        let mut result: Option<tree_sitter::Node<'a>> = 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<tree_sitter::Node<'_>> {
         let start_offset = position.to_offset(self);

crates/multi_buffer/src/multi_buffer.rs ๐Ÿ”—

@@ -6095,6 +6095,28 @@ impl MultiBufferSnapshot {
         Some((node, range))
     }
 
+    pub fn syntax_next_sibling<T: ToOffset>(
+        &self,
+        range: Range<T>,
+    ) -> Option<tree_sitter::Node<'_>> {
+        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<T: ToOffset>(
+        &self,
+        range: Range<T>,
+    ) -> Option<tree_sitter::Node<'_>> {
+        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<Outline<Anchor>> {
         let (excerpt_id, _, buffer) = self.as_singleton()?;
         let outline = buffer.outline(theme)?;

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>) {
         }
     });
 
+    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
+    }
 }

crates/zed/src/zed/app_menus.rs ๐Ÿ”—

@@ -127,6 +127,11 @@ pub fn app_menus() -> Vec<Menu> {
                 ),
                 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),