vim: Add `g?` convert to `Rot13`/`Rot47` (#27824)

0x2CA and Conrad Irwin created

Release Notes:

- Added `g?` convert to `Rot13`/`Rot47`

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                            |  11 +
crates/editor/src/actions.rs                       |   2 
crates/editor/src/editor.rs                        |  36 ++++
crates/editor/src/element.rs                       |   2 
crates/gpui/src/keymap/context.rs                  |   2 
crates/vim/src/normal.rs                           |  32 +++-
crates/vim/src/normal/convert.rs                   | 128 ++++++++++++++-
crates/vim/src/state.rs                            |   8 +
crates/vim/src/vim.rs                              |  10 +
crates/vim/test_data/test_change_rot13_motion.json |  23 ++
crates/vim/test_data/test_change_rot13_object.json |   6 
crates/vim/test_data/test_convert_to_rot13.json    |  15 +
12 files changed, 252 insertions(+), 23 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -227,6 +227,8 @@
       "g u": "vim::PushLowercase",
       "g shift-u": "vim::PushUppercase",
       "g ~": "vim::PushOppositeCase",
+      "g ?": "vim::PushRot13",
+      // "g ?": "vim::PushRot47",
       "\"": "vim::PushRegister",
       "g w": "vim::PushRewrap",
       "g q": "vim::PushRewrap",
@@ -298,6 +300,8 @@
       "g r": ["vim::Paste", { "preserve_clipboard": true }],
       "g c": "vim::ToggleComments",
       "g q": "vim::Rewrap",
+      "g ?": "vim::ConvertToRot13",
+      // "g ?": "vim::ConvertToRot47",
       "\"": "vim::PushRegister",
       // tree-sitter related commands
       "[ x": "editor::SelectLargerSyntaxNode",
@@ -477,6 +481,13 @@
       "~": "vim::CurrentLine"
     }
   },
