vim: Enable %
to jump between tags (#20536)
Closes #12986 Release Notes: - Enable `%` to jump between pairs of tags --------- Co-authored-by: Harrison <hrouillard@sfi.com.au>
This commit is contained in:
parent
189a034e71
commit
f0882f44a7
8 changed files with 159 additions and 10 deletions
1
a.html
Normal file
1
a.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<body></body>
|
|
@ -234,7 +234,16 @@ impl EditorLspTestContext {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Some(tree_sitter_html::language()),
|
Some(tree_sitter_html::language()),
|
||||||
);
|
)
|
||||||
|
.with_queries(LanguageQueries {
|
||||||
|
brackets: Some(Cow::from(indoc! {r#"
|
||||||
|
("<" @open "/>" @close)
|
||||||
|
("</" @open ">" @close)
|
||||||
|
("<" @open ">" @close)
|
||||||
|
("\"" @open "\"" @close)"#})),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.expect("Could not parse queries");
|
||||||
Self::new(language, Default::default(), cx).await
|
Self::new(language, Default::default(), cx).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,6 @@
|
||||||
("[" @open "]" @close)
|
("[" @open "]" @close)
|
||||||
("{" @open "}" @close)
|
("{" @open "}" @close)
|
||||||
("<" @open ">" @close)
|
("<" @open ">" @close)
|
||||||
|
("<" @open "/>" @close)
|
||||||
|
("</" @open ">" @close)
|
||||||
("\"" @open "\"" @close)
|
("\"" @open "\"" @close)
|
||||||
|
|
|
@ -1697,6 +1697,31 @@ fn end_of_document(
|
||||||
map.clip_point(new_point.to_display_point(map), Bias::Left)
|
map.clip_point(new_point.to_display_point(map), Bias::Left)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn matching_tag(map: &DisplaySnapshot, head: DisplayPoint) -> Option<DisplayPoint> {
|
||||||
|
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 {
|
fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint {
|
||||||
// https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200
|
// https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1200
|
||||||
let display_point = map.clip_at_line_end(display_point);
|
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;
|
let mut closest_distance = usize::MAX;
|
||||||
|
|
||||||
for (open_range, close_range) in ranges {
|
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) {
|
if open_range.start >= offset && line_range.contains(&open_range.start) {
|
||||||
let distance = open_range.start - offset;
|
let distance = open_range.start - offset;
|
||||||
if distance < closest_distance {
|
if distance < closest_distance {
|
||||||
closest_pair_destination = Some(close_range.start);
|
closest_pair_destination = Some(close_range.end - 1);
|
||||||
closest_distance = distance;
|
closest_distance = distance;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -2077,6 +2118,56 @@ mod test {
|
||||||
cx.shared_state().await.assert_eq("func boop(ˇ) {\n}");
|
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"<bˇody></body>"}).await;
|
||||||
|
cx.simulate_shared_keystrokes("%").await;
|
||||||
|
cx.shared_state()
|
||||||
|
.await
|
||||||
|
.assert_eq(indoc! {r"<body><ˇ/body>"});
|
||||||
|
cx.simulate_shared_keystrokes("%").await;
|
||||||
|
|
||||||
|
// test jumping backwards
|
||||||
|
cx.shared_state()
|
||||||
|
.await
|
||||||
|
.assert_eq(indoc! {r"<ˇbody></body>"});
|
||||||
|
|
||||||
|
// test self-closing tags
|
||||||
|
cx.set_shared_state(indoc! {r"<a><bˇr/></a>"}).await;
|
||||||
|
cx.simulate_shared_keystrokes("%").await;
|
||||||
|
cx.shared_state().await.assert_eq(indoc! {r"<a><bˇr/></a>"});
|
||||||
|
|
||||||
|
// test tag with attributes
|
||||||
|
cx.set_shared_state(indoc! {r"<div class='test' ˇid='main'>
|
||||||
|
</div>
|
||||||
|
"})
|
||||||
|
.await;
|
||||||
|
cx.simulate_shared_keystrokes("%").await;
|
||||||
|
cx.shared_state()
|
||||||
|
.await
|
||||||
|
.assert_eq(indoc! {r"<div class='test' id='main'>
|
||||||
|
<ˇ/div>
|
||||||
|
"});
|
||||||
|
|
||||||
|
// test multi-line self-closing tag
|
||||||
|
cx.set_shared_state(indoc! {r#"<a>
|
||||||
|
<br
|
||||||
|
test = "test"
|
||||||
|
/ˇ>
|
||||||
|
</a>"#})
|
||||||
|
.await;
|
||||||
|
cx.simulate_shared_keystrokes("%").await;
|
||||||
|
cx.shared_state().await.assert_eq(indoc! {r#"<a>
|
||||||
|
ˇ<br
|
||||||
|
test = "test"
|
||||||
|
/>
|
||||||
|
</a>"#});
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) {
|
async fn test_comma_semicolon(cx: &mut gpui::TestAppContext) {
|
||||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||||
|
|
|
@ -204,7 +204,11 @@ impl Object {
|
||||||
Object::Parentheses => {
|
Object::Parentheses => {
|
||||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
|
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 => {
|
Object::SquareBrackets => {
|
||||||
surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
|
surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
|
||||||
}
|
}
|
||||||
|
@ -262,9 +266,10 @@ fn in_word(
|
||||||
Some(start..end)
|
Some(start..end)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn surrounding_html_tag(
|
pub fn surrounding_html_tag(
|
||||||
map: &DisplaySnapshot,
|
map: &DisplaySnapshot,
|
||||||
selection: Selection<DisplayPoint>,
|
head: DisplayPoint,
|
||||||
|
range: Range<DisplayPoint>,
|
||||||
around: bool,
|
around: bool,
|
||||||
) -> Option<Range<DisplayPoint>> {
|
) -> Option<Range<DisplayPoint>> {
|
||||||
fn read_tag(chars: impl Iterator<Item = char>) -> String {
|
fn read_tag(chars: impl Iterator<Item = char>) -> String {
|
||||||
|
@ -286,7 +291,7 @@ fn surrounding_html_tag(
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot = &map.buffer_snapshot;
|
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 excerpt = snapshot.excerpt_containing(offset..offset)?;
|
||||||
let buffer = excerpt.buffer();
|
let buffer = excerpt.buffer();
|
||||||
let offset = excerpt.map_offset_to_buffer(offset);
|
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 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()));
|
let close_tag = close_tag(buffer.chars_for_range(last_child.byte_range()));
|
||||||
// It needs to be handled differently according to the selection length
|
// It needs to be handled differently according to the selection length
|
||||||
let is_valid = if selection.end.to_offset(map, Bias::Left)
|
let is_valid = if range.end.to_offset(map, Bias::Left)
|
||||||
- selection.start.to_offset(map, Bias::Left)
|
- range.start.to_offset(map, Bias::Left)
|
||||||
<= 1
|
<= 1
|
||||||
{
|
{
|
||||||
offset <= last_child.end_byte()
|
offset <= last_child.end_byte()
|
||||||
} else {
|
} else {
|
||||||
selection.start.to_offset(map, Bias::Left) >= first_child.start_byte()
|
range.start.to_offset(map, Bias::Left) >= first_child.start_byte()
|
||||||
&& selection.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1
|
&& range.end.to_offset(map, Bias::Left) <= last_child.start_byte() + 1
|
||||||
};
|
};
|
||||||
if open_tag.is_some() && open_tag == close_tag && is_valid {
|
if open_tag.is_some() && open_tag == close_tag && is_valid {
|
||||||
let range = if around {
|
let range = if around {
|
||||||
|
|
|
@ -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) {
|
pub async fn set_shared_state(&mut self, marked_text: &str) {
|
||||||
let mode = if marked_text.contains('»') {
|
let mode = if marked_text.contains('»') {
|
||||||
Mode::Visual
|
Mode::Visual
|
||||||
|
|
15
crates/vim/test_data/test_matching_tags.json
Normal file
15
crates/vim/test_data/test_matching_tags.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{"Exec":{"command":"set filetype=html"}}
|
||||||
|
{"Put":{"state":"<bˇody></body>"}}
|
||||||
|
{"Key":"%"}
|
||||||
|
{"Get":{"state":"<body><ˇ/body>","mode":"Normal"}}
|
||||||
|
{"Key":"%"}
|
||||||
|
{"Get":{"state":"<ˇbody></body>","mode":"Normal"}}
|
||||||
|
{"Put":{"state":"<a><bˇr/></a>"}}
|
||||||
|
{"Key":"%"}
|
||||||
|
{"Get":{"state":"<a><bˇr/></a>","mode":"Normal"}}
|
||||||
|
{"Put":{"state":"<div class='test' ˇid='main'>\n</div>\n"}}
|
||||||
|
{"Key":"%"}
|
||||||
|
{"Get":{"state":"<div class='test' id='main'>\n<ˇ/div>\n","mode":"Normal"}}
|
||||||
|
{"Put":{"state":"<a>\n <br\n test = \"test\"\n /ˇ>\n</a>"}}
|
||||||
|
{"Key":"%"}
|
||||||
|
{"Get":{"state":"<a>\n ˇ<br\n test = \"test\"\n />\n</a>","mode":"Normal"}}
|
|
@ -1,2 +1,4 @@
|
||||||
|
("<" @open "/>" @close)
|
||||||
|
("</" @open ">" @close)
|
||||||
("<" @open ">" @close)
|
("<" @open ">" @close)
|
||||||
("\"" @open "\"" @close)
|
("\"" @open "\"" @close)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue