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}