From f0882f44a7e67340bca86659647a789fb53a50ff Mon Sep 17 00:00:00 2001 From: hrou0003 <54772688+hrou0003@users.noreply.github.com> Date: Fri, 15 Nov 2024 06:41:53 +1100 Subject: [PATCH] vim: Enable `%` to jump between tags (#20536) Closes #12986 Release Notes: - Enable `%` to jump between pairs of tags --------- Co-authored-by: Harrison --- a.html | 1 + .../src/test/editor_lsp_test_context.rs | 11 ++- crates/languages/src/tsx/brackets.scm | 2 + crates/vim/src/motion.rs | 93 ++++++++++++++++++- crates/vim/src/object.rs | 21 +++-- .../src/test/neovim_backed_test_context.rs | 24 +++++ crates/vim/test_data/test_matching_tags.json | 15 +++ extensions/html/languages/html/brackets.scm | 2 + 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 a.html create mode 100644 crates/vim/test_data/test_matching_tags.json diff --git a/a.html b/a.html new file mode 100644 index 0000000000..c8b7661f42 --- /dev/null +++ b/a.html @@ -0,0 +1 @@ + diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 23c5775abd..0384ed065b 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -234,7 +234,16 @@ impl EditorLspTestContext { ..Default::default() }, Some(tree_sitter_html::language()), - ); + ) + .with_queries(LanguageQueries { + brackets: Some(Cow::from(indoc! {r#" + ("<" @open "/>" @close) + ("" @close) + ("<" @open ">" @close) + ("\"" @open "\"" @close)"#})), + ..Default::default() + }) + .expect("Could not parse queries"); Self::new(language, Default::default(), cx).await } diff --git a/crates/languages/src/tsx/brackets.scm b/crates/languages/src/tsx/brackets.scm index 63395f81d8..7a02f815c0 100644 --- a/crates/languages/src/tsx/brackets.scm +++ b/crates/languages/src/tsx/brackets.scm @@ -2,4 +2,6 @@ ("[" @open "]" @close) ("{" @open "}" @close) ("<" @open ">" @close) +("<" @open "/>" @close) +("" @close) ("\"" @open "\"" @close) diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index ce9c11d400..9f7a30afe9 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1697,6 +1697,31 @@ fn end_of_document( map.clip_point(new_point.to_display_point(map), Bias::Left) } +fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option { + let inner = crate::object::surrounding_html_tag(map, head, head..head, false)?; + let outer = crate::object::surrounding_html_tag(map, head, head..head, true)?; + + if head > outer.start && head < inner.start { + let mut offset = inner.end.to_offset(map, Bias::Left); + for c in map.buffer_snapshot.chars_at(offset) { + if c == '/' || c == '\n' || c == '>' { + return Some(offset.to_display_point(map)); + } + offset += c.len_utf8(); + } + } else { + let mut offset = outer.start.to_offset(map, Bias::Left); + for c in map.buffer_snapshot.chars_at(offset) { + offset += c.len_utf8(); + if c == '<' || c == '\n' { + return Some(offset.to_display_point(map)); + } + } + } + + return None; +} + fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200 let display_point = map.clip_at_line_end(display_point); @@ -1722,10 +1747,26 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint let mut closest_distance = usize::MAX; for (open_range, close_range) in ranges { + if map.buffer_snapshot.chars_at(open_range.start).next() == Some('<') { + if offset > open_range.start && offset < close_range.start { + let mut chars = map.buffer_snapshot.chars_at(close_range.start); + if (Some('/'), Some('>')) == (chars.next(), chars.next()) { + return display_point; + } + if let Some(tag) = matching_tag(map, display_point) { + return tag; + } + } else if close_range.contains(&offset) { + return open_range.start.to_display_point(map); + } else if open_range.contains(&offset) { + return (close_range.end - 1).to_display_point(map); + } + } + if open_range.start >= offset && line_range.contains(&open_range.start) { let distance = open_range.start - offset; if distance < closest_distance { - closest_pair_destination = Some(close_range.start); + closest_pair_destination = Some(close_range.end - 1); closest_distance = distance; continue; } @@ -2077,6 +2118,56 @@ mod test { cx.shared_state().await.assert_eq("func boop(ˇ) {\n}"); } + #[gpui::test] + async fn test_matching_tags(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new_html(cx).await; + + cx.neovim.exec("set filetype=html").await; + + cx.set_shared_state(indoc! {r""}).await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"<ˇ/body>"}); + cx.simulate_shared_keystrokes("%").await; + + // test jumping backwards + cx.shared_state() + .await + .assert_eq(indoc! {r"<ˇbody>"}); + + // test self-closing tags + cx.set_shared_state(indoc! {r""}).await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r""}); + + // test tag with attributes + cx.set_shared_state(indoc! {r"
+
+ "}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"
+ <ˇ/div> + "}); + + // test multi-line self-closing tag + cx.set_shared_state(indoc! {r#" +
+
"#}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + ˇ
+
"#}); + } + #[gpui::test] async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 502f00aa8d..7c1f2fdb4c 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -204,7 +204,11 @@ impl Object { Object::Parentheses => { surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')') } - Object::Tag => surrounding_html_tag(map, selection, around), + Object::Tag => { + let head = selection.head(); + let range = selection.range(); + surrounding_html_tag(map, head, range, around) + } Object::SquareBrackets => { surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']') } @@ -262,9 +266,10 @@ fn in_word( Some(start..end) } -fn surrounding_html_tag( +pub fn surrounding_html_tag( map: &DisplaySnapshot, - selection: Selection, + head: DisplayPoint, + range: Range, around: bool, ) -> Option> { fn read_tag(chars: impl Iterator) -> String { @@ -286,7 +291,7 @@ fn surrounding_html_tag( } let snapshot = &map.buffer_snapshot; - let offset = selection.head().to_offset(map, Bias::Left); + let offset = head.to_offset(map, Bias::Left); let excerpt = snapshot.excerpt_containing(offset..offset)?; let buffer = excerpt.buffer(); let offset = excerpt.map_offset_to_buffer(offset); @@ -307,14 +312,14 @@ fn surrounding_html_tag( let open_tag = open_tag(buffer.chars_for_range(first_child.byte_range())); let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range())); // It needs to be handled differently according to the selection length - let is_valid = if selection.end.to_offset(map, Bias::Left) - - selection.start.to_offset(map, Bias::Left) + let is_valid = if range.end.to_offset(map, Bias::Left) + - range.start.to_offset(map, Bias::Left) <= 1 { offset <= last_child.end_byte() } else { - selection.start.to_offset(map, Bias::Left) >= first_child.start_byte() - && selection.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1 + range.start.to_offset(map, Bias::Left) >= first_child.start_byte() + && range.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1 }; if open_tag.is_some() && open_tag == close_tag && is_valid { let range = if around { diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 16ab7771bd..954cc68a7a 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -162,6 +162,30 @@ impl NeovimBackedTestContext { } } + pub async fn new_html(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext { + #[cfg(feature = "neovim")] + cx.executor().allow_parking(); + // rust stores the name of the test on the current thread. + // We use this to automatically name a file that will store + // the neovim connection's requests/responses so that we can + // run without neovim on CI. + let thread = thread::current(); + let test_name = thread + .name() + .expect("thread is not named") + .split(':') + .last() + .unwrap() + .to_string(); + Self { + cx: VimTestContext::new_html(cx).await, + neovim: NeovimConnection::new(test_name).await, + + last_set_state: None, + recent_keystrokes: Default::default(), + } + } + pub async fn set_shared_state(&mut self, marked_text: &str) { let mode = if marked_text.contains('»') { Mode::Visual diff --git a/crates/vim/test_data/test_matching_tags.json b/crates/vim/test_data/test_matching_tags.json new file mode 100644 index 0000000000..bb4f5fd450 --- /dev/null +++ b/crates/vim/test_data/test_matching_tags.json @@ -0,0 +1,15 @@ +{"Exec":{"command":"set filetype=html"}} +{"Put":{"state":""}} +{"Key":"%"} +{"Get":{"state":"<ˇ/body>","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"<ˇbody>","mode":"Normal"}} +{"Put":{"state":""}} +{"Key":"%"} +{"Get":{"state":"","mode":"Normal"}} +{"Put":{"state":"
\n
\n"}} +{"Key":"%"} +{"Get":{"state":"
\n<ˇ/div>\n","mode":"Normal"}} +{"Put":{"state":"\n \n"}} +{"Key":"%"} +{"Get":{"state":"\n ˇ\n","mode":"Normal"}} diff --git a/extensions/html/languages/html/brackets.scm b/extensions/html/languages/html/brackets.scm index 2d12b17daa..e865561f77 100644 --- a/extensions/html/languages/html/brackets.scm +++ b/extensions/html/languages/html/brackets.scm @@ -1,2 +1,4 @@ +("<" @open "/>" @close) +("" @close) ("<" @open ">" @close) ("\"" @open "\"" @close)