editor: Fix MoveToEnclosingBracket and unmatched forward/backward Vim motions in Markdown code blocks (#42813)

Smit Barmase and MuskanPaliwal created

We now correctly use bracket ranges from the deepest syntax layer when
finding enclosing brackets.

Release Notes:

- Fixed an issue where `MoveToEnclosingBracket` didn’t work correctly
inside Markdown code blocks.
- Fixed an issue where unmatched forward/backward Vim motions didn’t
work correctly inside Markdown code blocks.

---------

Co-authored-by: MuskanPaliwal <muskan10112002@gmail.com>

Change summary

crates/editor/src/editor_tests.rs                          | 84 +++++++
crates/editor/src/test/editor_lsp_test_context.rs          | 18 +
crates/language/Cargo.toml                                 |  2 
crates/language/src/buffer.rs                              | 11 
crates/language/src/language.rs                            | 29 ++
crates/vim/src/motion.rs                                   | 90 ++++++++
crates/vim/src/test/neovim_backed_test_context.rs          | 20 +
crates/vim/src/test/vim_test_context.rs                    |  5 
crates/vim/test_data/test_unmatched_backward_markdown.json |  9 
crates/vim/test_data/test_unmatched_forward_markdown.json  |  9 
10 files changed, 274 insertions(+), 3 deletions(-)

Detailed changes

crates/editor/src/editor_tests.rs 🔗

@@ -32,6 +32,7 @@ use language::{
     tree_sitter_python,
 };
 use language_settings::Formatter;
+use languages::markdown_lang;
 use languages::rust_lang;
 use lsp::CompletionParams;
 use multi_buffer::{IndentGuide, PathKey};
@@ -17503,6 +17504,89 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_move_to_enclosing_bracket_in_markdown_code_block(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    let language_registry = Arc::new(language::LanguageRegistry::test(cx.executor()));
+    language_registry.add(markdown_lang());
+    language_registry.add(rust_lang());
+    let buffer = cx.new(|cx| {
+        let mut buffer = language::Buffer::local(
+            indoc! {"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                }
+            }
+            ```
+        "},
+            cx,
+        );
+        buffer.set_language_registry(language_registry.clone());
+        buffer.set_language(Some(markdown_lang()), cx);
+        buffer
+    });
+    let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+    let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx));
+    cx.executor().run_until_parked();
+    _ = editor.update(cx, |editor, window, cx| {
+        // Case 1: Test outer enclosing brackets
+        select_ranges(
+            editor,
+            &indoc! {"
+                ```rs
+                impl Worktree {
+                    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                    }
+                }ˇ
+                ```
+            "},
+            window,
+            cx,
+        );
+        editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx);
+        assert_text_with_selections(
+            editor,
+            &indoc! {"
+                ```rs
+                impl Worktree ˇ{
+                    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                    }
+                }
+                ```
+            "},
+            cx,
+        );
+        // Case 2: Test inner enclosing brackets
+        select_ranges(
+            editor,
+            &indoc! {"
+                ```rs
+                impl Worktree {
+                    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                    }ˇ
+                }
+                ```
+            "},
+            window,
+            cx,
+        );
+        editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx);
+        assert_text_with_selections(
+            editor,
+            &indoc! {"
+                ```rs
+                impl Worktree {
+                    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{
+                    }
+                }
+                ```
+            "},
+            cx,
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -6,7 +6,7 @@ use std::{
 };
 
 use anyhow::Result;
-use language::rust_lang;
+use language::{markdown_lang, rust_lang};
 use serde_json::json;
 
 use crate::{Editor, ToPoint};
@@ -313,6 +313,22 @@ impl EditorLspTestContext {
         Self::new(language, Default::default(), cx).await
     }
 
+    pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> Self {
+        let context = Self::new(
+            Arc::into_inner(markdown_lang()).unwrap(),
+            Default::default(),
+            cx,
+        )
+        .await;
+
+        let language_registry = context.workspace.read_with(cx, |workspace, cx| {
+            workspace.project().read(cx).languages().clone()
+        });
+        language_registry.add(rust_lang());
+
+        context
+    }
+
     /// Constructs lsp range using a marked string with '[', ']' range delimiters
     #[track_caller]
     pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {

crates/language/Cargo.toml 🔗

@@ -21,6 +21,7 @@ test-support = [
     "tree-sitter-rust",
     "tree-sitter-python",
     "tree-sitter-typescript",
+    "tree-sitter-md",
     "settings/test-support",
     "util/test-support",
 ]
@@ -59,6 +60,7 @@ sum_tree.workspace = true
 task.workspace = true
 text.workspace = true
 theme.workspace = true
+tree-sitter-md = { workspace = true, optional = true }
 tree-sitter-python = { workspace = true, optional = true }
 tree-sitter-rust = { workspace = true, optional = true }
 tree-sitter-typescript = { workspace = true, optional = true }

crates/language/src/buffer.rs 🔗

@@ -823,6 +823,7 @@ pub struct BracketMatch {
     pub open_range: Range<usize>,
     pub close_range: Range<usize>,
     pub newline_only: bool,
+    pub depth: usize,
 }
 
 impl Buffer {
@@ -4136,6 +4137,7 @@ impl BufferSnapshot {
             while let Some(mat) = matches.peek() {
                 let mut open = None;
                 let mut close = None;
+                let depth = mat.depth;
                 let config = &configs[mat.grammar_index];
                 let pattern = &config.patterns[mat.pattern_index];
                 for capture in mat.captures {
@@ -4161,6 +4163,7 @@ impl BufferSnapshot {
                     open_range,
                     close_range,
                     newline_only: pattern.newline_only,
+                    depth,
                 });
             }
             None
@@ -4320,8 +4323,12 @@ impl BufferSnapshot {
     ) -> impl Iterator<Item = BracketMatch> + '_ {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
 
-        self.bracket_ranges(range.clone()).filter(move |pair| {
-            pair.open_range.start <= range.start && pair.close_range.end >= range.end
+        let result: Vec<_> = self.bracket_ranges(range.clone()).collect();
+        let max_depth = result.iter().map(|mat| mat.depth).max().unwrap_or(0);
+        result.into_iter().filter(move |pair| {
+            pair.open_range.start <= range.start
+                && pair.close_range.end >= range.end
+                && pair.depth == max_depth
         })
     }
 

crates/language/src/language.rs 🔗

@@ -2658,6 +2658,35 @@ pub fn rust_lang() -> Arc<Language> {
     Arc::new(language)
 }
 
+#[doc(hidden)]
+#[cfg(any(test, feature = "test-support"))]
+pub fn markdown_lang() -> Arc<Language> {
+    use std::borrow::Cow;
+
+    let language = Language::new(
+        LanguageConfig {
+            name: "Markdown".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["md".into()],
+                ..Default::default()
+            },
+            ..Default::default()
+        },
+        Some(tree_sitter_md::LANGUAGE.into()),
+    )
+    .with_queries(LanguageQueries {
+        brackets: Some(Cow::from(include_str!(
+            "../../languages/src/markdown/brackets.scm"
+        ))),
+        injections: Some(Cow::from(include_str!(
+            "../../languages/src/markdown/injections.scm"
+        ))),
+        ..Default::default()
+    })
+    .expect("Could not parse markdown queries");
+    Arc::new(language)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/vim/src/motion.rs 🔗

@@ -3312,6 +3312,96 @@ mod test {
         cx.shared_state().await.assert_eq("ˇ(\n    {()} \n)");
     }
 
+    #[gpui::test]
+    async fn test_unmatched_forward_markdown(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new_markdown_with_rust(cx).await;
+
+        cx.neovim.exec("set filetype=markdown").await;
+
+        cx.set_shared_state(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+            ˇ    }
+            }
+            ```
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("] }").await;
+        cx.shared_state().await.assert_eq(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                ˇ}
+            }
+            ```
+        "});
+
+        cx.set_shared_state(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                }   ˇ
+            }
+            ```
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("] }").await;
+        cx.shared_state().await.assert_eq(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                }  •
+            ˇ}
+            ```
+        "});
+    }
+
+    #[gpui::test]
+    async fn test_unmatched_backward_markdown(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new_markdown_with_rust(cx).await;
+
+        cx.neovim.exec("set filetype=markdown").await;
+
+        cx.set_shared_state(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+            ˇ    }
+            }
+            ```
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("[ {").await;
+        cx.shared_state().await.assert_eq(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{
+                }
+            }
+            ```
+        "});
+
+        cx.set_shared_state(indoc! {r"
+            ```rs
+            impl Worktree {
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                }   ˇ
+            }
+            ```
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("[ {").await;
+        cx.shared_state().await.assert_eq(indoc! {r"
+            ```rs
+            impl Worktree ˇ{
+                pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {
+                }  •
+            }
+            ```
+        "});
+    }
+
     #[gpui::test]
     async fn test_matching_tags(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new_html(cx).await;

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -183,6 +183,26 @@ impl NeovimBackedTestContext {
         }
     }
 
+    pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
+        #[cfg(feature = "neovim")]
+        cx.executor().allow_parking();
+        let thread = thread::current();
+        let test_name = thread
+            .name()
+            .expect("thread is not named")
+            .split(':')
+            .next_back()
+            .unwrap()
+            .to_string();
+        Self {
+            cx: VimTestContext::new_markdown_with_rust(cx).await,
+            neovim: NeovimConnection::new(test_name).await,
+
+            last_set_state: None,
+            recent_keystrokes: Default::default(),
+        }
+    }
+
     pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
         #[cfg(feature = "neovim")]
         cx.executor().allow_parking();

crates/vim/src/test/vim_test_context.rs 🔗

@@ -41,6 +41,11 @@ impl VimTestContext {
         Self::new_with_lsp(EditorLspTestContext::new_html(cx).await, true)
     }
 
+    pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> VimTestContext {
+        Self::init(cx);
+        Self::new_with_lsp(EditorLspTestContext::new_markdown_with_rust(cx).await, true)
+    }
+
     pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> VimTestContext {
         Self::init(cx);
         Self::new_with_lsp(

crates/vim/test_data/test_unmatched_backward_markdown.json 🔗

@@ -0,0 +1,9 @@
+{"Exec":{"command":"set filetype=markdown"}}
+{"Put":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\nˇ    }\n}\n```\n"}}
+{"Key":"["}
+{"Key":"{"}
+{"Get":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> ˇ{\n    }\n}\n```\n","mode":"Normal"}}
+{"Put":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n    }   ˇ\n}\n```\n"}}
+{"Key":"["}
+{"Key":"{"}
+{"Get":{"state":"```rs\nimpl Worktree ˇ{\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n    }   \n}\n```\n","mode":"Normal"}}

crates/vim/test_data/test_unmatched_forward_markdown.json 🔗

@@ -0,0 +1,9 @@
+{"Exec":{"command":"set filetype=markdown"}}
+{"Put":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\nˇ    }\n}\n```\n"}}
+{"Key":"]"}
+{"Key":"}"}
+{"Get":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n    ˇ}\n}\n```\n","mode":"Normal"}}
+{"Put":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n    }   ˇ\n}\n```\n"}}
+{"Key":"]"}
+{"Key":"}"}
+{"Get":{"state":"```rs\nimpl Worktree {\n    pub async fn open_buffers(&self, path: &Path) -> impl Iterator<&Buffer> {\n    }   \nˇ}\n```\n","mode":"Normal"}}