command.rs

  1use std::{iter::Peekable, ops::Range, str::Chars, sync::OnceLock};
  2
  3use anyhow::{anyhow, Result};
  4use command_palette_hooks::CommandInterceptResult;
  5use editor::{
  6    actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
  7    Editor, ToPoint,
  8};
  9use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext};
 10use language::Point;
 11use multi_buffer::MultiBufferRow;
 12use serde::Deserialize;
 13use ui::WindowContext;
 14use util::ResultExt;
 15use workspace::{notifications::NotifyResultExt, SaveIntent};
 16
 17use crate::{
 18    motion::{EndOfDocument, Motion, StartOfDocument},
 19    normal::{
 20        search::{FindCommand, ReplaceCommand, Replacement},
 21        JoinLines,
 22    },
 23    state::Mode,
 24    visual::{VisualDeleteLine, VisualYankLine},
 25    Vim,
 26};
 27
 28#[derive(Debug, Clone, PartialEq, Deserialize)]
 29pub struct GoToLine {
 30    range: CommandRange,
 31}
 32
 33#[derive(Debug)]
 34pub struct WithRange {
 35    is_count: bool,
 36    range: CommandRange,
 37    action: Box<dyn Action>,
 38}
 39
 40actions!(vim, [VisualCommand, CountCommand]);
 41impl_actions!(vim, [GoToLine, WithRange]);
 42
 43impl<'de> Deserialize<'de> for WithRange {
 44    fn deserialize<D>(_: D) -> Result<Self, D::Error>
 45    where
 46        D: serde::Deserializer<'de>,
 47    {
 48        Err(serde::de::Error::custom("Cannot deserialize WithRange"))
 49    }
 50}
 51
 52impl PartialEq for WithRange {
 53    fn eq(&self, other: &Self) -> bool {
 54        self.range == other.range && self.action.partial_eq(&*other.action)
 55    }
 56}
 57
 58impl Clone for WithRange {
 59    fn clone(&self) -> Self {
 60        Self {
 61            is_count: self.is_count,
 62            range: self.range.clone(),
 63            action: self.action.boxed_clone(),
 64        }
 65    }
 66}
 67
 68pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
 69    Vim::action(editor, cx, |vim, _: &VisualCommand, cx| {
 70        let Some(workspace) = vim.workspace(cx) else {
 71            return;
 72        };
 73        workspace.update(cx, |workspace, cx| {
 74            command_palette::CommandPalette::toggle(workspace, "'<,'>", cx);
 75        })
 76    });
 77
 78    Vim::action(editor, cx, |vim, _: &CountCommand, cx| {
 79        let Some(workspace) = vim.workspace(cx) else {
 80            return;
 81        };
 82        let count = vim.take_count(cx).unwrap_or(1);
 83        workspace.update(cx, |workspace, cx| {
 84            command_palette::CommandPalette::toggle(
 85                workspace,
 86                &format!(".,.+{}", count.saturating_sub(1)),
 87                cx,
 88            );
 89        })
 90    });
 91
 92    Vim::action(editor, cx, |vim, action: &GoToLine, cx| {
 93        vim.switch_mode(Mode::Normal, false, cx);
 94        let result = vim.update_editor(cx, |vim, editor, cx| {
 95            action.range.head().buffer_row(vim, editor, cx)
 96        });
 97        let buffer_row = match result {
 98            None => return,
 99            Some(e @ Err(_)) => {
100                let Some(workspace) = vim.workspace(cx) else {
101                    return;
102                };
103                workspace.update(cx, |workspace, cx| {
104                    e.notify_err(workspace, cx);
105                });
106                return;
107            }
108            Some(Ok(result)) => result,
109        };
110        vim.move_cursor(Motion::StartOfDocument, Some(buffer_row.0 as usize + 1), cx);
111    });
112
113    Vim::action(editor, cx, |vim, action: &WithRange, cx| {
114        if action.is_count {
115            for _ in 0..action.range.as_count() {
116                cx.dispatch_action(action.action.boxed_clone())
117            }
118            return;
119        }
120        let result = vim.update_editor(cx, |vim, editor, cx| {
121            action.range.buffer_range(vim, editor, cx)
122        });
123
124        let range = match result {
125            None => return,
126            Some(e @ Err(_)) => {
127                let Some(workspace) = vim.workspace(cx) else {
128                    return;
129                };
130                workspace.update(cx, |workspace, cx| {
131                    e.notify_err(workspace, cx);
132                });
133                return;
134            }
135            Some(Ok(result)) => result,
136        };
137        vim.update_editor(cx, |_, editor, cx| {
138            editor.change_selections(None, cx, |s| {
139                let end = Point::new(range.end.0, s.buffer().line_len(range.end));
140                s.select_ranges([end..Point::new(range.start.0, 0)]);
141            })
142        });
143        cx.dispatch_action(action.action.boxed_clone());
144        cx.defer(move |vim, cx| {
145            vim.update_editor(cx, |_, editor, cx| {
146                editor.change_selections(None, cx, |s| {
147                    s.select_ranges([Point::new(range.start.0, 0)..Point::new(range.start.0, 0)]);
148                })
149            });
150        });
151    });
152}
153
154#[derive(Debug, Default)]
155struct VimCommand {
156    prefix: &'static str,
157    suffix: &'static str,
158    action: Option<Box<dyn Action>>,
159    action_name: Option<&'static str>,
160    bang_action: Option<Box<dyn Action>>,
161    has_range: bool,
162    has_count: bool,
163}
164
165impl VimCommand {
166    fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
167        Self {
168            prefix: pattern.0,
169            suffix: pattern.1,
170            action: Some(action.boxed_clone()),
171            ..Default::default()
172        }
173    }
174
175    // from_str is used for actions in other crates.
176    fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
177        Self {
178            prefix: pattern.0,
179            suffix: pattern.1,
180            action_name: Some(action_name),
181            ..Default::default()
182        }
183    }
184
185    fn bang(mut self, bang_action: impl Action) -> Self {
186        self.bang_action = Some(bang_action.boxed_clone());
187        self
188    }
189
190    fn range(mut self) -> Self {
191        self.has_range = true;
192        self
193    }
194    fn count(mut self) -> Self {
195        self.has_count = true;
196        self
197    }
198
199    fn parse(&self, mut query: &str, cx: &AppContext) -> Option<Box<dyn Action>> {
200        let has_bang = query.ends_with('!');
201        if has_bang {
202            query = &query[..query.len() - 1];
203        }
204
205        let suffix = query.strip_prefix(self.prefix)?;
206        if !self.suffix.starts_with(suffix) {
207            return None;
208        }
209
210        if has_bang && self.bang_action.is_some() {
211            Some(self.bang_action.as_ref().unwrap().boxed_clone())
212        } else if let Some(action) = self.action.as_ref() {
213            Some(action.boxed_clone())
214        } else if let Some(action_name) = self.action_name {
215            cx.build_action(action_name, None).log_err()
216        } else {
217            None
218        }
219    }
220
221    // TODO: ranges with search queries
222    fn parse_range(query: &str) -> (Option<CommandRange>, String) {
223        let mut chars = query.chars().peekable();
224
225        match chars.peek() {
226            Some('%') => {
227                chars.next();
228                return (
229                    Some(CommandRange {
230                        start: Position::Line { row: 1, offset: 0 },
231                        end: Some(Position::LastLine { offset: 0 }),
232                    }),
233                    chars.collect(),
234                );
235            }
236            Some('*') => {
237                chars.next();
238                return (
239                    Some(CommandRange {
240                        start: Position::Mark {
241                            name: '<',
242                            offset: 0,
243                        },
244                        end: Some(Position::Mark {
245                            name: '>',
246                            offset: 0,
247                        }),
248                    }),
249                    chars.collect(),
250                );
251            }
252            _ => {}
253        }
254
255        let start = Self::parse_position(&mut chars);
256
257        match chars.peek() {
258            Some(',' | ';') => {
259                chars.next();
260                (
261                    Some(CommandRange {
262                        start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
263                        end: Self::parse_position(&mut chars),
264                    }),
265                    chars.collect(),
266                )
267            }
268            _ => (
269                start.map(|start| CommandRange { start, end: None }),
270                chars.collect(),
271            ),
272        }
273    }
274
275    fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
276        match chars.peek()? {
277            '0'..='9' => {
278                let row = Self::parse_u32(chars);
279                Some(Position::Line {
280                    row,
281                    offset: Self::parse_offset(chars),
282                })
283            }
284            '\'' => {
285                chars.next();
286                let name = chars.next()?;
287                Some(Position::Mark {
288                    name,
289                    offset: Self::parse_offset(chars),
290                })
291            }
292            '.' => {
293                chars.next();
294                Some(Position::CurrentLine {
295                    offset: Self::parse_offset(chars),
296                })
297            }
298            '+' | '-' => Some(Position::CurrentLine {
299                offset: Self::parse_offset(chars),
300            }),
301            '$' => {
302                chars.next();
303                Some(Position::LastLine {
304                    offset: Self::parse_offset(chars),
305                })
306            }
307            _ => None,
308        }
309    }
310
311    fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
312        let mut res: i32 = 0;
313        while matches!(chars.peek(), Some('+' | '-')) {
314            let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
315            let amount = if matches!(chars.peek(), Some('0'..='9')) {
316                (Self::parse_u32(chars) as i32).saturating_mul(sign)
317            } else {
318                sign
319            };
320            res = res.saturating_add(amount)
321        }
322        res
323    }
324
325    fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
326        let mut res: u32 = 0;
327        while matches!(chars.peek(), Some('0'..='9')) {
328            res = res
329                .saturating_mul(10)
330                .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
331        }
332        res
333    }
334}
335
336#[derive(Debug, Clone, PartialEq, Deserialize)]
337enum Position {
338    Line { row: u32, offset: i32 },
339    Mark { name: char, offset: i32 },
340    LastLine { offset: i32 },
341    CurrentLine { offset: i32 },
342}
343
344impl Position {
345    fn buffer_row(
346        &self,
347        vim: &Vim,
348        editor: &mut Editor,
349        cx: &mut WindowContext,
350    ) -> Result<MultiBufferRow> {
351        let snapshot = editor.snapshot(cx);
352        let target = match self {
353            Position::Line { row, offset } => row.saturating_add_signed(offset.saturating_sub(1)),
354            Position::Mark { name, offset } => {
355                let Some(mark) = vim.marks.get(&name.to_string()).and_then(|vec| vec.last()) else {
356                    return Err(anyhow!("mark {} not set", name));
357                };
358                mark.to_point(&snapshot.buffer_snapshot)
359                    .row
360                    .saturating_add_signed(*offset)
361            }
362            Position::LastLine { offset } => {
363                snapshot.max_buffer_row().0.saturating_add_signed(*offset)
364            }
365            Position::CurrentLine { offset } => editor
366                .selections
367                .newest_anchor()
368                .head()
369                .to_point(&snapshot.buffer_snapshot)
370                .row
371                .saturating_add_signed(*offset),
372        };
373
374        Ok(MultiBufferRow(target).min(snapshot.max_buffer_row()))
375    }
376}
377
378#[derive(Debug, Clone, PartialEq, Deserialize)]
379pub(crate) struct CommandRange {
380    start: Position,
381    end: Option<Position>,
382}
383
384impl CommandRange {
385    fn head(&self) -> &Position {
386        self.end.as_ref().unwrap_or(&self.start)
387    }
388
389    pub(crate) fn buffer_range(
390        &self,
391        vim: &Vim,
392        editor: &mut Editor,
393        cx: &mut WindowContext,
394    ) -> Result<Range<MultiBufferRow>> {
395        let start = self.start.buffer_row(vim, editor, cx)?;
396        let end = if let Some(end) = self.end.as_ref() {
397            end.buffer_row(vim, editor, cx)?
398        } else {
399            start
400        };
401        if end < start {
402            anyhow::Ok(end..start)
403        } else {
404            anyhow::Ok(start..end)
405        }
406    }
407
408    pub fn as_count(&self) -> u32 {
409        if let CommandRange {
410            start: Position::Line { row, offset: 0 },
411            end: None,
412        } = &self
413        {
414            *row
415        } else {
416            0
417        }
418    }
419
420    pub fn is_count(&self) -> bool {
421        matches!(
422            &self,
423            CommandRange {
424                start: Position::Line { row: _, offset: 0 },
425                end: None
426            }
427        )
428    }
429}
430
431fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
432    vec![
433        VimCommand::new(
434            ("w", "rite"),
435            workspace::Save {
436                save_intent: Some(SaveIntent::Save),
437            },
438        )
439        .bang(workspace::Save {
440            save_intent: Some(SaveIntent::Overwrite),
441        }),
442        VimCommand::new(
443            ("q", "uit"),
444            workspace::CloseActiveItem {
445                save_intent: Some(SaveIntent::Close),
446            },
447        )
448        .bang(workspace::CloseActiveItem {
449            save_intent: Some(SaveIntent::Skip),
450        }),
451        VimCommand::new(
452            ("wq", ""),
453            workspace::CloseActiveItem {
454                save_intent: Some(SaveIntent::Save),
455            },
456        )
457        .bang(workspace::CloseActiveItem {
458            save_intent: Some(SaveIntent::Overwrite),
459        }),
460        VimCommand::new(
461            ("x", "it"),
462            workspace::CloseActiveItem {
463                save_intent: Some(SaveIntent::SaveAll),
464            },
465        )
466        .bang(workspace::CloseActiveItem {
467            save_intent: Some(SaveIntent::Overwrite),
468        }),
469        VimCommand::new(
470            ("ex", "it"),
471            workspace::CloseActiveItem {
472                save_intent: Some(SaveIntent::SaveAll),
473            },
474        )
475        .bang(workspace::CloseActiveItem {
476            save_intent: Some(SaveIntent::Overwrite),
477        }),
478        VimCommand::new(
479            ("up", "date"),
480            workspace::Save {
481                save_intent: Some(SaveIntent::SaveAll),
482            },
483        ),
484        VimCommand::new(
485            ("wa", "ll"),
486            workspace::SaveAll {
487                save_intent: Some(SaveIntent::SaveAll),
488            },
489        )
490        .bang(workspace::SaveAll {
491            save_intent: Some(SaveIntent::Overwrite),
492        }),
493        VimCommand::new(
494            ("qa", "ll"),
495            workspace::CloseAllItemsAndPanes {
496                save_intent: Some(SaveIntent::Close),
497            },
498        )
499        .bang(workspace::CloseAllItemsAndPanes {
500            save_intent: Some(SaveIntent::Skip),
501        }),
502        VimCommand::new(
503            ("quita", "ll"),
504            workspace::CloseAllItemsAndPanes {
505                save_intent: Some(SaveIntent::Close),
506            },
507        )
508        .bang(workspace::CloseAllItemsAndPanes {
509            save_intent: Some(SaveIntent::Skip),
510        }),
511        VimCommand::new(
512            ("xa", "ll"),
513            workspace::CloseAllItemsAndPanes {
514                save_intent: Some(SaveIntent::SaveAll),
515            },
516        )
517        .bang(workspace::CloseAllItemsAndPanes {
518            save_intent: Some(SaveIntent::Overwrite),
519        }),
520        VimCommand::new(
521            ("wqa", "ll"),
522            workspace::CloseAllItemsAndPanes {
523                save_intent: Some(SaveIntent::SaveAll),
524            },
525        )
526        .bang(workspace::CloseAllItemsAndPanes {
527            save_intent: Some(SaveIntent::Overwrite),
528        }),
529        VimCommand::new(("cq", "uit"), zed_actions::Quit),
530        VimCommand::new(("sp", "lit"), workspace::SplitHorizontal),
531        VimCommand::new(("vs", "plit"), workspace::SplitVertical),
532        VimCommand::new(
533            ("bd", "elete"),
534            workspace::CloseActiveItem {
535                save_intent: Some(SaveIntent::Close),
536            },
537        )
538        .bang(workspace::CloseActiveItem {
539            save_intent: Some(SaveIntent::Skip),
540        }),
541        VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
542        VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem).count(),
543        VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem).count(),
544        VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
545        VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
546        VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
547        VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
548        VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
549        VimCommand::new(("tabe", "dit"), workspace::NewFile),
550        VimCommand::new(("tabnew", ""), workspace::NewFile),
551        VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
552        VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem).count(),
553        VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem).count(),
554        VimCommand::new(
555            ("tabc", "lose"),
556            workspace::CloseActiveItem {
557                save_intent: Some(SaveIntent::Close),
558            },
559        ),
560        VimCommand::new(
561            ("tabo", "nly"),
562            workspace::CloseInactiveItems {
563                save_intent: Some(SaveIntent::Close),
564            },
565        )
566        .bang(workspace::CloseInactiveItems {
567            save_intent: Some(SaveIntent::Skip),
568        }),
569        VimCommand::new(
570            ("on", "ly"),
571            workspace::CloseInactiveTabsAndPanes {
572                save_intent: Some(SaveIntent::Close),
573            },
574        )
575        .bang(workspace::CloseInactiveTabsAndPanes {
576            save_intent: Some(SaveIntent::Skip),
577        }),
578        VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
579        VimCommand::new(("cc", ""), editor::actions::Hover),
580        VimCommand::new(("ll", ""), editor::actions::Hover),
581        VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic).count(),
582        VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
583        VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
584        VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
585        VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
586        VimCommand::new(("j", "oin"), JoinLines).range(),
587        VimCommand::new(("dif", "fupdate"), editor::actions::ToggleHunkDiff).range(),
588        VimCommand::new(("rev", "ert"), editor::actions::RevertSelectedHunks).range(),
589        VimCommand::new(("d", "elete"), VisualDeleteLine).range(),
590        VimCommand::new(("y", "ank"), VisualYankLine).range(),
591        VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(),
592        VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(),
593        VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
594        VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
595        VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
596        VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
597        VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
598        VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
599        VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
600        VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
601        VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
602        VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
603        VimCommand::str(("A", "I"), "assistant::ToggleFocus"),
604        VimCommand::new(("$", ""), EndOfDocument),
605        VimCommand::new(("%", ""), EndOfDocument),
606        VimCommand::new(("0", ""), StartOfDocument),
607    ]
608}
609
610struct VimCommands(Vec<VimCommand>);
611// safety: we only ever access this from the main thread (as ensured by the cx argument)
612// actions are not Sync so we can't otherwise use a OnceLock.
613unsafe impl Sync for VimCommands {}
614impl Global for VimCommands {}
615
616fn commands(cx: &AppContext) -> &Vec<VimCommand> {
617    static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
618    &COMMANDS
619        .get_or_init(|| VimCommands(generate_commands(cx)))
620        .0
621}
622
623pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
624    // NOTE: We also need to support passing arguments to commands like :w
625    // (ideally with filename autocompletion).
626    while input.starts_with(':') {
627        input = &input[1..];
628    }
629
630    let (range, query) = VimCommand::parse_range(input);
631    let range_prefix = input[0..(input.len() - query.len())].to_string();
632    let query = query.as_str().trim();
633
634    let action = if range.is_some() && query.is_empty() {
635        Some(
636            GoToLine {
637                range: range.clone().unwrap(),
638            }
639            .boxed_clone(),
640        )
641    } else if query.starts_with('/') || query.starts_with('?') {
642        Some(
643            FindCommand {
644                query: query[1..].to_string(),
645                backwards: query.starts_with('?'),
646            }
647            .boxed_clone(),
648        )
649    } else if query.starts_with('s') {
650        let mut substitute = "substitute".chars().peekable();
651        let mut query = query.chars().peekable();
652        while substitute
653            .peek()
654            .is_some_and(|char| Some(char) == query.peek())
655        {
656            substitute.next();
657            query.next();
658        }
659        if let Some(replacement) = Replacement::parse(query) {
660            let range = range.clone().unwrap_or(CommandRange {
661                start: Position::CurrentLine { offset: 0 },
662                end: None,
663            });
664            Some(ReplaceCommand { replacement, range }.boxed_clone())
665        } else {
666            None
667        }
668    } else {
669        None
670    };
671    if let Some(action) = action {
672        let string = input.to_string();
673        let positions = generate_positions(&string, &(range_prefix + query));
674        return Some(CommandInterceptResult {
675            action,
676            string,
677            positions,
678        });
679    }
680
681    for command in commands(cx).iter() {
682        if let Some(action) = command.parse(query, cx) {
683            let string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
684            let positions = generate_positions(&string, &(range_prefix + query));
685
686            if let Some(range) = &range {
687                if command.has_range || (range.is_count() && command.has_count) {
688                    return Some(CommandInterceptResult {
689                        action: Box::new(WithRange {
690                            is_count: command.has_count,
691                            range: range.clone(),
692                            action,
693                        }),
694                        string,
695                        positions,
696                    });
697                } else {
698                    return None;
699                }
700            }
701
702            return Some(CommandInterceptResult {
703                action,
704                string,
705                positions,
706            });
707        }
708    }
709    None
710}
711
712fn generate_positions(string: &str, query: &str) -> Vec<usize> {
713    let mut positions = Vec::new();
714    let mut chars = query.chars();
715
716    let Some(mut current) = chars.next() else {
717        return positions;
718    };
719
720    for (i, c) in string.char_indices() {
721        if c == current {
722            positions.push(i);
723            if let Some(c) = chars.next() {
724                current = c;
725            } else {
726                break;
727            }
728        }
729    }
730
731    positions
732}
733
734#[cfg(test)]
735mod test {
736    use std::path::Path;
737
738    use crate::{
739        state::Mode,
740        test::{NeovimBackedTestContext, VimTestContext},
741    };
742    use editor::Editor;
743    use gpui::TestAppContext;
744    use indoc::indoc;
745    use ui::ViewContext;
746    use workspace::Workspace;
747
748    #[gpui::test]
749    async fn test_command_basics(cx: &mut TestAppContext) {
750        let mut cx = NeovimBackedTestContext::new(cx).await;
751
752        cx.set_shared_state(indoc! {"
753            ˇa
754            b
755            c"})
756            .await;
757
758        cx.simulate_shared_keystrokes(": j enter").await;
759
760        // hack: our cursor positionining after a join command is wrong
761        cx.simulate_shared_keystrokes("^").await;
762        cx.shared_state().await.assert_eq(indoc! {
763            "ˇa b
764            c"
765        });
766    }
767
768    #[gpui::test]
769    async fn test_command_goto(cx: &mut TestAppContext) {
770        let mut cx = NeovimBackedTestContext::new(cx).await;
771
772        cx.set_shared_state(indoc! {"
773            ˇa
774            b
775            c"})
776            .await;
777        cx.simulate_shared_keystrokes(": 3 enter").await;
778        cx.shared_state().await.assert_eq(indoc! {"
779            a
780            b
781            ˇc"});
782    }
783
784    #[gpui::test]
785    async fn test_command_replace(cx: &mut TestAppContext) {
786        let mut cx = NeovimBackedTestContext::new(cx).await;
787
788        cx.set_shared_state(indoc! {"
789            ˇa
790            b
791            b
792            c"})
793            .await;
794        cx.simulate_shared_keystrokes(": % s / b / d enter").await;
795        cx.shared_state().await.assert_eq(indoc! {"
796            a
797            d
798            ˇd
799            c"});
800        cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
801            .await;
802        cx.shared_state().await.assert_eq(indoc! {"
803            aa
804            dd
805            dd
806            ˇcc"});
807        cx.simulate_shared_keystrokes("k : s / dd / ee enter").await;
808        cx.shared_state().await.assert_eq(indoc! {"
809            aa
810            dd
811            ˇee
812            cc"});
813    }
814
815    #[gpui::test]
816    async fn test_command_search(cx: &mut TestAppContext) {
817        let mut cx = NeovimBackedTestContext::new(cx).await;
818
819        cx.set_shared_state(indoc! {"
820                ˇa
821                b
822                a
823                c"})
824            .await;
825        cx.simulate_shared_keystrokes(": / b enter").await;
826        cx.shared_state().await.assert_eq(indoc! {"
827                a
828                ˇb
829                a
830                c"});
831        cx.simulate_shared_keystrokes(": ? a enter").await;
832        cx.shared_state().await.assert_eq(indoc! {"
833                ˇa
834                b
835                a
836                c"});
837    }
838
839    #[gpui::test]
840    async fn test_command_write(cx: &mut TestAppContext) {
841        let mut cx = VimTestContext::new(cx, true).await;
842        let path = Path::new("/root/dir/file.rs");
843        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
844
845        cx.simulate_keystrokes("i @ escape");
846        cx.simulate_keystrokes(": w enter");
847
848        assert_eq!(fs.load(path).await.unwrap(), "@\n");
849
850        fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
851
852        // conflict!
853        cx.simulate_keystrokes("i @ escape");
854        cx.simulate_keystrokes(": w enter");
855        assert!(cx.has_pending_prompt());
856        // "Cancel"
857        cx.simulate_prompt_answer(0);
858        assert_eq!(fs.load(path).await.unwrap(), "oops\n");
859        assert!(!cx.has_pending_prompt());
860        // force overwrite
861        cx.simulate_keystrokes(": w ! enter");
862        assert!(!cx.has_pending_prompt());
863        assert_eq!(fs.load(path).await.unwrap(), "@@\n");
864    }
865
866    #[gpui::test]
867    async fn test_command_quit(cx: &mut TestAppContext) {
868        let mut cx = VimTestContext::new(cx, true).await;
869
870        cx.simulate_keystrokes(": n e w enter");
871        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
872        cx.simulate_keystrokes(": q enter");
873        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 1));
874        cx.simulate_keystrokes(": n e w enter");
875        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
876        cx.simulate_keystrokes(": q a enter");
877        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 0));
878    }
879
880    #[gpui::test]
881    async fn test_offsets(cx: &mut TestAppContext) {
882        let mut cx = NeovimBackedTestContext::new(cx).await;
883
884        cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
885            .await;
886
887        cx.simulate_shared_keystrokes(": + enter").await;
888        cx.shared_state()
889            .await
890            .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
891
892        cx.simulate_shared_keystrokes(": 1 0 - enter").await;
893        cx.shared_state()
894            .await
895            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
896
897        cx.simulate_shared_keystrokes(": . - 2 enter").await;
898        cx.shared_state()
899            .await
900            .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
901
902        cx.simulate_shared_keystrokes(": % enter").await;
903        cx.shared_state()
904            .await
905            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
906    }
907
908    #[gpui::test]
909    async fn test_command_ranges(cx: &mut TestAppContext) {
910        let mut cx = NeovimBackedTestContext::new(cx).await;
911
912        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
913
914        cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
915        cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
916
917        cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
918        cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
919
920        cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
921        cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
922    }
923
924    #[gpui::test]
925    async fn test_command_visual_replace(cx: &mut TestAppContext) {
926        let mut cx = NeovimBackedTestContext::new(cx).await;
927
928        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
929
930        cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
931            .await;
932        cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
933    }
934
935    fn assert_active_item(
936        workspace: &mut Workspace,
937        expected_path: &str,
938        expected_text: &str,
939        cx: &mut ViewContext<Workspace>,
940    ) {
941        let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
942
943        let buffer = active_editor
944            .read(cx)
945            .buffer()
946            .read(cx)
947            .as_singleton()
948            .unwrap();
949
950        let text = buffer.read(cx).text();
951        let file = buffer.read(cx).file().unwrap();
952        let file_path = file.as_local().unwrap().abs_path(cx);
953
954        assert_eq!(text, expected_text);
955        assert_eq!(file_path.to_str().unwrap(), expected_path);
956    }
957
958    #[gpui::test]
959    async fn test_command_gf(cx: &mut TestAppContext) {
960        let mut cx = VimTestContext::new(cx, true).await;
961
962        // Assert base state, that we're in /root/dir/file.rs
963        cx.workspace(|workspace, cx| {
964            assert_active_item(workspace, "/root/dir/file.rs", "", cx);
965        });
966
967        // Insert a new file
968        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
969        fs.as_fake()
970            .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
971            .await;
972
973        // Put the path to the second file into the currently open buffer
974        cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
975
976        // Go to file2.rs
977        cx.simulate_keystrokes("g f");
978
979        // We now have two items
980        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
981        cx.workspace(|workspace, cx| {
982            assert_active_item(workspace, "/root/dir/file2.rs", "This is file2.rs", cx);
983        });
984    }
985}