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