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