From 1683052e6ce44d206e947c397917b2ab7617c0fe Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Sat, 15 Nov 2025 23:57:49 +0530 Subject: [PATCH] editor: Fix MoveToEnclosingBracket and unmatched forward/backward Vim motions in Markdown code blocks (#42813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/editor/src/editor_tests.rs | 84 +++++++++++++++++ .../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 +++++++++++++++++++ .../src/test/neovim_backed_test_context.rs | 20 +++++ crates/vim/src/test/vim_test_context.rs | 5 ++ .../test_unmatched_backward_markdown.json | 9 ++ .../test_unmatched_forward_markdown.json | 9 ++ 10 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 crates/vim/test_data/test_unmatched_backward_markdown.json create mode 100644 crates/vim/test_data/test_unmatched_forward_markdown.json diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0b485d4e1adac071a82d1ad8bde53f07d14f1434..20ad9ca076ed4ee68679bd351386ddc49f18491a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/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, |_| {}); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 427f0bd0de4d56bd01f6a1525ec8aaaf83fe3870..87cc3357783ef4503b584f9624d14a35a8487dd7 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/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 { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index ffc5ad85d14c293eeeaff9172b21ef58cf9a1cf0..49ea681290c3edc878391a337c5423fa795dba4f 100644 --- a/crates/language/Cargo.toml +++ b/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 } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 3b4f24a400403f7e4dbd4f09ee7fb829f4cbbe00..f46fc0db0a171349456b2c29a1c9fff556daee2d 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -823,6 +823,7 @@ pub struct BracketMatch { pub open_range: Range, pub close_range: Range, 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 + '_ { 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 }) } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index ac94378c9cc1ae300f9dcbd5a088f25761f309b4..82e0d69cefa94cc0e03a694eea0f29031d8fe156 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -2658,6 +2658,35 @@ pub fn rust_lang() -> Arc { Arc::new(language) } +#[doc(hidden)] +#[cfg(any(test, feature = "test-support"))] +pub fn markdown_lang() -> Arc { + 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::*; diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 2da1083ee6623cc8a463ef31be7e90dca0063b34..0264ea9176fb2264bc693888fb861ff33d5be706 100644 --- a/crates/vim/src/motion.rs +++ b/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; diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 9d2452ab20a6a99138c4b0d86f597f084a0876d6..ce2bb6eb7b6f77788f3bc002ff979fdbb251cb94 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/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(); diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 6300e3a3fcc079e064ef0e26c3e218b4032aa890..4d6859f1e56976fbb0d84d475e614325e0e52795 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/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( diff --git a/crates/vim/test_data/test_unmatched_backward_markdown.json b/crates/vim/test_data/test_unmatched_backward_markdown.json new file mode 100644 index 0000000000000000000000000000000000000000..c2df848b812e1685c39b7b8c353401493cc5a4be --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_unmatched_forward_markdown.json b/crates/vim/test_data/test_unmatched_forward_markdown.json new file mode 100644 index 0000000000000000000000000000000000000000..753f68d04fb458891de915134b5da8219742c06f --- /dev/null +++ b/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"}}