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

Juan Pablo Briones created

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`.

Change summary

crates/vim/src/motion.rs                                        | 261 ++
crates/vim/test_data/test_matching_comments.json                |  10 
crates/vim/test_data/test_matching_preprocessor_directives.json |  18 
3 files changed, 288 insertions(+), 1 deletion(-)

Detailed changes

crates/vim/src/motion.rs 🔗

@@ -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;

crates/vim/test_data/test_matching_comments.json 🔗

@@ -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"}}

crates/vim/test_data/test_matching_preprocessor_directives.json 🔗

@@ -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"}}