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}