vim: Add `:r[ead] [name]` command (#45332)

AidanV and Ben Kunkle created

This adds the following Vim commands: 
- `:r[ead] [name]`
- `:{range}r[ead] [name]`

The most important parts of this feature are outlined
[here](https://vimhelp.org/insert.txt.html#%3Ar).

The only intentional difference between this and Vim is that Vim only
allows `:read` (no filename) for buffers with a file attached. I am
allowing it for all buffers because I think that could be useful.

Release Notes:

- vim: Added the [`:r[ead] [name]` Vim
command](https://vimhelp.org/insert.txt.html#:read)

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/vim/src/command.rs | 200 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 200 insertions(+)

Detailed changes

crates/vim/src/command.rs 🔗

@@ -230,6 +230,14 @@ struct VimEdit {
     pub filename: String,
 }
 
+/// Pastes the specified file's contents.
+#[derive(Clone, PartialEq, Action)]
+#[action(namespace = vim, no_json, no_register)]
+struct VimRead {
+    pub range: Option<CommandRange>,
+    pub filename: String,
+}
+
 #[derive(Clone, PartialEq, Action)]
 #[action(namespace = vim, no_json, no_register)]
 struct VimNorm {
@@ -643,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
         });
     });
 
+    Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
+        vim.update_editor(cx, |vim, editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let end = if let Some(range) = action.range.clone() {
+                let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
+                else {
+                    return;
+                };
+
+                match &range.start {
+                    // inserting text above the first line uses the command ":0r {name}"
+                    Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
+                        snapshot.clip_point(Point::new(0, 0), Bias::Right)
+                    }
+                    _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
+                }
+            } else {
+                let end_row = editor
+                    .selections
+                    .newest::<Point>(&editor.display_snapshot(cx))
+                    .range()
+                    .end
+                    .row;
+                snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
+            };
+            let is_end_of_file = end == snapshot.max_point();
+            let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
+
+            let mut text = if is_end_of_file {
+                String::from('\n')
+            } else {
+                String::new()
+            };
+
+            let mut task = None;
+            if action.filename.is_empty() {
+                text.push_str(
+                    &editor
+                        .buffer()
+                        .read(cx)
+                        .as_singleton()
+                        .map(|buffer| buffer.read(cx).text())
+                        .unwrap_or_default(),
+                );
+            } else {
+                if let Some(project) = editor.project().cloned() {
+                    project.update(cx, |project, cx| {
+                        let Some(worktree) = project.visible_worktrees(cx).next() else {
+                            return;
+                        };
+                        let path_style = worktree.read(cx).path_style();
+                        let Some(path) =
+                            RelPath::new(Path::new(&action.filename), path_style).log_err()
+                        else {
+                            return;
+                        };
+                        task =
+                            Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
+                    });
+                } else {
+                    return;
+                }
+            };
+
+            cx.spawn_in(window, async move |editor, cx| {
+                if let Some(task) = task {
+                    text.push_str(
+                        &task
+                            .await
+                            .log_err()
+                            .map(|loaded_file| loaded_file.text)
+                            .unwrap_or_default(),
+                    );
+                }
+
+                if !text.is_empty() && !is_end_of_file {
+                    text.push('\n');
+                }
+
+                let _ = editor.update_in(cx, |editor, window, cx| {
+                    editor.transact(window, cx, |editor, window, cx| {
+                        editor.edit([(edit_range.clone(), text)], cx);
+                        let snapshot = editor.buffer().read(cx).snapshot(cx);
+                        editor.change_selections(Default::default(), window, cx, |s| {
+                            let point = if is_end_of_file {
+                                Point::new(
+                                    edit_range.start.to_point(&snapshot).row.saturating_add(1),
+                                    0,
+                                )
+                            } else {
+                                Point::new(edit_range.start.to_point(&snapshot).row, 0)
+                            };
+                            s.select_ranges([point..point]);
+                        })
+                    });
+                });
+            })
+            .detach();
+        });
+    });
+
     Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
         let keystrokes = action
             .command
@@ -1338,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
         VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
             .bang(editor::actions::ReloadFile)
             .filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
+        VimCommand::new(
+            ("r", "ead"),
+            VimRead {
+                range: None,
+                filename: "".into(),
+            },
+        )
+        .filename(|_, filename| {
+            Some(
+                VimRead {
+                    range: None,
+                    filename,
+                }
+                .boxed_clone(),
+            )
+        })
+        .range(|action, range| {
+            let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
+            action.range.replace(range.clone());
+            Some(Box::new(action))
+        }),
         VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
             Some(
                 VimSplit {
@@ -2575,6 +2705,76 @@ mod test {
         assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
     }
 
+    #[gpui::test]
+    async fn test_command_read(cx: &mut TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        let path = Path::new(path!("/root/dir/other.rs"));
+        fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
+
+        cx.workspace(|workspace, _, cx| {
+            assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
+        });
+
+        // File without trailing newline
+        cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
+
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\nˇtwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
+
+        // Empty filename
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
+
+        // File with trailing newline
+        fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
+        cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+        cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
+
+        cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
+
+        cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+        // Empty file
+        fs.as_fake().insert_file(path, "".into()).await;
+        cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
+        cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+        cx.simulate_keystrokes("enter");
+        cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
+    }
+
     #[gpui::test]
     async fn test_command_quit(cx: &mut TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;