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