1use anyhow::{Result, anyhow};
2use collections::{HashMap, HashSet};
3use command_palette_hooks::CommandInterceptResult;
4use editor::{
5 Bias, Editor, SelectionEffects, ToPoint,
6 actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
7 display_map::ToDisplayPoint,
8};
9use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Task, Window, actions};
10use itertools::Itertools;
11use language::Point;
12use multi_buffer::MultiBufferRow;
13use project::ProjectPath;
14use regex::Regex;
15use schemars::JsonSchema;
16use search::{BufferSearchBar, SearchOptions};
17use serde::Deserialize;
18use std::{
19 io::Write,
20 iter::Peekable,
21 ops::{Deref, Range},
22 path::Path,
23 process::Stdio,
24 str::Chars,
25 sync::OnceLock,
26 time::Instant,
27};
28use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
29use ui::ActiveTheme;
30use util::{ResultExt, rel_path::RelPath};
31use workspace::{Item, SaveIntent, notifications::NotifyResultExt};
32use workspace::{SplitDirection, notifications::DetachAndPromptErr};
33use zed_actions::{OpenDocs, RevealTarget};
34
35use crate::{
36 ToggleMarksView, ToggleRegistersView, Vim,
37 motion::{EndOfDocument, Motion, MotionKind, StartOfDocument},
38 normal::{
39 JoinLines,
40 search::{FindCommand, ReplaceCommand, Replacement},
41 },
42 object::Object,
43 state::{Mark, Mode},
44 visual::VisualDeleteLine,
45};
46
47/// Goes to the specified line number in the editor.
48#[derive(Clone, Debug, PartialEq, Action)]
49#[action(namespace = vim, no_json, no_register)]
50pub struct GoToLine {
51 range: CommandRange,
52}
53
54/// Yanks (copies) text based on the specified range.
55#[derive(Clone, Debug, PartialEq, Action)]
56#[action(namespace = vim, no_json, no_register)]
57pub struct YankCommand {
58 range: CommandRange,
59}
60
61/// Executes a command with the specified range.
62#[derive(Clone, Debug, PartialEq, Action)]
63#[action(namespace = vim, no_json, no_register)]
64pub struct WithRange {
65 restore_selection: bool,
66 range: CommandRange,
67 action: WrappedAction,
68}
69
70/// Executes a command with the specified count.
71#[derive(Clone, Debug, PartialEq, Action)]
72#[action(namespace = vim, no_json, no_register)]
73pub struct WithCount {
74 count: u32,
75 action: WrappedAction,
76}
77
78#[derive(Clone, Deserialize, JsonSchema, PartialEq)]
79pub enum VimOption {
80 Wrap(bool),
81 Number(bool),
82 RelativeNumber(bool),
83}
84
85impl VimOption {
86 fn possible_commands(query: &str) -> Vec<CommandInterceptResult> {
87 let mut prefix_of_options = Vec::new();
88 let mut options = query.split(" ").collect::<Vec<_>>();
89 let prefix = options.pop().unwrap_or_default();
90 for option in options {
91 if let Some(opt) = Self::from(option) {
92 prefix_of_options.push(opt)
93 } else {
94 return vec![];
95 }
96 }
97
98 Self::possibilities(prefix)
99 .map(|possible| {
100 let mut options = prefix_of_options.clone();
101 options.push(possible);
102
103 CommandInterceptResult {
104 string: format!(
105 ":set {}",
106 options.iter().map(|opt| opt.to_string()).join(" ")
107 ),
108 action: VimSet { options }.boxed_clone(),
109 positions: vec![],
110 }
111 })
112 .collect()
113 }
114
115 fn possibilities(query: &str) -> impl Iterator<Item = Self> + '_ {
116 [
117 (None, VimOption::Wrap(true)),
118 (None, VimOption::Wrap(false)),
119 (None, VimOption::Number(true)),
120 (None, VimOption::Number(false)),
121 (None, VimOption::RelativeNumber(true)),
122 (None, VimOption::RelativeNumber(false)),
123 (Some("rnu"), VimOption::RelativeNumber(true)),
124 (Some("nornu"), VimOption::RelativeNumber(false)),
125 ]
126 .into_iter()
127 .filter(move |(prefix, option)| prefix.unwrap_or(option.to_string()).starts_with(query))
128 .map(|(_, option)| option)
129 }
130
131 fn from(option: &str) -> Option<Self> {
132 match option {
133 "wrap" => Some(Self::Wrap(true)),
134 "nowrap" => Some(Self::Wrap(false)),
135
136 "number" => Some(Self::Number(true)),
137 "nu" => Some(Self::Number(true)),
138 "nonumber" => Some(Self::Number(false)),
139 "nonu" => Some(Self::Number(false)),
140
141 "relativenumber" => Some(Self::RelativeNumber(true)),
142 "rnu" => Some(Self::RelativeNumber(true)),
143 "norelativenumber" => Some(Self::RelativeNumber(false)),
144 "nornu" => Some(Self::RelativeNumber(false)),
145
146 _ => None,
147 }
148 }
149
150 fn to_string(&self) -> &'static str {
151 match self {
152 VimOption::Wrap(true) => "wrap",
153 VimOption::Wrap(false) => "nowrap",
154 VimOption::Number(true) => "number",
155 VimOption::Number(false) => "nonumber",
156 VimOption::RelativeNumber(true) => "relativenumber",
157 VimOption::RelativeNumber(false) => "norelativenumber",
158 }
159 }
160}
161
162/// Sets vim options and configuration values.
163#[derive(Clone, PartialEq, Action)]
164#[action(namespace = vim, no_json, no_register)]
165pub struct VimSet {
166 options: Vec<VimOption>,
167}
168
169/// Saves the current file with optional save intent.
170#[derive(Clone, PartialEq, Action)]
171#[action(namespace = vim, no_json, no_register)]
172struct VimSave {
173 pub save_intent: Option<SaveIntent>,
174 pub filename: String,
175}
176
177/// Deletes the specified marks from the editor.
178#[derive(Clone, PartialEq, Action)]
179#[action(namespace = vim, no_json, no_register)]
180struct VimSplit {
181 pub vertical: bool,
182 pub filename: String,
183}
184
185#[derive(Clone, PartialEq, Action)]
186#[action(namespace = vim, no_json, no_register)]
187enum DeleteMarks {
188 Marks(String),
189 AllLocal,
190}
191
192actions!(
193 vim,
194 [
195 /// Executes a command in visual mode.
196 VisualCommand,
197 /// Executes a command with a count prefix.
198 CountCommand,
199 /// Executes a shell command.
200 ShellCommand,
201 /// Indicates that an argument is required for the command.
202 ArgumentRequired
203 ]
204);
205
206/// Opens the specified file for editing.
207#[derive(Clone, PartialEq, Action)]
208#[action(namespace = vim, no_json, no_register)]
209struct VimEdit {
210 pub filename: String,
211}
212
213#[derive(Clone, PartialEq, Action)]
214#[action(namespace = vim, no_json, no_register)]
215struct VimNorm {
216 pub range: Option<CommandRange>,
217 pub command: String,
218}
219
220#[derive(Debug)]
221struct WrappedAction(Box<dyn Action>);
222
223impl PartialEq for WrappedAction {
224 fn eq(&self, other: &Self) -> bool {
225 self.0.partial_eq(&*other.0)
226 }
227}
228
229impl Clone for WrappedAction {
230 fn clone(&self) -> Self {
231 Self(self.0.boxed_clone())
232 }
233}
234
235impl Deref for WrappedAction {
236 type Target = dyn Action;
237 fn deref(&self) -> &dyn Action {
238 &*self.0
239 }
240}
241
242pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
243 // Vim::action(editor, cx, |vim, action: &StartOfLine, window, cx| {
244 Vim::action(editor, cx, |vim, action: &VimSet, _, cx| {
245 for option in action.options.iter() {
246 vim.update_editor(cx, |_, editor, cx| match option {
247 VimOption::Wrap(true) => {
248 editor
249 .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx);
250 }
251 VimOption::Wrap(false) => {
252 editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx);
253 }
254 VimOption::Number(enabled) => {
255 editor.set_show_line_numbers(*enabled, cx);
256 }
257 VimOption::RelativeNumber(enabled) => {
258 editor.set_relative_line_number(Some(*enabled), cx);
259 }
260 });
261 }
262 });
263 Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| {
264 let Some(workspace) = vim.workspace(window) else {
265 return;
266 };
267 workspace.update(cx, |workspace, cx| {
268 command_palette::CommandPalette::toggle(workspace, "'<,'>", window, cx);
269 })
270 });
271
272 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
273 let Some(workspace) = vim.workspace(window) else {
274 return;
275 };
276 workspace.update(cx, |workspace, cx| {
277 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
278 })
279 });
280
281 Vim::action(editor, cx, |_, _: &ArgumentRequired, window, cx| {
282 let _ = window.prompt(
283 gpui::PromptLevel::Critical,
284 "Argument required",
285 None,
286 &["Cancel"],
287 cx,
288 );
289 });
290
291 Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| {
292 let Some(workspace) = vim.workspace(window) else {
293 return;
294 };
295 workspace.update(cx, |workspace, cx| {
296 command_palette::CommandPalette::toggle(workspace, "'<,'>!", window, cx);
297 })
298 });
299
300 Vim::action(editor, cx, |vim, action: &VimSave, window, cx| {
301 vim.update_editor(cx, |_, editor, cx| {
302 let Some(project) = editor.project().cloned() else {
303 return;
304 };
305 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
306 return;
307 };
308 let path_style = worktree.read(cx).path_style();
309 let Ok(project_path) = RelPath::from_std_path(Path::new(&action.filename), path_style)
310 .map(|path| ProjectPath {
311 worktree_id: worktree.read(cx).id(),
312 path,
313 })
314 else {
315 // TODO implement save_as with absolute path
316 Task::ready(Err::<(), _>(anyhow!(
317 "Cannot save buffer with absolute path"
318 )))
319 .detach_and_prompt_err(
320 "Failed to save",
321 window,
322 cx,
323 |_, _, _| None,
324 );
325 return;
326 };
327
328 if project.read(cx).entry_for_path(&project_path, cx).is_some()
329 && action.save_intent != Some(SaveIntent::Overwrite)
330 {
331 let answer = window.prompt(
332 gpui::PromptLevel::Critical,
333 &format!(
334 "{} already exists. Do you want to replace it?",
335 project_path.path.display(path_style)
336 ),
337 Some(
338 "A file or folder with the same name already exists. \
339 Replacing it will overwrite its current contents.",
340 ),
341 &["Replace", "Cancel"],
342 cx,
343 );
344 cx.spawn_in(window, async move |editor, cx| {
345 if answer.await.ok() != Some(0) {
346 return;
347 }
348
349 let _ = editor.update_in(cx, |editor, window, cx| {
350 editor
351 .save_as(project, project_path, window, cx)
352 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
353 });
354 })
355 .detach();
356 } else {
357 editor
358 .save_as(project, project_path, window, cx)
359 .detach_and_prompt_err("Failed to :w", window, cx, |_, _, _| None);
360 }
361 });
362 });
363
364 Vim::action(editor, cx, |vim, action: &VimSplit, window, cx| {
365 let Some(workspace) = vim.workspace(window) else {
366 return;
367 };
368
369 workspace.update(cx, |workspace, cx| {
370 let project = workspace.project().clone();
371 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
372 return;
373 };
374 let path_style = worktree.read(cx).path_style();
375 let Some(path) =
376 RelPath::from_std_path(Path::new(&action.filename), path_style).log_err()
377 else {
378 return;
379 };
380 let project_path = ProjectPath {
381 worktree_id: worktree.read(cx).id(),
382 path,
383 };
384
385 let direction = if action.vertical {
386 SplitDirection::vertical(cx)
387 } else {
388 SplitDirection::horizontal(cx)
389 };
390
391 workspace
392 .split_path_preview(project_path, false, Some(direction), window, cx)
393 .detach_and_log_err(cx);
394 })
395 });
396
397 Vim::action(editor, cx, |vim, action: &DeleteMarks, window, cx| {
398 fn err(s: String, window: &mut Window, cx: &mut Context<Editor>) {
399 let _ = window.prompt(
400 gpui::PromptLevel::Critical,
401 &format!("Invalid argument: {}", s),
402 None,
403 &["Cancel"],
404 cx,
405 );
406 }
407 vim.update_editor(cx, |vim, editor, cx| match action {
408 DeleteMarks::Marks(s) => {
409 if s.starts_with('-') || s.ends_with('-') || s.contains(['\'', '`']) {
410 err(s.clone(), window, cx);
411 return;
412 }
413
414 let to_delete = if s.len() < 3 {
415 Some(s.clone())
416 } else {
417 s.chars()
418 .tuple_windows::<(_, _, _)>()
419 .map(|(a, b, c)| {
420 if b == '-' {
421 if match a {
422 'a'..='z' => a <= c && c <= 'z',
423 'A'..='Z' => a <= c && c <= 'Z',
424 '0'..='9' => a <= c && c <= '9',
425 _ => false,
426 } {
427 Some((a..=c).collect_vec())
428 } else {
429 None
430 }
431 } else if a == '-' {
432 if c == '-' { None } else { Some(vec![c]) }
433 } else if c == '-' {
434 if a == '-' { None } else { Some(vec![a]) }
435 } else {
436 Some(vec![a, b, c])
437 }
438 })
439 .fold_options(HashSet::<char>::default(), |mut set, chars| {
440 set.extend(chars.iter().copied());
441 set
442 })
443 .map(|set| set.iter().collect::<String>())
444 };
445
446 let Some(to_delete) = to_delete else {
447 err(s.clone(), window, cx);
448 return;
449 };
450
451 for c in to_delete.chars().filter(|c| !c.is_whitespace()) {
452 vim.delete_mark(c.to_string(), editor, window, cx);
453 }
454 }
455 DeleteMarks::AllLocal => {
456 for s in 'a'..='z' {
457 vim.delete_mark(s.to_string(), editor, window, cx);
458 }
459 }
460 });
461 });
462
463 Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| {
464 vim.update_editor(cx, |vim, editor, cx| {
465 let Some(workspace) = vim.workspace(window) else {
466 return;
467 };
468 let Some(project) = editor.project().cloned() else {
469 return;
470 };
471 let Some(worktree) = project.read(cx).visible_worktrees(cx).next() else {
472 return;
473 };
474 let path_style = worktree.read(cx).path_style();
475 let Some(path) =
476 RelPath::from_std_path(Path::new(&action.filename), path_style).log_err()
477 else {
478 return;
479 };
480 let project_path = ProjectPath {
481 worktree_id: worktree.read(cx).id(),
482 path,
483 };
484
485 let _ = workspace.update(cx, |workspace, cx| {
486 workspace
487 .open_path(project_path, None, true, window, cx)
488 .detach_and_log_err(cx);
489 });
490 });
491 });
492
493 Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
494 let keystrokes = action
495 .command
496 .chars()
497 .map(|c| Keystroke::parse(&c.to_string()).unwrap())
498 .collect();
499 vim.switch_mode(Mode::Normal, true, window, cx);
500 let initial_selections =
501 vim.update_editor(cx, |_, editor, _| editor.selections.disjoint_anchors_arc());
502 if let Some(range) = &action.range {
503 let result = vim.update_editor(cx, |vim, editor, cx| {
504 let range = range.buffer_range(vim, editor, window, cx)?;
505 editor.change_selections(
506 SelectionEffects::no_scroll().nav_history(false),
507 window,
508 cx,
509 |s| {
510 s.select_ranges(
511 (range.start.0..=range.end.0)
512 .map(|line| Point::new(line, 0)..Point::new(line, 0)),
513 );
514 },
515 );
516 anyhow::Ok(())
517 });
518 if let Some(Err(err)) = result {
519 log::error!("Error selecting range: {}", err);
520 return;
521 }
522 };
523
524 let Some(workspace) = vim.workspace(window) else {
525 return;
526 };
527 let task = workspace.update(cx, |workspace, cx| {
528 workspace.send_keystrokes_impl(keystrokes, window, cx)
529 });
530 let had_range = action.range.is_some();
531
532 cx.spawn_in(window, async move |vim, cx| {
533 task.await;
534 vim.update_in(cx, |vim, window, cx| {
535 vim.update_editor(cx, |_, editor, cx| {
536 if had_range {
537 editor.change_selections(SelectionEffects::default(), window, cx, |s| {
538 s.select_anchor_ranges([s.newest_anchor().range()]);
539 })
540 }
541 });
542 if matches!(vim.mode, Mode::Insert | Mode::Replace) {
543 vim.normal_before(&Default::default(), window, cx);
544 } else {
545 vim.switch_mode(Mode::Normal, true, window, cx);
546 }
547 vim.update_editor(cx, |_, editor, cx| {
548 if let Some(first_sel) = initial_selections
549 && let Some(tx_id) = editor
550 .buffer()
551 .update(cx, |multi, cx| multi.last_transaction_id(cx))
552 {
553 let last_sel = editor.selections.disjoint_anchors_arc();
554 editor.modify_transaction_selection_history(tx_id, |old| {
555 old.0 = first_sel;
556 old.1 = Some(last_sel);
557 });
558 }
559 });
560 })
561 .ok();
562 })
563 .detach();
564 });
565
566 Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
567 let Some(workspace) = vim.workspace(window) else {
568 return;
569 };
570 let count = Vim::take_count(cx).unwrap_or(1);
571 Vim::take_forced_motion(cx);
572 let n = if count > 1 {
573 format!(".,.+{}", count.saturating_sub(1))
574 } else {
575 ".".to_string()
576 };
577 workspace.update(cx, |workspace, cx| {
578 command_palette::CommandPalette::toggle(workspace, &n, window, cx);
579 })
580 });
581
582 Vim::action(editor, cx, |vim, action: &GoToLine, window, cx| {
583 vim.switch_mode(Mode::Normal, false, window, cx);
584 let result = vim.update_editor(cx, |vim, editor, cx| {
585 let snapshot = editor.snapshot(window, cx);
586 let buffer_row = action.range.head().buffer_row(vim, editor, window, cx)?;
587 let current = editor.selections.newest::<Point>(cx);
588 let target = snapshot
589 .buffer_snapshot
590 .clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left);
591 editor.change_selections(Default::default(), window, cx, |s| {
592 s.select_ranges([target..target]);
593 });
594
595 anyhow::Ok(())
596 });
597 if let Some(e @ Err(_)) = result {
598 let Some(workspace) = vim.workspace(window) else {
599 return;
600 };
601 workspace.update(cx, |workspace, cx| {
602 e.notify_err(workspace, cx);
603 });
604 }
605 });
606
607 Vim::action(editor, cx, |vim, action: &YankCommand, window, cx| {
608 vim.update_editor(cx, |vim, editor, cx| {
609 let snapshot = editor.snapshot(window, cx);
610 if let Ok(range) = action.range.buffer_range(vim, editor, window, cx) {
611 let end = if range.end < snapshot.buffer_snapshot.max_row() {
612 Point::new(range.end.0 + 1, 0)
613 } else {
614 snapshot.buffer_snapshot.max_point()
615 };
616 vim.copy_ranges(
617 editor,
618 MotionKind::Linewise,
619 true,
620 vec![Point::new(range.start.0, 0)..end],
621 window,
622 cx,
623 )
624 }
625 });
626 });
627
628 Vim::action(editor, cx, |_, action: &WithCount, window, cx| {
629 for _ in 0..action.count {
630 window.dispatch_action(action.action.boxed_clone(), cx)
631 }
632 });
633
634 Vim::action(editor, cx, |vim, action: &WithRange, window, cx| {
635 let result = vim.update_editor(cx, |vim, editor, cx| {
636 action.range.buffer_range(vim, editor, window, cx)
637 });
638
639 let range = match result {
640 None => return,
641 Some(e @ Err(_)) => {
642 let Some(workspace) = vim.workspace(window) else {
643 return;
644 };
645 workspace.update(cx, |workspace, cx| {
646 e.notify_err(workspace, cx);
647 });
648 return;
649 }
650 Some(Ok(result)) => result,
651 };
652
653 let previous_selections = vim
654 .update_editor(cx, |_, editor, cx| {
655 let selections = action.restore_selection.then(|| {
656 editor
657 .selections
658 .disjoint_anchor_ranges()
659 .collect::<Vec<_>>()
660 });
661 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
662 let end = Point::new(range.end.0, s.buffer().line_len(range.end));
663 s.select_ranges([end..Point::new(range.start.0, 0)]);
664 });
665 selections
666 })
667 .flatten();
668 window.dispatch_action(action.action.boxed_clone(), cx);
669 cx.defer_in(window, move |vim, window, cx| {
670 vim.update_editor(cx, |_, editor, cx| {
671 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
672 if let Some(previous_selections) = previous_selections {
673 s.select_ranges(previous_selections);
674 } else {
675 s.select_ranges([
676 Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
677 ]);
678 }
679 })
680 });
681 });
682 });
683
684 Vim::action(editor, cx, |vim, action: &OnMatchingLines, window, cx| {
685 action.run(vim, window, cx)
686 });
687
688 Vim::action(editor, cx, |vim, action: &ShellExec, window, cx| {
689 action.run(vim, window, cx)
690 })
691}
692
693#[derive(Default)]
694struct VimCommand {
695 prefix: &'static str,
696 suffix: &'static str,
697 action: Option<Box<dyn Action>>,
698 action_name: Option<&'static str>,
699 bang_action: Option<Box<dyn Action>>,
700 args: Option<
701 Box<dyn Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static>,
702 >,
703 range: Option<
704 Box<
705 dyn Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>>
706 + Send
707 + Sync
708 + 'static,
709 >,
710 >,
711 has_count: bool,
712}
713
714impl VimCommand {
715 fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
716 Self {
717 prefix: pattern.0,
718 suffix: pattern.1,
719 action: Some(action.boxed_clone()),
720 ..Default::default()
721 }
722 }
723
724 // from_str is used for actions in other crates.
725 fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
726 Self {
727 prefix: pattern.0,
728 suffix: pattern.1,
729 action_name: Some(action_name),
730 ..Default::default()
731 }
732 }
733
734 fn bang(mut self, bang_action: impl Action) -> Self {
735 self.bang_action = Some(bang_action.boxed_clone());
736 self
737 }
738
739 fn args(
740 mut self,
741 f: impl Fn(Box<dyn Action>, String) -> Option<Box<dyn Action>> + Send + Sync + 'static,
742 ) -> Self {
743 self.args = Some(Box::new(f));
744 self
745 }
746
747 fn range(
748 mut self,
749 f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
750 ) -> Self {
751 self.range = Some(Box::new(f));
752 self
753 }
754
755 fn count(mut self) -> Self {
756 self.has_count = true;
757 self
758 }
759
760 fn parse(
761 &self,
762 query: &str,
763 range: &Option<CommandRange>,
764 cx: &App,
765 ) -> Option<Box<dyn Action>> {
766 let rest = query
767 .to_string()
768 .strip_prefix(self.prefix)?
769 .to_string()
770 .chars()
771 .zip_longest(self.suffix.to_string().chars())
772 .skip_while(|e| e.clone().both().map(|(s, q)| s == q).unwrap_or(false))
773 .filter_map(|e| e.left())
774 .collect::<String>();
775 let has_bang = rest.starts_with('!');
776 let args = if has_bang {
777 rest.strip_prefix('!')?.trim().to_string()
778 } else if rest.is_empty() {
779 "".into()
780 } else {
781 rest.strip_prefix(' ')?.trim().to_string()
782 };
783
784 let action = if has_bang && self.bang_action.is_some() {
785 self.bang_action.as_ref().unwrap().boxed_clone()
786 } else if let Some(action) = self.action.as_ref() {
787 action.boxed_clone()
788 } else if let Some(action_name) = self.action_name {
789 cx.build_action(action_name, None).log_err()?
790 } else {
791 return None;
792 };
793
794 let action = if args.is_empty() {
795 action
796 } else {
797 // if command does not accept args and we have args then we should do no action
798 self.args.as_ref()?(action, args)?
799 };
800
801 if let Some(range) = range {
802 self.range.as_ref().and_then(|f| f(action, range))
803 } else {
804 Some(action)
805 }
806 }
807
808 // TODO: ranges with search queries
809 fn parse_range(query: &str) -> (Option<CommandRange>, String) {
810 let mut chars = query.chars().peekable();
811
812 match chars.peek() {
813 Some('%') => {
814 chars.next();
815 return (
816 Some(CommandRange {
817 start: Position::Line { row: 1, offset: 0 },
818 end: Some(Position::LastLine { offset: 0 }),
819 }),
820 chars.collect(),
821 );
822 }
823 Some('*') => {
824 chars.next();
825 return (
826 Some(CommandRange {
827 start: Position::Mark {
828 name: '<',
829 offset: 0,
830 },
831 end: Some(Position::Mark {
832 name: '>',
833 offset: 0,
834 }),
835 }),
836 chars.collect(),
837 );
838 }
839 _ => {}
840 }
841
842 let start = Self::parse_position(&mut chars);
843
844 match chars.peek() {
845 Some(',' | ';') => {
846 chars.next();
847 (
848 Some(CommandRange {
849 start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
850 end: Self::parse_position(&mut chars),
851 }),
852 chars.collect(),
853 )
854 }
855 _ => (
856 start.map(|start| CommandRange { start, end: None }),
857 chars.collect(),
858 ),
859 }
860 }
861
862 fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
863 match chars.peek()? {
864 '0'..='9' => {
865 let row = Self::parse_u32(chars);
866 Some(Position::Line {
867 row,
868 offset: Self::parse_offset(chars),
869 })
870 }
871 '\'' => {
872 chars.next();
873 let name = chars.next()?;
874 Some(Position::Mark {
875 name,
876 offset: Self::parse_offset(chars),
877 })
878 }
879 '.' => {
880 chars.next();
881 Some(Position::CurrentLine {
882 offset: Self::parse_offset(chars),
883 })
884 }
885 '+' | '-' => Some(Position::CurrentLine {
886 offset: Self::parse_offset(chars),
887 }),
888 '$' => {
889 chars.next();
890 Some(Position::LastLine {
891 offset: Self::parse_offset(chars),
892 })
893 }
894 _ => None,
895 }
896 }
897
898 fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
899 let mut res: i32 = 0;
900 while matches!(chars.peek(), Some('+' | '-')) {
901 let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
902 let amount = if matches!(chars.peek(), Some('0'..='9')) {
903 (Self::parse_u32(chars) as i32).saturating_mul(sign)
904 } else {
905 sign
906 };
907 res = res.saturating_add(amount)
908 }
909 res
910 }
911
912 fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
913 let mut res: u32 = 0;
914 while matches!(chars.peek(), Some('0'..='9')) {
915 res = res
916 .saturating_mul(10)
917 .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
918 }
919 res
920 }
921}
922
923#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)]
924enum Position {
925 Line { row: u32, offset: i32 },
926 Mark { name: char, offset: i32 },
927 LastLine { offset: i32 },
928 CurrentLine { offset: i32 },
929}
930
931impl Position {
932 fn buffer_row(
933 &self,
934 vim: &Vim,
935 editor: &mut Editor,
936 window: &mut Window,
937 cx: &mut App,
938 ) -> Result<MultiBufferRow> {
939 let snapshot = editor.snapshot(window, cx);
940 let target = match self {
941 Position::Line { row, offset } => {
942 if let Some(anchor) = editor.active_excerpt(cx).and_then(|(_, buffer, _)| {
943 editor.buffer().read(cx).buffer_point_to_anchor(
944 &buffer,
945 Point::new(row.saturating_sub(1), 0),
946 cx,
947 )
948 }) {
949 anchor
950 .to_point(&snapshot.buffer_snapshot)
951 .row
952 .saturating_add_signed(*offset)
953 } else {
954 row.saturating_add_signed(offset.saturating_sub(1))
955 }
956 }
957 Position::Mark { name, offset } => {
958 let Some(Mark::Local(anchors)) =
959 vim.get_mark(&name.to_string(), editor, window, cx)
960 else {
961 anyhow::bail!("mark {name} not set");
962 };
963 let Some(mark) = anchors.last() else {
964 anyhow::bail!("mark {name} contains empty anchors");
965 };
966 mark.to_point(&snapshot.buffer_snapshot)
967 .row
968 .saturating_add_signed(*offset)
969 }
970 Position::LastLine { offset } => snapshot
971 .buffer_snapshot
972 .max_row()
973 .0
974 .saturating_add_signed(*offset),
975 Position::CurrentLine { offset } => editor
976 .selections
977 .newest_anchor()
978 .head()
979 .to_point(&snapshot.buffer_snapshot)
980 .row
981 .saturating_add_signed(*offset),
982 };
983
984 Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row()))
985 }
986}
987
988#[derive(Clone, Debug, PartialEq)]
989pub(crate) struct CommandRange {
990 start: Position,
991 end: Option<Position>,
992}
993
994impl CommandRange {
995 fn head(&self) -> &Position {
996 self.end.as_ref().unwrap_or(&self.start)
997 }
998
999 pub(crate) fn buffer_range(
1000 &self,
1001 vim: &Vim,
1002 editor: &mut Editor,
1003 window: &mut Window,
1004 cx: &mut App,
1005 ) -> Result<Range<MultiBufferRow>> {
1006 let start = self.start.buffer_row(vim, editor, window, cx)?;
1007 let end = if let Some(end) = self.end.as_ref() {
1008 end.buffer_row(vim, editor, window, cx)?
1009 } else {
1010 start
1011 };
1012 if end < start {
1013 anyhow::Ok(end..start)
1014 } else {
1015 anyhow::Ok(start..end)
1016 }
1017 }
1018
1019 pub fn as_count(&self) -> Option<u32> {
1020 if let CommandRange {
1021 start: Position::Line { row, offset: 0 },
1022 end: None,
1023 } = &self
1024 {
1025 Some(*row)
1026 } else {
1027 None
1028 }
1029 }
1030}
1031
1032fn generate_commands(_: &App) -> Vec<VimCommand> {
1033 vec![
1034 VimCommand::new(
1035 ("w", "rite"),
1036 workspace::Save {
1037 save_intent: Some(SaveIntent::Save),
1038 },
1039 )
1040 .bang(workspace::Save {
1041 save_intent: Some(SaveIntent::Overwrite),
1042 })
1043 .args(|action, args| {
1044 Some(
1045 VimSave {
1046 save_intent: action
1047 .as_any()
1048 .downcast_ref::<workspace::Save>()
1049 .and_then(|action| action.save_intent),
1050 filename: args,
1051 }
1052 .boxed_clone(),
1053 )
1054 }),
1055 VimCommand::new(
1056 ("q", "uit"),
1057 workspace::CloseActiveItem {
1058 save_intent: Some(SaveIntent::Close),
1059 close_pinned: false,
1060 },
1061 )
1062 .bang(workspace::CloseActiveItem {
1063 save_intent: Some(SaveIntent::Skip),
1064 close_pinned: true,
1065 }),
1066 VimCommand::new(
1067 ("wq", ""),
1068 workspace::CloseActiveItem {
1069 save_intent: Some(SaveIntent::Save),
1070 close_pinned: false,
1071 },
1072 )
1073 .bang(workspace::CloseActiveItem {
1074 save_intent: Some(SaveIntent::Overwrite),
1075 close_pinned: true,
1076 }),
1077 VimCommand::new(
1078 ("x", "it"),
1079 workspace::CloseActiveItem {
1080 save_intent: Some(SaveIntent::SaveAll),
1081 close_pinned: false,
1082 },
1083 )
1084 .bang(workspace::CloseActiveItem {
1085 save_intent: Some(SaveIntent::Overwrite),
1086 close_pinned: true,
1087 }),
1088 VimCommand::new(
1089 ("exi", "t"),
1090 workspace::CloseActiveItem {
1091 save_intent: Some(SaveIntent::SaveAll),
1092 close_pinned: false,
1093 },
1094 )
1095 .bang(workspace::CloseActiveItem {
1096 save_intent: Some(SaveIntent::Overwrite),
1097 close_pinned: true,
1098 }),
1099 VimCommand::new(
1100 ("up", "date"),
1101 workspace::Save {
1102 save_intent: Some(SaveIntent::SaveAll),
1103 },
1104 ),
1105 VimCommand::new(
1106 ("wa", "ll"),
1107 workspace::SaveAll {
1108 save_intent: Some(SaveIntent::SaveAll),
1109 },
1110 )
1111 .bang(workspace::SaveAll {
1112 save_intent: Some(SaveIntent::Overwrite),
1113 }),
1114 VimCommand::new(
1115 ("qa", "ll"),
1116 workspace::CloseAllItemsAndPanes {
1117 save_intent: Some(SaveIntent::Close),
1118 },
1119 )
1120 .bang(workspace::CloseAllItemsAndPanes {
1121 save_intent: Some(SaveIntent::Skip),
1122 }),
1123 VimCommand::new(
1124 ("quita", "ll"),
1125 workspace::CloseAllItemsAndPanes {
1126 save_intent: Some(SaveIntent::Close),
1127 },
1128 )
1129 .bang(workspace::CloseAllItemsAndPanes {
1130 save_intent: Some(SaveIntent::Skip),
1131 }),
1132 VimCommand::new(
1133 ("xa", "ll"),
1134 workspace::CloseAllItemsAndPanes {
1135 save_intent: Some(SaveIntent::SaveAll),
1136 },
1137 )
1138 .bang(workspace::CloseAllItemsAndPanes {
1139 save_intent: Some(SaveIntent::Overwrite),
1140 }),
1141 VimCommand::new(
1142 ("wqa", "ll"),
1143 workspace::CloseAllItemsAndPanes {
1144 save_intent: Some(SaveIntent::SaveAll),
1145 },
1146 )
1147 .bang(workspace::CloseAllItemsAndPanes {
1148 save_intent: Some(SaveIntent::Overwrite),
1149 }),
1150 VimCommand::new(("cq", "uit"), zed_actions::Quit),
1151 VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).args(|_, args| {
1152 Some(
1153 VimSplit {
1154 vertical: false,
1155 filename: args,
1156 }
1157 .boxed_clone(),
1158 )
1159 }),
1160 VimCommand::new(("vs", "plit"), workspace::SplitVertical).args(|_, args| {
1161 Some(
1162 VimSplit {
1163 vertical: true,
1164 filename: args,
1165 }
1166 .boxed_clone(),
1167 )
1168 }),
1169 VimCommand::new(
1170 ("bd", "elete"),
1171 workspace::CloseActiveItem {
1172 save_intent: Some(SaveIntent::Close),
1173 close_pinned: false,
1174 },
1175 )
1176 .bang(workspace::CloseActiveItem {
1177 save_intent: Some(SaveIntent::Skip),
1178 close_pinned: true,
1179 }),
1180 VimCommand::new(
1181 ("norm", "al"),
1182 VimNorm {
1183 command: "".into(),
1184 range: None,
1185 },
1186 )
1187 .args(|_, args| {
1188 Some(
1189 VimNorm {
1190 command: args,
1191 range: None,
1192 }
1193 .boxed_clone(),
1194 )
1195 })
1196 .range(|action, range| {
1197 let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
1198 action.range.replace(range.clone());
1199 Some(Box::new(action))
1200 }),
1201 VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
1202 VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
1203 VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
1204 VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
1205 VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
1206 VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
1207 VimCommand::str(("buffers", ""), "tab_switcher::ToggleAll"),
1208 VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
1209 VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
1210 VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
1211 VimCommand::new(("tabe", "dit"), workspace::NewFile)
1212 .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())),
1213 VimCommand::new(("tabnew", ""), workspace::NewFile)
1214 .args(|_action, args| Some(VimEdit { filename: args }.boxed_clone())),
1215 VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
1216 VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
1217 VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
1218 VimCommand::new(
1219 ("tabc", "lose"),
1220 workspace::CloseActiveItem {
1221 save_intent: Some(SaveIntent::Close),
1222 close_pinned: false,
1223 },
1224 ),
1225 VimCommand::new(
1226 ("tabo", "nly"),
1227 workspace::CloseOtherItems {
1228 save_intent: Some(SaveIntent::Close),
1229 close_pinned: false,
1230 },
1231 )
1232 .bang(workspace::CloseOtherItems {
1233 save_intent: Some(SaveIntent::Skip),
1234 close_pinned: false,
1235 }),
1236 VimCommand::new(
1237 ("on", "ly"),
1238 workspace::CloseInactiveTabsAndPanes {
1239 save_intent: Some(SaveIntent::Close),
1240 },
1241 )
1242 .bang(workspace::CloseInactiveTabsAndPanes {
1243 save_intent: Some(SaveIntent::Skip),
1244 }),
1245 VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
1246 VimCommand::new(("cc", ""), editor::actions::Hover),
1247 VimCommand::new(("ll", ""), editor::actions::Hover),
1248 VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic::default())
1249 .range(wrap_count),
1250 VimCommand::new(
1251 ("cp", "revious"),
1252 editor::actions::GoToPreviousDiagnostic::default(),
1253 )
1254 .range(wrap_count),
1255 VimCommand::new(
1256 ("cN", "ext"),
1257 editor::actions::GoToPreviousDiagnostic::default(),
1258 )
1259 .range(wrap_count),
1260 VimCommand::new(
1261 ("lp", "revious"),
1262 editor::actions::GoToPreviousDiagnostic::default(),
1263 )
1264 .range(wrap_count),
1265 VimCommand::new(
1266 ("lN", "ext"),
1267 editor::actions::GoToPreviousDiagnostic::default(),
1268 )
1269 .range(wrap_count),
1270 VimCommand::new(("j", "oin"), JoinLines).range(select_range),
1271 VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
1272 VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
1273 .bang(editor::actions::UnfoldRecursive)
1274 .range(act_on_range),
1275 VimCommand::new(("foldc", "lose"), editor::actions::Fold)
1276 .bang(editor::actions::FoldRecursive)
1277 .range(act_on_range),
1278 VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
1279 .range(act_on_range),
1280 VimCommand::str(("rev", "ert"), "git::Restore").range(act_on_range),
1281 VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
1282 VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
1283 Some(
1284 YankCommand {
1285 range: range.clone(),
1286 }
1287 .boxed_clone(),
1288 )
1289 }),
1290 VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
1291 VimCommand::new(("di", "splay"), ToggleRegistersView).bang(ToggleRegistersView),
1292 VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
1293 VimCommand::new(("delm", "arks"), ArgumentRequired)
1294 .bang(DeleteMarks::AllLocal)
1295 .args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())),
1296 VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
1297 VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
1298 VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
1299 VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
1300 VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
1301 VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
1302 VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
1303 VimCommand::str(("te", "rm"), "terminal_panel::Toggle"),
1304 VimCommand::str(("T", "erm"), "terminal_panel::Toggle"),
1305 VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
1306 VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
1307 VimCommand::str(("A", "I"), "agent::ToggleFocus"),
1308 VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
1309 VimCommand::str(("D", "ebug"), "debug_panel::ToggleFocus"),
1310 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
1311 VimCommand::new(("$", ""), EndOfDocument),
1312 VimCommand::new(("%", ""), EndOfDocument),
1313 VimCommand::new(("0", ""), StartOfDocument),
1314 VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
1315 .bang(editor::actions::ReloadFile)
1316 .args(|_, args| Some(VimEdit { filename: args }.boxed_clone())),
1317 VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
1318 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
1319 VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
1320 VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
1321 VimCommand::new(("h", "elp"), OpenDocs),
1322 ]
1323}
1324
1325struct VimCommands(Vec<VimCommand>);
1326// safety: we only ever access this from the main thread (as ensured by the cx argument)
1327// actions are not Sync so we can't otherwise use a OnceLock.
1328unsafe impl Sync for VimCommands {}
1329impl Global for VimCommands {}
1330
1331fn commands(cx: &App) -> &Vec<VimCommand> {
1332 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
1333 &COMMANDS
1334 .get_or_init(|| VimCommands(generate_commands(cx)))
1335 .0
1336}
1337
1338fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1339 Some(
1340 WithRange {
1341 restore_selection: true,
1342 range: range.clone(),
1343 action: WrappedAction(action),
1344 }
1345 .boxed_clone(),
1346 )
1347}
1348
1349fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1350 Some(
1351 WithRange {
1352 restore_selection: false,
1353 range: range.clone(),
1354 action: WrappedAction(action),
1355 }
1356 .boxed_clone(),
1357 )
1358}
1359
1360fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1361 range.as_count().map(|count| {
1362 WithCount {
1363 count,
1364 action: WrappedAction(action),
1365 }
1366 .boxed_clone()
1367 })
1368}
1369
1370pub fn command_interceptor(mut input: &str, cx: &App) -> Vec<CommandInterceptResult> {
1371 // NOTE: We also need to support passing arguments to commands like :w
1372 // (ideally with filename autocompletion).
1373 while input.starts_with(':') {
1374 input = &input[1..];
1375 }
1376
1377 let (range, query) = VimCommand::parse_range(input);
1378 let range_prefix = input[0..(input.len() - query.len())].to_string();
1379 let query = query.as_str().trim();
1380
1381 let action = if range.is_some() && query.is_empty() {
1382 Some(
1383 GoToLine {
1384 range: range.clone().unwrap(),
1385 }
1386 .boxed_clone(),
1387 )
1388 } else if query.starts_with('/') || query.starts_with('?') {
1389 Some(
1390 FindCommand {
1391 query: query[1..].to_string(),
1392 backwards: query.starts_with('?'),
1393 }
1394 .boxed_clone(),
1395 )
1396 } else if query.starts_with("se ") || query.starts_with("set ") {
1397 let (prefix, option) = query.split_once(' ').unwrap();
1398 let mut commands = VimOption::possible_commands(option);
1399 if !commands.is_empty() {
1400 let query = prefix.to_string() + " " + option;
1401 for command in &mut commands {
1402 command.positions = generate_positions(&command.string, &query);
1403 }
1404 }
1405 return commands;
1406 } else if query.starts_with('s') {
1407 let mut substitute = "substitute".chars().peekable();
1408 let mut query = query.chars().peekable();
1409 while substitute
1410 .peek()
1411 .is_some_and(|char| Some(char) == query.peek())
1412 {
1413 substitute.next();
1414 query.next();
1415 }
1416 if let Some(replacement) = Replacement::parse(query) {
1417 let range = range.clone().unwrap_or(CommandRange {
1418 start: Position::CurrentLine { offset: 0 },
1419 end: None,
1420 });
1421 Some(ReplaceCommand { replacement, range }.boxed_clone())
1422 } else {
1423 None
1424 }
1425 } else if query.starts_with('g') || query.starts_with('v') {
1426 let mut global = "global".chars().peekable();
1427 let mut query = query.chars().peekable();
1428 let mut invert = false;
1429 if query.peek() == Some(&'v') {
1430 invert = true;
1431 query.next();
1432 }
1433 while global.peek().is_some_and(|char| Some(char) == query.peek()) {
1434 global.next();
1435 query.next();
1436 }
1437 if !invert && query.peek() == Some(&'!') {
1438 invert = true;
1439 query.next();
1440 }
1441 let range = range.clone().unwrap_or(CommandRange {
1442 start: Position::Line { row: 0, offset: 0 },
1443 end: Some(Position::LastLine { offset: 0 }),
1444 });
1445 OnMatchingLines::parse(query, invert, range, cx).map(|action| action.boxed_clone())
1446 } else if query.contains('!') {
1447 ShellExec::parse(query, range.clone())
1448 } else {
1449 None
1450 };
1451 if let Some(action) = action {
1452 let string = input.to_string();
1453 let positions = generate_positions(&string, &(range_prefix + query));
1454 return vec![CommandInterceptResult {
1455 action,
1456 string,
1457 positions,
1458 }];
1459 }
1460
1461 for command in commands(cx).iter() {
1462 if let Some(action) = command.parse(query, &range, cx) {
1463 let mut string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
1464 if query.contains('!') {
1465 string.push('!');
1466 }
1467 let positions = generate_positions(&string, &(range_prefix + query));
1468
1469 return vec![CommandInterceptResult {
1470 action,
1471 string,
1472 positions,
1473 }];
1474 }
1475 }
1476 Vec::default()
1477}
1478
1479fn generate_positions(string: &str, query: &str) -> Vec<usize> {
1480 let mut positions = Vec::new();
1481 let mut chars = query.chars();
1482
1483 let Some(mut current) = chars.next() else {
1484 return positions;
1485 };
1486
1487 for (i, c) in string.char_indices() {
1488 if c == current {
1489 positions.push(i);
1490 if let Some(c) = chars.next() {
1491 current = c;
1492 } else {
1493 break;
1494 }
1495 }
1496 }
1497
1498 positions
1499}
1500
1501/// Applies a command to all lines matching a pattern.
1502#[derive(Debug, PartialEq, Clone, Action)]
1503#[action(namespace = vim, no_json, no_register)]
1504pub(crate) struct OnMatchingLines {
1505 range: CommandRange,
1506 search: String,
1507 action: WrappedAction,
1508 invert: bool,
1509}
1510
1511impl OnMatchingLines {
1512 // convert a vim query into something more usable by zed.
1513 // we don't attempt to fully convert between the two regex syntaxes,
1514 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
1515 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
1516 pub(crate) fn parse(
1517 mut chars: Peekable<Chars>,
1518 invert: bool,
1519 range: CommandRange,
1520 cx: &App,
1521 ) -> Option<Self> {
1522 let delimiter = chars.next().filter(|c| {
1523 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
1524 })?;
1525
1526 let mut search = String::new();
1527 let mut escaped = false;
1528
1529 for c in chars.by_ref() {
1530 if escaped {
1531 escaped = false;
1532 // unescape escaped parens
1533 if c != '(' && c != ')' && c != delimiter {
1534 search.push('\\')
1535 }
1536 search.push(c)
1537 } else if c == '\\' {
1538 escaped = true;
1539 } else if c == delimiter {
1540 break;
1541 } else {
1542 // escape unescaped parens
1543 if c == '(' || c == ')' {
1544 search.push('\\')
1545 }
1546 search.push(c)
1547 }
1548 }
1549
1550 let command: String = chars.collect();
1551
1552 let action = WrappedAction(
1553 command_interceptor(&command, cx)
1554 .first()?
1555 .action
1556 .boxed_clone(),
1557 );
1558
1559 Some(Self {
1560 range,
1561 search,
1562 invert,
1563 action,
1564 })
1565 }
1566
1567 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
1568 let result = vim.update_editor(cx, |vim, editor, cx| {
1569 self.range.buffer_range(vim, editor, window, cx)
1570 });
1571
1572 let range = match result {
1573 None => return,
1574 Some(e @ Err(_)) => {
1575 let Some(workspace) = vim.workspace(window) else {
1576 return;
1577 };
1578 workspace.update(cx, |workspace, cx| {
1579 e.notify_err(workspace, cx);
1580 });
1581 return;
1582 }
1583 Some(Ok(result)) => result,
1584 };
1585
1586 let mut action = self.action.boxed_clone();
1587 let mut last_pattern = self.search.clone();
1588
1589 let mut regexes = match Regex::new(&self.search) {
1590 Ok(regex) => vec![(regex, !self.invert)],
1591 e @ Err(_) => {
1592 let Some(workspace) = vim.workspace(window) else {
1593 return;
1594 };
1595 workspace.update(cx, |workspace, cx| {
1596 e.notify_err(workspace, cx);
1597 });
1598 return;
1599 }
1600 };
1601 while let Some(inner) = action
1602 .boxed_clone()
1603 .as_any()
1604 .downcast_ref::<OnMatchingLines>()
1605 {
1606 let Some(regex) = Regex::new(&inner.search).ok() else {
1607 break;
1608 };
1609 last_pattern = inner.search.clone();
1610 action = inner.action.boxed_clone();
1611 regexes.push((regex, !inner.invert))
1612 }
1613
1614 if let Some(pane) = vim.pane(window, cx) {
1615 pane.update(cx, |pane, cx| {
1616 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
1617 {
1618 search_bar.update(cx, |search_bar, cx| {
1619 if search_bar.show(window, cx) {
1620 let _ = search_bar.search(
1621 &last_pattern,
1622 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
1623 false,
1624 window,
1625 cx,
1626 );
1627 }
1628 });
1629 }
1630 });
1631 };
1632
1633 vim.update_editor(cx, |_, editor, cx| {
1634 let snapshot = editor.snapshot(window, cx);
1635 let mut row = range.start.0;
1636
1637 let point_range = Point::new(range.start.0, 0)
1638 ..snapshot
1639 .buffer_snapshot
1640 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
1641 cx.spawn_in(window, async move |editor, cx| {
1642 let new_selections = cx
1643 .background_spawn(async move {
1644 let mut line = String::new();
1645 let mut new_selections = Vec::new();
1646 let chunks = snapshot
1647 .buffer_snapshot
1648 .text_for_range(point_range)
1649 .chain(["\n"]);
1650
1651 for chunk in chunks {
1652 for (newline_ix, text) in chunk.split('\n').enumerate() {
1653 if newline_ix > 0 {
1654 if regexes.iter().all(|(regex, should_match)| {
1655 regex.is_match(&line) == *should_match
1656 }) {
1657 new_selections
1658 .push(Point::new(row, 0).to_display_point(&snapshot))
1659 }
1660 row += 1;
1661 line.clear();
1662 }
1663 line.push_str(text)
1664 }
1665 }
1666
1667 new_selections
1668 })
1669 .await;
1670
1671 if new_selections.is_empty() {
1672 return;
1673 }
1674 editor
1675 .update_in(cx, |editor, window, cx| {
1676 editor.start_transaction_at(Instant::now(), window, cx);
1677 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1678 s.replace_cursors_with(|_| new_selections);
1679 });
1680 window.dispatch_action(action, cx);
1681 cx.defer_in(window, move |editor, window, cx| {
1682 let newest = editor.selections.newest::<Point>(cx);
1683 editor.change_selections(
1684 SelectionEffects::no_scroll(),
1685 window,
1686 cx,
1687 |s| {
1688 s.select(vec![newest]);
1689 },
1690 );
1691 editor.end_transaction_at(Instant::now(), cx);
1692 })
1693 })
1694 .ok();
1695 })
1696 .detach();
1697 });
1698 }
1699}
1700
1701/// Executes a shell command and returns the output.
1702#[derive(Clone, Debug, PartialEq, Action)]
1703#[action(namespace = vim, no_json, no_register)]
1704pub struct ShellExec {
1705 command: String,
1706 range: Option<CommandRange>,
1707 is_read: bool,
1708}
1709
1710impl Vim {
1711 pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1712 if self.running_command.take().is_some() {
1713 self.update_editor(cx, |_, editor, cx| {
1714 editor.transact(window, cx, |editor, _window, _cx| {
1715 editor.clear_row_highlights::<ShellExec>();
1716 })
1717 });
1718 }
1719 }
1720
1721 fn prepare_shell_command(
1722 &mut self,
1723 command: &str,
1724 _: &mut Window,
1725 cx: &mut Context<Self>,
1726 ) -> String {
1727 let mut ret = String::new();
1728 // N.B. non-standard escaping rules:
1729 // * !echo % => "echo README.md"
1730 // * !echo \% => "echo %"
1731 // * !echo \\% => echo \%
1732 // * !echo \\\% => echo \\%
1733 for c in command.chars() {
1734 if c != '%' && c != '!' {
1735 ret.push(c);
1736 continue;
1737 } else if ret.chars().last() == Some('\\') {
1738 ret.pop();
1739 ret.push(c);
1740 continue;
1741 }
1742 match c {
1743 '%' => {
1744 self.update_editor(cx, |_, editor, cx| {
1745 if let Some((_, buffer, _)) = editor.active_excerpt(cx)
1746 && let Some(file) = buffer.read(cx).file()
1747 && let Some(local) = file.as_local()
1748 {
1749 ret.push_str(&local.path().display(local.path_style(cx)));
1750 }
1751 });
1752 }
1753 '!' => {
1754 if let Some(command) = &self.last_command {
1755 ret.push_str(command)
1756 }
1757 }
1758 _ => {}
1759 }
1760 }
1761 self.last_command = Some(ret.clone());
1762 ret
1763 }
1764
1765 pub fn shell_command_motion(
1766 &mut self,
1767 motion: Motion,
1768 times: Option<usize>,
1769 forced_motion: bool,
1770 window: &mut Window,
1771 cx: &mut Context<Vim>,
1772 ) {
1773 self.stop_recording(cx);
1774 let Some(workspace) = self.workspace(window) else {
1775 return;
1776 };
1777 let command = self.update_editor(cx, |_, editor, cx| {
1778 let snapshot = editor.snapshot(window, cx);
1779 let start = editor.selections.newest_display(cx);
1780 let text_layout_details = editor.text_layout_details(window);
1781 let (mut range, _) = motion
1782 .range(
1783 &snapshot,
1784 start.clone(),
1785 times,
1786 &text_layout_details,
1787 forced_motion,
1788 )
1789 .unwrap_or((start.range(), MotionKind::Exclusive));
1790 if range.start != start.start {
1791 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1792 s.select_ranges([
1793 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1794 ]);
1795 })
1796 }
1797 if range.end.row() > range.start.row() && range.end.column() != 0 {
1798 *range.end.row_mut() -= 1
1799 }
1800 if range.end.row() == range.start.row() {
1801 ".!".to_string()
1802 } else {
1803 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1804 }
1805 });
1806 if let Some(command) = command {
1807 workspace.update(cx, |workspace, cx| {
1808 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
1809 });
1810 }
1811 }
1812
1813 pub fn shell_command_object(
1814 &mut self,
1815 object: Object,
1816 around: bool,
1817 window: &mut Window,
1818 cx: &mut Context<Vim>,
1819 ) {
1820 self.stop_recording(cx);
1821 let Some(workspace) = self.workspace(window) else {
1822 return;
1823 };
1824 let command = self.update_editor(cx, |_, editor, cx| {
1825 let snapshot = editor.snapshot(window, cx);
1826 let start = editor.selections.newest_display(cx);
1827 let range = object
1828 .range(&snapshot, start.clone(), around, None)
1829 .unwrap_or(start.range());
1830 if range.start != start.start {
1831 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
1832 s.select_ranges([
1833 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1834 ]);
1835 })
1836 }
1837 if range.end.row() == range.start.row() {
1838 ".!".to_string()
1839 } else {
1840 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1841 }
1842 });
1843 if let Some(command) = command {
1844 workspace.update(cx, |workspace, cx| {
1845 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
1846 });
1847 }
1848 }
1849}
1850
1851impl ShellExec {
1852 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
1853 let (before, after) = query.split_once('!')?;
1854 let before = before.trim();
1855
1856 if !"read".starts_with(before) {
1857 return None;
1858 }
1859
1860 Some(
1861 ShellExec {
1862 command: after.trim().to_string(),
1863 range,
1864 is_read: !before.is_empty(),
1865 }
1866 .boxed_clone(),
1867 )
1868 }
1869
1870 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
1871 let Some(workspace) = vim.workspace(window) else {
1872 return;
1873 };
1874
1875 let project = workspace.read(cx).project().clone();
1876 let command = vim.prepare_shell_command(&self.command, window, cx);
1877
1878 if self.range.is_none() && !self.is_read {
1879 workspace.update(cx, |workspace, cx| {
1880 let project = workspace.project().read(cx);
1881 let cwd = project.first_project_directory(cx);
1882 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1883
1884 let spawn_in_terminal = SpawnInTerminal {
1885 id: TaskId("vim".to_string()),
1886 full_label: command.clone(),
1887 label: command.clone(),
1888 command: Some(command.clone()),
1889 args: Vec::new(),
1890 command_label: command.clone(),
1891 cwd,
1892 env: HashMap::default(),
1893 use_new_terminal: true,
1894 allow_concurrent_runs: true,
1895 reveal: RevealStrategy::NoFocus,
1896 reveal_target: RevealTarget::Dock,
1897 hide: HideStrategy::Never,
1898 shell,
1899 show_summary: false,
1900 show_command: false,
1901 show_rerun: false,
1902 };
1903
1904 let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
1905 cx.background_spawn(async move {
1906 match task_status.await {
1907 Some(Ok(status)) => {
1908 if status.success() {
1909 log::debug!("Vim shell exec succeeded");
1910 } else {
1911 log::debug!("Vim shell exec failed, code: {:?}", status.code());
1912 }
1913 }
1914 Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
1915 None => log::debug!("Vim shell exec got cancelled"),
1916 }
1917 })
1918 .detach();
1919 });
1920 return;
1921 };
1922
1923 let mut input_snapshot = None;
1924 let mut input_range = None;
1925 let mut needs_newline_prefix = false;
1926 vim.update_editor(cx, |vim, editor, cx| {
1927 let snapshot = editor.buffer().read(cx).snapshot(cx);
1928 let range = if let Some(range) = self.range.clone() {
1929 let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
1930 return;
1931 };
1932 Point::new(range.start.0, 0)
1933 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
1934 } else {
1935 let mut end = editor.selections.newest::<Point>(cx).range().end;
1936 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
1937 needs_newline_prefix = end == snapshot.max_point();
1938 end..end
1939 };
1940 if self.is_read {
1941 input_range =
1942 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
1943 } else {
1944 input_range =
1945 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
1946 }
1947 editor.highlight_rows::<ShellExec>(
1948 input_range.clone().unwrap(),
1949 cx.theme().status().unreachable_background,
1950 Default::default(),
1951 cx,
1952 );
1953
1954 if !self.is_read {
1955 input_snapshot = Some(snapshot)
1956 }
1957 });
1958
1959 let Some(range) = input_range else { return };
1960
1961 let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else {
1962 return;
1963 };
1964 process.stdout(Stdio::piped());
1965 process.stderr(Stdio::piped());
1966
1967 if input_snapshot.is_some() {
1968 process.stdin(Stdio::piped());
1969 } else {
1970 process.stdin(Stdio::null());
1971 };
1972
1973 util::set_pre_exec_to_start_new_session(&mut process);
1974 let is_read = self.is_read;
1975
1976 let task = cx.spawn_in(window, async move |vim, cx| {
1977 let Some(mut running) = process.spawn().log_err() else {
1978 vim.update_in(cx, |vim, window, cx| {
1979 vim.cancel_running_command(window, cx);
1980 })
1981 .log_err();
1982 return;
1983 };
1984
1985 if let Some(mut stdin) = running.stdin.take()
1986 && let Some(snapshot) = input_snapshot
1987 {
1988 let range = range.clone();
1989 cx.background_spawn(async move {
1990 for chunk in snapshot.text_for_range(range) {
1991 if stdin.write_all(chunk.as_bytes()).log_err().is_none() {
1992 return;
1993 }
1994 }
1995 stdin.flush().log_err();
1996 })
1997 .detach();
1998 };
1999
2000 let output = cx
2001 .background_spawn(async move { running.wait_with_output() })
2002 .await;
2003
2004 let Some(output) = output.log_err() else {
2005 vim.update_in(cx, |vim, window, cx| {
2006 vim.cancel_running_command(window, cx);
2007 })
2008 .log_err();
2009 return;
2010 };
2011 let mut text = String::new();
2012 if needs_newline_prefix {
2013 text.push('\n');
2014 }
2015 text.push_str(&String::from_utf8_lossy(&output.stdout));
2016 text.push_str(&String::from_utf8_lossy(&output.stderr));
2017 if !text.is_empty() && text.chars().last() != Some('\n') {
2018 text.push('\n');
2019 }
2020
2021 vim.update_in(cx, |vim, window, cx| {
2022 vim.update_editor(cx, |_, editor, cx| {
2023 editor.transact(window, cx, |editor, window, cx| {
2024 editor.edit([(range.clone(), text)], cx);
2025 let snapshot = editor.buffer().read(cx).snapshot(cx);
2026 editor.change_selections(Default::default(), window, cx, |s| {
2027 let point = if is_read {
2028 let point = range.end.to_point(&snapshot);
2029 Point::new(point.row.saturating_sub(1), 0)
2030 } else {
2031 let point = range.start.to_point(&snapshot);
2032 Point::new(point.row, 0)
2033 };
2034 s.select_ranges([point..point]);
2035 })
2036 })
2037 });
2038 vim.cancel_running_command(window, cx);
2039 })
2040 .log_err();
2041 });
2042 vim.running_command.replace(task);
2043 }
2044}
2045
2046#[cfg(test)]
2047mod test {
2048 use std::path::Path;
2049
2050 use crate::{
2051 VimAddon,
2052 state::Mode,
2053 test::{NeovimBackedTestContext, VimTestContext},
2054 };
2055 use editor::Editor;
2056 use gpui::{Context, TestAppContext};
2057 use indoc::indoc;
2058 use util::path;
2059 use workspace::Workspace;
2060
2061 #[gpui::test]
2062 async fn test_command_basics(cx: &mut TestAppContext) {
2063 let mut cx = NeovimBackedTestContext::new(cx).await;
2064
2065 cx.set_shared_state(indoc! {"
2066 ˇa
2067 b
2068 c"})
2069 .await;
2070
2071 cx.simulate_shared_keystrokes(": j enter").await;
2072
2073 // hack: our cursor positioning after a join command is wrong
2074 cx.simulate_shared_keystrokes("^").await;
2075 cx.shared_state().await.assert_eq(indoc! {
2076 "ˇa b
2077 c"
2078 });
2079 }
2080
2081 #[gpui::test]
2082 async fn test_command_goto(cx: &mut TestAppContext) {
2083 let mut cx = NeovimBackedTestContext::new(cx).await;
2084
2085 cx.set_shared_state(indoc! {"
2086 ˇa
2087 b
2088 c"})
2089 .await;
2090 cx.simulate_shared_keystrokes(": 3 enter").await;
2091 cx.shared_state().await.assert_eq(indoc! {"
2092 a
2093 b
2094 ˇc"});
2095 }
2096
2097 #[gpui::test]
2098 async fn test_command_replace(cx: &mut TestAppContext) {
2099 let mut cx = NeovimBackedTestContext::new(cx).await;
2100
2101 cx.set_shared_state(indoc! {"
2102 ˇa
2103 b
2104 b
2105 c"})
2106 .await;
2107 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
2108 cx.shared_state().await.assert_eq(indoc! {"
2109 a
2110 d
2111 ˇd
2112 c"});
2113 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
2114 .await;
2115 cx.shared_state().await.assert_eq(indoc! {"
2116 aa
2117 dd
2118 dd
2119 ˇcc"});
2120 cx.simulate_shared_keystrokes("k : s / d d / e e enter")
2121 .await;
2122 cx.shared_state().await.assert_eq(indoc! {"
2123 aa
2124 dd
2125 ˇee
2126 cc"});
2127 }
2128
2129 #[gpui::test]
2130 async fn test_command_search(cx: &mut TestAppContext) {
2131 let mut cx = NeovimBackedTestContext::new(cx).await;
2132
2133 cx.set_shared_state(indoc! {"
2134 ˇa
2135 b
2136 a
2137 c"})
2138 .await;
2139 cx.simulate_shared_keystrokes(": / b enter").await;
2140 cx.shared_state().await.assert_eq(indoc! {"
2141 a
2142 ˇb
2143 a
2144 c"});
2145 cx.simulate_shared_keystrokes(": ? a enter").await;
2146 cx.shared_state().await.assert_eq(indoc! {"
2147 ˇa
2148 b
2149 a
2150 c"});
2151 }
2152
2153 #[gpui::test]
2154 async fn test_command_write(cx: &mut TestAppContext) {
2155 let mut cx = VimTestContext::new(cx, true).await;
2156 let path = Path::new(path!("/root/dir/file.rs"));
2157 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2158
2159 cx.simulate_keystrokes("i @ escape");
2160 cx.simulate_keystrokes(": w enter");
2161
2162 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
2163
2164 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
2165
2166 // conflict!
2167 cx.simulate_keystrokes("i @ escape");
2168 cx.simulate_keystrokes(": w enter");
2169 cx.simulate_prompt_answer("Cancel");
2170
2171 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
2172 assert!(!cx.has_pending_prompt());
2173 cx.simulate_keystrokes(": w ! enter");
2174 assert!(!cx.has_pending_prompt());
2175 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
2176 }
2177
2178 #[gpui::test]
2179 async fn test_command_quit(cx: &mut TestAppContext) {
2180 let mut cx = VimTestContext::new(cx, true).await;
2181
2182 cx.simulate_keystrokes(": n e w enter");
2183 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2184 cx.simulate_keystrokes(": q enter");
2185 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2186 cx.simulate_keystrokes(": n e w enter");
2187 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2188 cx.simulate_keystrokes(": q a enter");
2189 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
2190 }
2191
2192 #[gpui::test]
2193 async fn test_offsets(cx: &mut TestAppContext) {
2194 let mut cx = NeovimBackedTestContext::new(cx).await;
2195
2196 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
2197 .await;
2198
2199 cx.simulate_shared_keystrokes(": + enter").await;
2200 cx.shared_state()
2201 .await
2202 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
2203
2204 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
2205 cx.shared_state()
2206 .await
2207 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
2208
2209 cx.simulate_shared_keystrokes(": . - 2 enter").await;
2210 cx.shared_state()
2211 .await
2212 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
2213
2214 cx.simulate_shared_keystrokes(": % enter").await;
2215 cx.shared_state()
2216 .await
2217 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
2218 }
2219
2220 #[gpui::test]
2221 async fn test_command_ranges(cx: &mut TestAppContext) {
2222 let mut cx = NeovimBackedTestContext::new(cx).await;
2223
2224 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2225
2226 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
2227 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
2228
2229 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
2230 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
2231
2232 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
2233 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
2234 }
2235
2236 #[gpui::test]
2237 async fn test_command_visual_replace(cx: &mut TestAppContext) {
2238 let mut cx = NeovimBackedTestContext::new(cx).await;
2239
2240 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2241
2242 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
2243 .await;
2244 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
2245 }
2246
2247 #[track_caller]
2248 fn assert_active_item(
2249 workspace: &mut Workspace,
2250 expected_path: &str,
2251 expected_text: &str,
2252 cx: &mut Context<Workspace>,
2253 ) {
2254 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2255
2256 let buffer = active_editor
2257 .read(cx)
2258 .buffer()
2259 .read(cx)
2260 .as_singleton()
2261 .unwrap();
2262
2263 let text = buffer.read(cx).text();
2264 let file = buffer.read(cx).file().unwrap();
2265 let file_path = file.as_local().unwrap().abs_path(cx);
2266
2267 assert_eq!(text, expected_text);
2268 assert_eq!(file_path, Path::new(expected_path));
2269 }
2270
2271 #[gpui::test]
2272 async fn test_command_gf(cx: &mut TestAppContext) {
2273 let mut cx = VimTestContext::new(cx, true).await;
2274
2275 // Assert base state, that we're in /root/dir/file.rs
2276 cx.workspace(|workspace, _, cx| {
2277 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2278 });
2279
2280 // Insert a new file
2281 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2282 fs.as_fake()
2283 .insert_file(
2284 path!("/root/dir/file2.rs"),
2285 "This is file2.rs".as_bytes().to_vec(),
2286 )
2287 .await;
2288 fs.as_fake()
2289 .insert_file(
2290 path!("/root/dir/file3.rs"),
2291 "go to file3".as_bytes().to_vec(),
2292 )
2293 .await;
2294
2295 // Put the path to the second file into the currently open buffer
2296 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
2297
2298 // Go to file2.rs
2299 cx.simulate_keystrokes("g f");
2300
2301 // We now have two items
2302 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2303 cx.workspace(|workspace, _, cx| {
2304 assert_active_item(
2305 workspace,
2306 path!("/root/dir/file2.rs"),
2307 "This is file2.rs",
2308 cx,
2309 );
2310 });
2311
2312 // Update editor to point to `file2.rs`
2313 cx.editor =
2314 cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
2315
2316 // Put the path to the third file into the currently open buffer,
2317 // but remove its suffix, because we want that lookup to happen automatically.
2318 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
2319
2320 // Go to file3.rs
2321 cx.simulate_keystrokes("g f");
2322
2323 // We now have three items
2324 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2325 cx.workspace(|workspace, _, cx| {
2326 assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
2327 });
2328 }
2329
2330 #[gpui::test]
2331 async fn test_w_command(cx: &mut TestAppContext) {
2332 let mut cx = VimTestContext::new(cx, true).await;
2333
2334 cx.workspace(|workspace, _, cx| {
2335 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2336 });
2337
2338 cx.simulate_keystrokes(": w space other.rs");
2339 cx.simulate_keystrokes("enter");
2340
2341 cx.workspace(|workspace, _, cx| {
2342 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2343 });
2344
2345 cx.simulate_keystrokes(": w space dir/file.rs");
2346 cx.simulate_keystrokes("enter");
2347
2348 cx.simulate_prompt_answer("Replace");
2349 cx.run_until_parked();
2350
2351 cx.workspace(|workspace, _, cx| {
2352 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2353 });
2354
2355 cx.simulate_keystrokes(": w ! space other.rs");
2356 cx.simulate_keystrokes("enter");
2357
2358 cx.workspace(|workspace, _, cx| {
2359 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2360 });
2361 }
2362
2363 #[gpui::test]
2364 async fn test_command_matching_lines(cx: &mut TestAppContext) {
2365 let mut cx = NeovimBackedTestContext::new(cx).await;
2366
2367 cx.set_shared_state(indoc! {"
2368 ˇa
2369 b
2370 a
2371 b
2372 a
2373 "})
2374 .await;
2375
2376 cx.simulate_shared_keystrokes(":").await;
2377 cx.simulate_shared_keystrokes("g / a / d").await;
2378 cx.simulate_shared_keystrokes("enter").await;
2379
2380 cx.shared_state().await.assert_eq(indoc! {"
2381 b
2382 b
2383 ˇ"});
2384
2385 cx.simulate_shared_keystrokes("u").await;
2386
2387 cx.shared_state().await.assert_eq(indoc! {"
2388 ˇa
2389 b
2390 a
2391 b
2392 a
2393 "});
2394
2395 cx.simulate_shared_keystrokes(":").await;
2396 cx.simulate_shared_keystrokes("v / a / d").await;
2397 cx.simulate_shared_keystrokes("enter").await;
2398
2399 cx.shared_state().await.assert_eq(indoc! {"
2400 a
2401 a
2402 ˇa"});
2403 }
2404
2405 #[gpui::test]
2406 async fn test_del_marks(cx: &mut TestAppContext) {
2407 let mut cx = NeovimBackedTestContext::new(cx).await;
2408
2409 cx.set_shared_state(indoc! {"
2410 ˇa
2411 b
2412 a
2413 b
2414 a
2415 "})
2416 .await;
2417
2418 cx.simulate_shared_keystrokes("m a").await;
2419
2420 let mark = cx.update_editor(|editor, window, cx| {
2421 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
2422 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
2423 });
2424 assert!(mark.is_some());
2425
2426 cx.simulate_shared_keystrokes(": d e l m space a").await;
2427 cx.simulate_shared_keystrokes("enter").await;
2428
2429 let mark = cx.update_editor(|editor, window, cx| {
2430 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
2431 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
2432 });
2433 assert!(mark.is_none())
2434 }
2435
2436 #[gpui::test]
2437 async fn test_normal_command(cx: &mut TestAppContext) {
2438 let mut cx = NeovimBackedTestContext::new(cx).await;
2439
2440 cx.set_shared_state(indoc! {"
2441 The quick
2442 brown« fox
2443 jumpsˇ» over
2444 the lazy dog
2445 "})
2446 .await;
2447
2448 cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
2449 .await;
2450 cx.simulate_shared_keystrokes("enter").await;
2451
2452 cx.shared_state().await.assert_eq(indoc! {"
2453 The quick
2454 brown word
2455 jumps worˇd
2456 the lazy dog
2457 "});
2458
2459 cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
2460 .await;
2461 cx.simulate_shared_keystrokes("enter").await;
2462
2463 cx.shared_state().await.assert_eq(indoc! {"
2464 The quick
2465 brown word
2466 jumps tesˇt
2467 the lazy dog
2468 "});
2469
2470 cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
2471 .await;
2472 cx.simulate_shared_keystrokes("enter").await;
2473
2474 cx.shared_state().await.assert_eq(indoc! {"
2475 The quick
2476 brown word
2477 lˇaumps test
2478 the lazy dog
2479 "});
2480
2481 cx.set_shared_state(indoc! {"
2482 ˇThe quick
2483 brown fox
2484 jumps over
2485 the lazy dog
2486 "})
2487 .await;
2488
2489 cx.simulate_shared_keystrokes("c i w M y escape").await;
2490
2491 cx.shared_state().await.assert_eq(indoc! {"
2492 Mˇy quick
2493 brown fox
2494 jumps over
2495 the lazy dog
2496 "});
2497
2498 cx.simulate_shared_keystrokes(": n o r m space u").await;
2499 cx.simulate_shared_keystrokes("enter").await;
2500
2501 cx.shared_state().await.assert_eq(indoc! {"
2502 ˇThe quick
2503 brown fox
2504 jumps over
2505 the lazy dog
2506 "});
2507 // Once ctrl-v to input character literals is added there should be a test for redo
2508 }
2509
2510 #[gpui::test]
2511 async fn test_command_tabnew(cx: &mut TestAppContext) {
2512 let mut cx = VimTestContext::new(cx, true).await;
2513
2514 // Create a new file to ensure that, when the filename is used with
2515 // `:tabnew`, it opens the existing file in a new tab.
2516 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2517 fs.as_fake()
2518 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
2519 .await;
2520
2521 cx.simulate_keystrokes(": tabnew");
2522 cx.simulate_keystrokes("enter");
2523 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2524
2525 // Assert that the new tab is empty and not associated with any file, as
2526 // no file path was provided to the `:tabnew` command.
2527 cx.workspace(|workspace, _window, cx| {
2528 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2529 let buffer = active_editor
2530 .read(cx)
2531 .buffer()
2532 .read(cx)
2533 .as_singleton()
2534 .unwrap();
2535
2536 assert!(&buffer.read(cx).file().is_none());
2537 });
2538
2539 // Leverage the filename as an argument to the `:tabnew` command,
2540 // ensuring that the file, instead of an empty buffer, is opened in a
2541 // new tab.
2542 cx.simulate_keystrokes(": tabnew space dir/file_2.rs");
2543 cx.simulate_keystrokes("enter");
2544
2545 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2546 cx.workspace(|workspace, _, cx| {
2547 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
2548 });
2549
2550 // If the `filename` argument provided to the `:tabnew` command is for a
2551 // file that doesn't yet exist, it should still associate the buffer
2552 // with that file path, so that when the buffer contents are saved, the
2553 // file is created.
2554 cx.simulate_keystrokes(": tabnew space dir/file_3.rs");
2555 cx.simulate_keystrokes("enter");
2556
2557 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
2558 cx.workspace(|workspace, _, cx| {
2559 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
2560 });
2561 }
2562
2563 #[gpui::test]
2564 async fn test_command_tabedit(cx: &mut TestAppContext) {
2565 let mut cx = VimTestContext::new(cx, true).await;
2566
2567 // Create a new file to ensure that, when the filename is used with
2568 // `:tabedit`, it opens the existing file in a new tab.
2569 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2570 fs.as_fake()
2571 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
2572 .await;
2573
2574 cx.simulate_keystrokes(": tabedit");
2575 cx.simulate_keystrokes("enter");
2576 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2577
2578 // Assert that the new tab is empty and not associated with any file, as
2579 // no file path was provided to the `:tabedit` command.
2580 cx.workspace(|workspace, _window, cx| {
2581 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2582 let buffer = active_editor
2583 .read(cx)
2584 .buffer()
2585 .read(cx)
2586 .as_singleton()
2587 .unwrap();
2588
2589 assert!(&buffer.read(cx).file().is_none());
2590 });
2591
2592 // Leverage the filename as an argument to the `:tabedit` command,
2593 // ensuring that the file, instead of an empty buffer, is opened in a
2594 // new tab.
2595 cx.simulate_keystrokes(": tabedit space dir/file_2.rs");
2596 cx.simulate_keystrokes("enter");
2597
2598 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2599 cx.workspace(|workspace, _, cx| {
2600 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
2601 });
2602
2603 // If the `filename` argument provided to the `:tabedit` command is for a
2604 // file that doesn't yet exist, it should still associate the buffer
2605 // with that file path, so that when the buffer contents are saved, the
2606 // file is created.
2607 cx.simulate_keystrokes(": tabedit space dir/file_3.rs");
2608 cx.simulate_keystrokes("enter");
2609
2610 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
2611 cx.workspace(|workspace, _, cx| {
2612 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
2613 });
2614 }
2615}