+  {
+    "context": "vim_operator == g?",
+    "bindings": {
+      "g ?": "vim::CurrentLine",
+      "?": "vim::CurrentLine"
+    }
+  },
   {
     "context": "vim_operator == gq",
     "bindings": {

crates/editor/src/actions.rs 🔗

@@ -274,6 +274,8 @@ actions!(
         ConvertToTitleCase,
         ConvertToUpperCamelCase,
         ConvertToUpperCase,
+        ConvertToRot13,
+        ConvertToRot47,
         Copy,
         CopyAndTrim,
         CopyFileLocation,

crates/editor/src/editor.rs 🔗

@@ -9168,6 +9168,42 @@ impl Editor {
         })
     }
 
+    pub fn convert_to_rot13(
+        &mut self,
+        _: &ConvertToRot13,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.manipulate_text(window, cx, |text| {
+            text.chars()
+                .map(|c| match c {
+                    'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char,
+                    'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char,
+                    _ => c,
+                })
+                .collect()
+        })
+    }
+
+    pub fn convert_to_rot47(
+        &mut self,
+        _: &ConvertToRot47,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.manipulate_text(window, cx, |text| {
+            text.chars()
+                .map(|c| {
+                    let code_point = c as u32;
+                    if code_point >= 33 && code_point <= 126 {
+                        return char::from_u32(33 + ((code_point + 14) % 94)).unwrap();
+                    }
+                    c
+                })
+                .collect()
+        })
+    }
+
     fn manipulate_text<Fn>(&mut self, window: &mut Window, cx: &mut Context<Self>, mut callback: Fn)
     where
         Fn: FnMut(&str) -> String,

crates/editor/src/element.rs 🔗

@@ -223,6 +223,8 @@ impl EditorElement {
         register_action(editor, window, Editor::convert_to_upper_camel_case);
         register_action(editor, window, Editor::convert_to_lower_camel_case);
         register_action(editor, window, Editor::convert_to_opposite_case);
+        register_action(editor, window, Editor::convert_to_rot13);
+        register_action(editor, window, Editor::convert_to_rot47);
         register_action(editor, window, Editor::delete_to_previous_word_start);
         register_action(editor, window, Editor::delete_to_previous_subword_start);
         register_action(editor, window, Editor::delete_to_next_word_end);

crates/gpui/src/keymap/context.rs 🔗

@@ -412,7 +412,7 @@ fn is_identifier_char(c: char) -> bool {
 }
 
 fn is_vim_operator_char(c: char) -> bool {
-    c == '>' || c == '<' || c == '~' || c == '"'
+    c == '>' || c == '<' || c == '~' || c == '"' || c == '?'
 }
 
 fn skip_whitespace(source: &str) -> &str {

crates/vim/src/normal.rs 🔗

@@ -1,5 +1,5 @@
-mod case;
 mod change;
+mod convert;
 mod delete;
 mod increment;
 pub(crate) mod mark;
@@ -22,8 +22,8 @@ use crate::{
     state::{Mark, Mode, Operator},
     surrounds::SurroundsType,
 };
-use case::CaseTarget;
 use collections::BTreeSet;
+use convert::ConvertTarget;
 use editor::Anchor;
 use editor::Bias;
 use editor::Editor;
@@ -55,6 +55,8 @@ actions!(
         ChangeCase,
         ConvertToUpperCase,
         ConvertToLowerCase,
+        ConvertToRot13,
+        ConvertToRot47,
         ToggleComments,
         ShowLocation,
         Undo,
@@ -73,6 +75,8 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::change_case);
     Vim::action(editor, cx, Vim::convert_to_upper_case);
     Vim::action(editor, cx, Vim::convert_to_lower_case);
+    Vim::action(editor, cx, Vim::convert_to_rot13);
+    Vim::action(editor, cx, Vim::convert_to_rot47);
     Vim::action(editor, cx, Vim::yank_line);
     Vim::action(editor, cx, Vim::toggle_comments);
     Vim::action(editor, cx, Vim::paste);
@@ -171,13 +175,19 @@ impl Vim {
             }
             Some(Operator::ShellCommand) => self.shell_command_motion(motion, times, window, cx),
             Some(Operator::Lowercase) => {
-                self.change_case_motion(motion, times, CaseTarget::Lowercase, window, cx)
+                self.convert_motion(motion, times, ConvertTarget::LowerCase, window, cx)
             }
             Some(Operator::Uppercase) => {
-                self.change_case_motion(motion, times, CaseTarget::Uppercase, window, cx)
+                self.convert_motion(motion, times, ConvertTarget::UpperCase, window, cx)
             }
             Some(Operator::OppositeCase) => {
-                self.change_case_motion(motion, times, CaseTarget::OppositeCase, window, cx)
+                self.convert_motion(motion, times, ConvertTarget::OppositeCase, window, cx)
+            }
+            Some(Operator::Rot13) => {
+                self.convert_motion(motion, times, ConvertTarget::Rot13, window, cx)
+            }
+            Some(Operator::Rot47) => {
+                self.convert_motion(motion, times, ConvertTarget::Rot47, window, cx)
             }
             Some(Operator::ToggleComments) => {
                 self.toggle_comments_motion(motion, times, window, cx)
@@ -216,13 +226,19 @@ impl Vim {
                 }
                 Some(Operator::Rewrap) => self.rewrap_object(object, around, window, cx),
                 Some(Operator::Lowercase) => {
-                    self.change_case_object(object, around, CaseTarget::Lowercase, window, cx)
+                    self.convert_object(object, around, ConvertTarget::LowerCase, window, cx)
                 }
                 Some(Operator::Uppercase) => {
-                    self.change_case_object(object, around, CaseTarget::Uppercase, window, cx)
+                    self.convert_object(object, around, ConvertTarget::UpperCase, window, cx)
                 }
                 Some(Operator::OppositeCase) => {
-                    self.change_case_object(object, around, CaseTarget::OppositeCase, window, cx)
+                    self.convert_object(object, around, ConvertTarget::OppositeCase, window, cx)
+                }
+                Some(Operator::Rot13) => {
+                    self.convert_object(object, around, ConvertTarget::Rot13, window, cx)
+                }
+                Some(Operator::Rot47) => {
+                    self.convert_object(object, around, ConvertTarget::Rot47, window, cx)
                 }
                 Some(Operator::AddSurrounds { target: None }) => {
                     waiting_operator = Some(Operator::AddSurrounds {

crates/vim/src/normal/case.rs → crates/vim/src/normal/convert.rs 🔗

@@ -7,23 +7,25 @@ use multi_buffer::MultiBufferRow;
 use crate::{
     Vim,
     motion::Motion,
-    normal::{ChangeCase, ConvertToLowerCase, ConvertToUpperCase},
+    normal::{ChangeCase, ConvertToLowerCase, ConvertToRot13, ConvertToRot47, ConvertToUpperCase},
     object::Object,
     state::Mode,
 };
 
-pub enum CaseTarget {
-    Lowercase,
-    Uppercase,
+pub enum ConvertTarget {
+    LowerCase,
+    UpperCase,
     OppositeCase,
+    Rot13,
+    Rot47,
 }
 
 impl Vim {
-    pub fn change_case_motion(
+    pub fn convert_motion(
         &mut self,
         motion: Motion,
         times: Option<usize>,
-        mode: CaseTarget,
+        mode: ConvertTarget,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -41,15 +43,21 @@ impl Vim {
                     });
                 });
                 match mode {
-                    CaseTarget::Lowercase => {
+                    ConvertTarget::LowerCase => {
                         editor.convert_to_lower_case(&Default::default(), window, cx)
                     }
-                    CaseTarget::Uppercase => {
+                    ConvertTarget::UpperCase => {
                         editor.convert_to_upper_case(&Default::default(), window, cx)
                     }
-                    CaseTarget::OppositeCase => {
+                    ConvertTarget::OppositeCase => {
                         editor.convert_to_opposite_case(&Default::default(), window, cx)
                     }
+                    ConvertTarget::Rot13 => {
+                        editor.convert_to_rot13(&Default::default(), window, cx)
+                    }
+                    ConvertTarget::Rot47 => {
+                        editor.convert_to_rot47(&Default::default(), window, cx)
+                    }
                 }
                 editor.change_selections(None, window, cx, |s| {
                     s.move_with(|map, selection| {
@@ -62,11 +70,11 @@ impl Vim {
         });
     }
 
-    pub fn change_case_object(
+    pub fn convert_object(
         &mut self,
         object: Object,
         around: bool,
-        mode: CaseTarget,
+        mode: ConvertTarget,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -85,15 +93,21 @@ impl Vim {
                     });
                 });
                 match mode {
-                    CaseTarget::Lowercase => {
+                    ConvertTarget::LowerCase => {
                         editor.convert_to_lower_case(&Default::default(), window, cx)
                     }
-                    CaseTarget::Uppercase => {
+                    ConvertTarget::UpperCase => {
                         editor.convert_to_upper_case(&Default::default(), window, cx)
                     }
-                    CaseTarget::OppositeCase => {
+                    ConvertTarget::OppositeCase => {
                         editor.convert_to_opposite_case(&Default::default(), window, cx)
                     }
+                    ConvertTarget::Rot13 => {
+                        editor.convert_to_rot13(&Default::default(), window, cx)
+                    }
+                    ConvertTarget::Rot47 => {
+                        editor.convert_to_rot47(&Default::default(), window, cx)
+                    }
                 }
                 editor.change_selections(None, window, cx, |s| {
                     s.move_with(|map, selection| {
@@ -134,6 +148,36 @@ impl Vim {
         self.manipulate_text(window, cx, |c| c.to_lowercase().collect::<Vec<char>>())
     }
 
+    pub fn convert_to_rot13(
+        &mut self,
+        _: &ConvertToRot13,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.manipulate_text(window, cx, |c| {
+            vec![match c {
+                'A'..='M' | 'a'..='m' => ((c as u8) + 13) as char,
+                'N'..='Z' | 'n'..='z' => ((c as u8) - 13) as char,
+                _ => c,
+            }]
+        })
+    }
+
+    pub fn convert_to_rot47(
+        &mut self,
+        _: &ConvertToRot47,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.manipulate_text(window, cx, |c| {
+            let code_point = c as u32;
+            if code_point >= 33 && code_point <= 126 {
+                return vec![char::from_u32(33 + ((code_point + 14) % 94)).unwrap()];
+            }
+            vec![c]
+        })
+    }
+
     fn manipulate_text<F>(&mut self, window: &mut Window, cx: &mut Context<Self>, transform: F)
     where
         F: Fn(char) -> Vec<char> + Copy,
@@ -308,4 +352,60 @@ mod test {
         cx.simulate_shared_keystrokes("g shift-u i w").await;
         cx.shared_state().await.assert_eq("abc ˇDEF\n");
     }
+
+    #[gpui::test]
+    async fn test_convert_to_rot13(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        // works in visual mode
+        cx.set_shared_state("a😀C«dÉ1*fˇ»\n").await;
+        cx.simulate_shared_keystrokes("g ?").await;
+        cx.shared_state().await.assert_eq("a😀CˇqÉ1*s\n");
+
+        // works with line selections
+        cx.set_shared_state("abˇC\n").await;
+        cx.simulate_shared_keystrokes("shift-v g ?").await;
+        cx.shared_state().await.assert_eq("ˇnoP\n");
+
+        // works in visual block mode
+        cx.set_shared_state("ˇaa\nbb\ncc").await;
+        cx.simulate_shared_keystrokes("ctrl-v j g ?").await;
+        cx.shared_state().await.assert_eq("ˇna\nob\ncc");
+    }
+
+    #[gpui::test]
+    async fn test_change_rot13_motion(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇabc def").await;
+        cx.simulate_shared_keystrokes("g ? w").await;
+        cx.shared_state().await.assert_eq("ˇnop def");
+
+        cx.simulate_shared_keystrokes("g ? w").await;
+        cx.shared_state().await.assert_eq("ˇabc def");
+
+        cx.simulate_shared_keystrokes(".").await;
+        cx.shared_state().await.assert_eq("ˇnop def");
+
+        cx.set_shared_state("abˇc def").await;
+        cx.simulate_shared_keystrokes("g ? i w").await;
+        cx.shared_state().await.assert_eq("ˇnop def");
+
+        cx.simulate_shared_keystrokes(".").await;
+        cx.shared_state().await.assert_eq("ˇabc def");
+
+        cx.simulate_shared_keystrokes("g ? $").await;
+        cx.shared_state().await.assert_eq("ˇnop qrs");
+    }
+
+    #[gpui::test]
+    async fn test_change_rot13_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
+            .await;
+        cx.simulate_shared_keystrokes("g ? i w").await;
+        cx.shared_state()
+            .await
+            .assert_eq("ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM");
+    }
 }

crates/vim/src/state.rs 🔗

@@ -116,6 +116,8 @@ pub enum Operator {
     Lowercase,
     Uppercase,
     OppositeCase,
+    Rot13,
+    Rot47,
     Digraph {
         first_char: Option<char>,
     },
@@ -958,6 +960,8 @@ impl Operator {
             Operator::Uppercase => "gU",
             Operator::Lowercase => "gu",
             Operator::OppositeCase => "g~",
+            Operator::Rot13 => "g?",
+            Operator::Rot47 => "g?",
             Operator::Register => "\"",
             Operator::RecordRegister => "q",
             Operator::ReplayRegister => "@",
@@ -1006,6 +1010,8 @@ impl Operator {
             | Operator::ShellCommand
             | Operator::Lowercase
             | Operator::Uppercase
+            | Operator::Rot13
+            | Operator::Rot47
             | Operator::ReplaceWithRegister
             | Operator::Exchange
             | Operator::Object { .. }
@@ -1026,6 +1032,8 @@ impl Operator {
             | Operator::Lowercase
             | Operator::Uppercase
             | Operator::OppositeCase
+            | Operator::Rot13
+            | Operator::Rot47
             | Operator::ToggleComments
             | Operator::ReplaceWithRegister
             | Operator::Rewrap

crates/vim/src/vim.rs 🔗

@@ -153,6 +153,8 @@ actions!(
         PushLowercase,
         PushUppercase,
         PushOppositeCase,
+        PushRot13,
+        PushRot47,
         ToggleRegistersView,
         PushRegister,
         PushRecordRegister,
@@ -619,6 +621,14 @@ impl Vim {
                 vim.push_operator(Operator::OppositeCase, window, cx)
             });
 
+            Vim::action(editor, cx, |vim, _: &PushRot13, window, cx| {
+                vim.push_operator(Operator::Rot13, window, cx)
+            });
+
+            Vim::action(editor, cx, |vim, _: &PushRot47, window, cx| {
+                vim.push_operator(Operator::Rot47, window, cx)
+            });
+
             Vim::action(editor, cx, |vim, _: &PushRegister, window, cx| {
                 vim.push_operator(Operator::Register, window, cx)
             });

crates/vim/test_data/test_change_rot13_motion.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇabc def"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Key":"w"}
+{"Get":{"state":"ˇnop def","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Key":"w"}
+{"Get":{"state":"ˇabc def","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"ˇnop def","mode":"Normal"}}
+{"Put":{"state":"abˇc def"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"ˇnop def","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"ˇabc def","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Key":"$"}
+{"Get":{"state":"ˇnop qrs","mode":"Normal"}}

crates/vim/test_data/test_change_rot13_object.json 🔗

@@ -0,0 +1,6 @@
+{"Put":{"state":"ˇabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM","mode":"Normal"}}

crates/vim/test_data/test_convert_to_rot13.json 🔗

@@ -0,0 +1,15 @@
+{"Put":{"state":"a😀C«dÉ1*fˇ»\n"}}
+{"Key":"g"}
+{"Key":"?"}
+{"Get":{"state":"a😀CˇqÉ1*s\n","mode":"Normal"}}
+{"Put":{"state":"abˇC\n"}}
+{"Key":"shift-v"}
+{"Key":"g"}
+{"Key":"?"}
+{"Get":{"state":"ˇnoP\n","mode":"Normal"}}
+{"Put":{"state":"ˇaa\nbb\ncc"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"g"}
+{"Key":"?"}
+{"Get":{"state":"ˇna\nob\ncc","mode":"Normal"}}