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