vim: Add support for toggling boolean values (#25997)

brian tan created

Closes #10400
Closes https://github.com/zed-industries/zed/issues/17947

Changes:
- Let vim::increment find boolean values in the line and toggle them. 

Release Notes:

- vim: Added support for toggling boolean values with `ctrl-a`/`ctrl-x`

Change summary

crates/vim/src/normal/increment.rs | 163 +++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+), 1 deletion(-)

Detailed changes

crates/vim/src/normal/increment.rs 🔗

@@ -7,6 +7,8 @@ use std::ops::Range;
 
 use crate::{state::Mode, Vim};
 
+const BOOLEAN_PAIRS: &[(&str, &str)] = &[("true", "false"), ("yes", "no"), ("on", "off")];
+
 #[derive(Clone, Deserialize, JsonSchema, PartialEq)]
 #[serde(deny_unknown_fields)]
 struct Increment {
@@ -77,6 +79,13 @@ impl Vim {
                         if selection.is_empty() {
                             new_anchors.push((false, snapshot.anchor_after(range.end)))
                         }
+                    } else if let Some((range, boolean)) = find_boolean(&snapshot, start) {
+                        let replace = toggle_boolean(&boolean);
+                        delta += step as i64;
+                        edits.push((range.clone(), replace));
+                        if selection.is_empty() {
+                            new_anchors.push((false, snapshot.anchor_after(range.end)))
+                        }
                     } else if selection.is_empty() {
                         new_anchors.push((true, snapshot.anchor_after(start)))
                     }
@@ -243,11 +252,104 @@ fn find_number(
     }
 }
 
+fn find_boolean(snapshot: &MultiBufferSnapshot, start: Point) -> Option<(Range<Point>, String)> {
+    let mut offset = start.to_offset(snapshot);
+
+    let ch0 = snapshot.chars_at(offset).next();
+    if ch0.as_ref().is_some_and(|c| c.is_ascii_alphabetic()) {
+        for ch in snapshot.reversed_chars_at(offset) {
+            if ch.is_ascii_alphabetic() {
+                offset -= ch.len_utf8();
+                continue;
+            }
+            break;
+        }
+    }
+
+    let mut begin = None;
+    let mut end = None;
+    let mut word = String::new();
+
+    let mut chars = snapshot.chars_at(offset);
+
+    while let Some(ch) = chars.next() {
+        if ch.is_ascii_alphabetic() {
+            if begin.is_none() {
+                begin = Some(offset);
+            }
+            word.push(ch);
+        } else if begin.is_some() {
+            end = Some(offset);
+            let word_lower = word.to_lowercase();
+            if BOOLEAN_PAIRS
+                .iter()
+                .any(|(a, b)| word_lower == *a || word_lower == *b)
+            {
+                return Some((
+                    begin.unwrap().to_point(snapshot)..end.unwrap().to_point(snapshot),
+                    word,
+                ));
+            }
+            begin = None;
+            end = None;
+            word = String::new();
+        } else if ch == '\n' {
+            break;
+        }
+        offset += ch.len_utf8();
+    }
+    if let Some(begin) = begin {
+        let end = end.unwrap_or(offset);
+        let word_lower = word.to_lowercase();
+        if BOOLEAN_PAIRS
+            .iter()
+            .any(|(a, b)| word_lower == *a || word_lower == *b)
+        {
+            return Some((begin.to_point(snapshot)..end.to_point(snapshot), word));
+        }
+    }
+    None
+}
+
+fn toggle_boolean(boolean: &str) -> String {
+    let lower = boolean.to_lowercase();
+
+    let target = BOOLEAN_PAIRS
+        .iter()
+        .find_map(|(a, b)| {
+            if lower == *a {
+                Some(b)
+            } else if lower == *b {
+                Some(a)
+            } else {
+                None
+            }
+        })
+        .unwrap_or(&boolean);
+
+    if boolean.chars().all(|c| c.is_uppercase()) {
+        // Upper case
+        target.to_uppercase()
+    } else if boolean.chars().next().unwrap_or(' ').is_uppercase() {
+        // Title case
+        let mut chars = target.chars();
+        match chars.next() {
+            None => String::new(),
+            Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
+        }
+    } else {
+        target.to_string()
+    }
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
 
-    use crate::test::NeovimBackedTestContext;
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_increment(cx: &mut gpui::TestAppContext) {
@@ -599,4 +701,63 @@ mod test {
             24
             30"});
     }
+
+    #[gpui::test]
+    async fn test_toggle_boolean(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("let enabled = trˇue;", Mode::Normal);
+        cx.simulate_keystrokes("ctrl-a");
+        cx.assert_state("let enabled = falsˇe;", Mode::Normal);
+
+        cx.simulate_keystrokes("0 ctrl-a");
+        cx.assert_state("let enabled = truˇe;", Mode::Normal);
+
+        cx.set_state(
+            indoc! {"
+                ˇlet enabled = TRUE;
+                let enabled = TRUE;
+                let enabled = TRUE;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("shift-v j j ctrl-x");
+        cx.assert_state(
+            indoc! {"
+                ˇlet enabled = FALSE;
+                let enabled = FALSE;
+                let enabled = FALSE;
+            "},
+            Mode::Normal,
+        );
+
+        cx.set_state(
+            indoc! {"
+                let enabled = ˇYes;
+                let enabled = Yes;
+                let enabled = Yes;
+            "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("ctrl-v j j e ctrl-x");
+        cx.assert_state(
+            indoc! {"
+                let enabled = ˇNo;
+                let enabled = No;
+                let enabled = No;
+            "},
+            Mode::Normal,
+        );
+
+        cx.set_state("ˇlet enabled = True;", Mode::Normal);
+        cx.simulate_keystrokes("ctrl-a");
+        cx.assert_state("let enabled = Falsˇe;", Mode::Normal);
+
+        cx.simulate_keystrokes("ctrl-a");
+        cx.assert_state("let enabled = Truˇe;", Mode::Normal);
+
+        cx.set_state("let enabled = Onˇ;", Mode::Normal);
+        cx.simulate_keystrokes("v b ctrl-a");
+        cx.assert_state("let enabled = ˇOff;", Mode::Normal);
+    }
 }