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