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