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