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