vim: Fix % for multiline comments and preprocessor directives (#53148)

Implements:
[49806](https://github.com/zed-industries/zed/discussions/49806)
Closes: [24820](https://github.com/zed-industries/zed/issues/24820)

Zeds impl of `%` didn't handle preprocessor directives and multiline

To implement this feature for multiline comment, a tree-sitter query is
used to check if we are inside a comment range
and then replicate the logic used in brackets.

For preprocessor directives using `TextObjects` wasn't a option, so it
was implemented through a text based query
that searches for the next preprocessor directives. Using text based
queries might not be the best for performance, so I'm open to any
suggestions.

Release Notes:

- Fixed vim's matching '%' to handle multiline comments `/* */` and
preprocessor directives `#if #else #endif`.
This commit is contained in:
Juan Pablo Briones 2026-04-06 22:51:54 -04:00 committed by GitHub
parent 46fc6938a6
commit 092c7058a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 288 additions and 1 deletions

View file

@ -7,7 +7,7 @@ use editor::{
},
};
use gpui::{Action, Context, Window, actions, px};
use language::{CharKind, Point, Selection, SelectionGoal};
use language::{CharKind, Point, Selection, SelectionGoal, TextObject, TreeSitterOptions};
use multi_buffer::MultiBufferRow;
use schemars::JsonSchema;
use serde::Deserialize;
@ -2451,6 +2451,10 @@ fn find_matching_bracket_text_based(
.take_while(|(_, char_offset)| *char_offset < line_range.end)
.find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset)));
if bracket_info.is_none() {
return find_matching_c_preprocessor_directive(map, line_range);
}
let (open, close, is_opening) = bracket_info?.0;
let bracket_offset = bracket_info?.1;
@ -2482,6 +2486,122 @@ fn find_matching_bracket_text_based(
None
}
fn find_matching_c_preprocessor_directive(
map: &DisplaySnapshot,
line_range: Range<MultiBufferOffset>,
) -> Option<MultiBufferOffset> {
let line_start = map
.buffer_chars_at(line_range.start)
.skip_while(|(c, _)| *c == ' ' || *c == '\t')
.map(|(c, _)| c)
.take(6)
.collect::<String>();
if line_start.starts_with("#if")
|| line_start.starts_with("#else")
|| line_start.starts_with("#elif")
{
let mut depth = 0i32;
for (ch, char_offset) in map.buffer_chars_at(line_range.end) {
if ch != '\n' {
continue;
}
let mut line_offset = char_offset + '\n'.len_utf8();
// Skip leading whitespace
map.buffer_chars_at(line_offset)
.take_while(|(c, _)| *c == ' ' || *c == '\t')
.for_each(|(_, _)| line_offset += 1);
// Check what directive starts the next line
let next_line_start = map
.buffer_chars_at(line_offset)
.map(|(c, _)| c)
.take(6)
.collect::<String>();
if next_line_start.starts_with("#if") {
depth += 1;
} else if next_line_start.starts_with("#endif") {
if depth > 0 {
depth -= 1;
} else {
return Some(line_offset);
}
} else if next_line_start.starts_with("#else") || next_line_start.starts_with("#elif") {
if depth == 0 {
return Some(line_offset);
}
}
}
} else if line_start.starts_with("#endif") {
let mut depth = 0i32;
for (ch, char_offset) in
map.reverse_buffer_chars_at(line_range.start.saturating_sub_usize(1))
{
let mut line_offset = if char_offset == MultiBufferOffset(0) {
MultiBufferOffset(0)
} else if ch != '\n' {
continue;
} else {
char_offset + '\n'.len_utf8()
};
// Skip leading whitespace
map.buffer_chars_at(line_offset)
.take_while(|(c, _)| *c == ' ' || *c == '\t')
.for_each(|(_, _)| line_offset += 1);
// Check what directive starts this line
let line_start = map
.buffer_chars_at(line_offset)
.skip_while(|(c, _)| *c == ' ' || *c == '\t')
.map(|(c, _)| c)
.take(6)
.collect::<String>();
if line_start.starts_with("\n\n") {
// empty line
continue;
} else if line_start.starts_with("#endif") {
depth += 1;
} else if line_start.starts_with("#if") {
if depth > 0 {
depth -= 1;
} else {
return Some(line_offset);
}
}
}
}
None
}
fn comment_delimiter_pair(
map: &DisplaySnapshot,
offset: MultiBufferOffset,
) -> Option<(Range<MultiBufferOffset>, Range<MultiBufferOffset>)> {
let snapshot = map.buffer_snapshot();
snapshot
.text_object_ranges(offset..offset, TreeSitterOptions::default())
.find_map(|(range, obj)| {
if !matches!(obj, TextObject::InsideComment | TextObject::AroundComment)
|| !range.contains(&offset)
{
return None;
}
let mut chars = snapshot.chars_at(range.start);
if (Some('/'), Some('*')) != (chars.next(), chars.next()) {
return None;
}
let open_range = range.start..range.start + 2usize;
let close_range = range.end - 2..range.end;
Some((open_range, close_range))
})
}
fn matching(
map: &DisplaySnapshot,
display_point: DisplayPoint,
@ -2609,6 +2729,32 @@ fn matching(
continue;
}
if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) {
if open_range.contains(&offset) {
return close_range.start.to_display_point(map);
}
if close_range.contains(&offset) {
return open_range.start.to_display_point(map);
}
let open_candidate = (open_range.start >= offset
&& line_range.contains(&open_range.start))
.then_some((open_range.start.saturating_sub(offset), close_range.start));
let close_candidate = (close_range.start >= offset
&& line_range.contains(&close_range.start))
.then_some((close_range.start.saturating_sub(offset), open_range.start));
if let Some((_, destination)) = [open_candidate, close_candidate]
.into_iter()
.flatten()
.min_by_key(|(distance, _)| *distance)
{
return destination.to_display_point(map);
}
}
closest_pair_destination
.map(|destination| destination.to_display_point(map))
.unwrap_or_else(|| {
@ -3497,6 +3643,119 @@ mod test {
);
}
#[gpui::test]
async fn test_matching_comments(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {r"ˇ/*
this is a comment
*/"})
.await;
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"/*
this is a comment
ˇ*/"});
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"ˇ/*
this is a comment
*/"});
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"/*
this is a comment
ˇ*/"});
cx.set_shared_state("ˇ// comment").await;
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq("ˇ// comment");
}
#[gpui::test]
async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {r"#ˇif
#else
#endif
"})
.await;
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"#if
ˇ#else
#endif
"});
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"#if
#else
ˇ#endif
"});
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"ˇ#if
#else
#endif
"});
cx.set_shared_state(indoc! {r"
#ˇif
#if
#else
#endif
#else
#endif
"})
.await;
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"
#if
#if
#else
#endif
ˇ#else
#endif
"});
cx.simulate_shared_keystrokes("% %").await;
cx.shared_state().await.assert_eq(indoc! {r"
ˇ#if
#if
#else
#endif
#else
#endif
"});
cx.simulate_shared_keystrokes("j % % %").await;
cx.shared_state().await.assert_eq(indoc! {r"
#if
ˇ#if
#else
#endif
#else
#endif
"});
}
#[gpui::test]
async fn test_unmatched_forward(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;

View file

@ -0,0 +1,10 @@
{"Put":{"state":"ˇ/*\n this is a comment\n*/"}}
{"Key":"%"}
{"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}}
{"Key":"%"}
{"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}}
{"Key":"%"}
{"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}}
{"Put":{"state":"ˇ// comment"}}
{"Key":"%"}
{"Get":{"state":"ˇ// comment","mode":"Normal"}}

View file

@ -0,0 +1,18 @@
{"Put":{"state":"#ˇif\n\n#else\n\n#endif\n"}}
{"Key":"%"}
{"Get":{"state":"#if\n\nˇ#else\n\n#endif\n","mode":"Normal"}}
{"Key":"%"}
{"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}}
{"Key":"%"}
{"Get":{"state":"ˇ#if\n\n#else\n\n#endif\n","mode":"Normal"}}
{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n"}}
{"Key":"%"}
{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n#endif\n","mode":"Normal"}}
{"Key":"%"}
{"Key":"%"}
{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}}
{"Key":"j"}
{"Key":"%"}
{"Key":"%"}
{"Key":"%"}
{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}}