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