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