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