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).filename(|_, filename| {
1472 Some(
1473 VimSplit {
1474 vertical: false,
1475 filename,
1476 }
1477 .boxed_clone(),
1478 )
1479 }),
1480 VimCommand::new(("vs", "plit"), workspace::SplitVertical).filename(|_, filename| {
1481 Some(
1482 VimSplit {
1483 vertical: true,
1484 filename,
1485 }
1486 .boxed_clone(),
1487 )
1488 }),
1489 VimCommand::new(("tabe", "dit"), workspace::NewFile)
1490 .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
1491 VimCommand::new(("tabnew", ""), workspace::NewFile)
1492 .filename(|_action, filename| Some(VimEdit { filename }.boxed_clone())),
1493 VimCommand::new(
1494 ("q", "uit"),
1495 workspace::CloseActiveItem {
1496 save_intent: Some(SaveIntent::Close),
1497 close_pinned: false,
1498 },
1499 )
1500 .bang(workspace::CloseActiveItem {
1501 save_intent: Some(SaveIntent::Skip),
1502 close_pinned: true,
1503 }),
1504 VimCommand::new(
1505 ("wq", ""),
1506 workspace::CloseActiveItem {
1507 save_intent: Some(SaveIntent::Save),
1508 close_pinned: false,
1509 },
1510 )
1511 .bang(workspace::CloseActiveItem {
1512 save_intent: Some(SaveIntent::Overwrite),
1513 close_pinned: true,
1514 }),
1515 VimCommand::new(
1516 ("x", "it"),
1517 workspace::CloseActiveItem {
1518 save_intent: Some(SaveIntent::SaveAll),
1519 close_pinned: false,
1520 },
1521 )
1522 .bang(workspace::CloseActiveItem {
1523 save_intent: Some(SaveIntent::Overwrite),
1524 close_pinned: true,
1525 }),
1526 VimCommand::new(
1527 ("exi", "t"),
1528 workspace::CloseActiveItem {
1529 save_intent: Some(SaveIntent::SaveAll),
1530 close_pinned: false,
1531 },
1532 )
1533 .bang(workspace::CloseActiveItem {
1534 save_intent: Some(SaveIntent::Overwrite),
1535 close_pinned: true,
1536 }),
1537 VimCommand::new(
1538 ("up", "date"),
1539 workspace::Save {
1540 save_intent: Some(SaveIntent::SaveAll),
1541 },
1542 ),
1543 VimCommand::new(
1544 ("wa", "ll"),
1545 workspace::SaveAll {
1546 save_intent: Some(SaveIntent::SaveAll),
1547 },
1548 )
1549 .bang(workspace::SaveAll {
1550 save_intent: Some(SaveIntent::Overwrite),
1551 }),
1552 VimCommand::new(
1553 ("qa", "ll"),
1554 workspace::CloseAllItemsAndPanes {
1555 save_intent: Some(SaveIntent::Close),
1556 },
1557 )
1558 .bang(workspace::CloseAllItemsAndPanes {
1559 save_intent: Some(SaveIntent::Skip),
1560 }),
1561 VimCommand::new(
1562 ("quita", "ll"),
1563 workspace::CloseAllItemsAndPanes {
1564 save_intent: Some(SaveIntent::Close),
1565 },
1566 )
1567 .bang(workspace::CloseAllItemsAndPanes {
1568 save_intent: Some(SaveIntent::Skip),
1569 }),
1570 VimCommand::new(
1571 ("xa", "ll"),
1572 workspace::CloseAllItemsAndPanes {
1573 save_intent: Some(SaveIntent::SaveAll),
1574 },
1575 )
1576 .bang(workspace::CloseAllItemsAndPanes {
1577 save_intent: Some(SaveIntent::Overwrite),
1578 }),
1579 VimCommand::new(
1580 ("wqa", "ll"),
1581 workspace::CloseAllItemsAndPanes {
1582 save_intent: Some(SaveIntent::SaveAll),
1583 },
1584 )
1585 .bang(workspace::CloseAllItemsAndPanes {
1586 save_intent: Some(SaveIntent::Overwrite),
1587 }),
1588 VimCommand::new(("cq", "uit"), zed_actions::Quit),
1589 VimCommand::new(
1590 ("bd", "elete"),
1591 workspace::CloseActiveItem {
1592 save_intent: Some(SaveIntent::Close),
1593 close_pinned: false,
1594 },
1595 )
1596 .bang(workspace::CloseActiveItem {
1597 save_intent: Some(SaveIntent::Skip),
1598 close_pinned: true,
1599 }),
1600 VimCommand::new(
1601 ("norm", "al"),
1602 VimNorm {
1603 command: "".into(),
1604 range: None,
1605 },
1606 )
1607 .args(|_, args| {
1608 Some(
1609 VimNorm {
1610 command: args,
1611 range: None,
1612 }
1613 .boxed_clone(),
1614 )
1615 })
1616 .range(|action, range| {
1617 let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
1618 action.range.replace(range.clone());
1619 Some(Box::new(action))
1620 }),
1621 VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
1622 VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
1623 VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
1624 VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
1625 VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
1626 VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
1627 VimCommand::str(("buffers", ""), "tab_switcher::ToggleAll"),
1628 VimCommand::str(("ls", ""), "tab_switcher::ToggleAll"),
1629 VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
1630 VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
1631 VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
1632 VimCommand::new(("tabp", "revious"), workspace::ActivatePreviousItem).count(),
1633 VimCommand::new(("tabN", "ext"), workspace::ActivatePreviousItem).count(),
1634 VimCommand::new(
1635 ("tabc", "lose"),
1636 workspace::CloseActiveItem {
1637 save_intent: Some(SaveIntent::Close),
1638 close_pinned: false,
1639 },
1640 ),
1641 VimCommand::new(
1642 ("tabo", "nly"),
1643 workspace::CloseOtherItems {
1644 save_intent: Some(SaveIntent::Close),
1645 close_pinned: false,
1646 },
1647 )
1648 .bang(workspace::CloseOtherItems {
1649 save_intent: Some(SaveIntent::Skip),
1650 close_pinned: false,
1651 }),
1652 VimCommand::new(
1653 ("on", "ly"),
1654 workspace::CloseInactiveTabsAndPanes {
1655 save_intent: Some(SaveIntent::Close),
1656 },
1657 )
1658 .bang(workspace::CloseInactiveTabsAndPanes {
1659 save_intent: Some(SaveIntent::Skip),
1660 }),
1661 VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
1662 VimCommand::new(("cc", ""), editor::actions::Hover),
1663 VimCommand::new(("ll", ""), editor::actions::Hover),
1664 VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic::default())
1665 .range(wrap_count),
1666 VimCommand::new(
1667 ("cp", "revious"),
1668 editor::actions::GoToPreviousDiagnostic::default(),
1669 )
1670 .range(wrap_count),
1671 VimCommand::new(
1672 ("cN", "ext"),
1673 editor::actions::GoToPreviousDiagnostic::default(),
1674 )
1675 .range(wrap_count),
1676 VimCommand::new(
1677 ("lp", "revious"),
1678 editor::actions::GoToPreviousDiagnostic::default(),
1679 )
1680 .range(wrap_count),
1681 VimCommand::new(
1682 ("lN", "ext"),
1683 editor::actions::GoToPreviousDiagnostic::default(),
1684 )
1685 .range(wrap_count),
1686 VimCommand::new(("j", "oin"), JoinLines).range(select_range),
1687 VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
1688 VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
1689 .bang(editor::actions::UnfoldRecursive)
1690 .range(act_on_range),
1691 VimCommand::new(("foldc", "lose"), editor::actions::Fold)
1692 .bang(editor::actions::FoldRecursive)
1693 .range(act_on_range),
1694 VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
1695 .range(act_on_range),
1696 VimCommand::str(("rev", "ert"), "git::Restore").range(act_on_range),
1697 VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
1698 VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
1699 Some(
1700 YankCommand {
1701 range: range.clone(),
1702 }
1703 .boxed_clone(),
1704 )
1705 }),
1706 VimCommand::new(("reg", "isters"), ToggleRegistersView).bang(ToggleRegistersView),
1707 VimCommand::new(("di", "splay"), ToggleRegistersView).bang(ToggleRegistersView),
1708 VimCommand::new(("marks", ""), ToggleMarksView).bang(ToggleMarksView),
1709 VimCommand::new(("delm", "arks"), ArgumentRequired)
1710 .bang(DeleteMarks::AllLocal)
1711 .args(|_, args| Some(DeleteMarks::Marks(args).boxed_clone())),
1712 VimCommand::new(("sor", "t"), SortLinesCaseSensitive)
1713 .range(select_range)
1714 .default_range(CommandRange::buffer()),
1715 VimCommand::new(("sort i", ""), SortLinesCaseInsensitive)
1716 .range(select_range)
1717 .default_range(CommandRange::buffer()),
1718 VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
1719 VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
1720 VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
1721 VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
1722 VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
1723 VimCommand::str(("te", "rm"), "terminal_panel::Toggle"),
1724 VimCommand::str(("T", "erm"), "terminal_panel::Toggle"),
1725 VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
1726 VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
1727 VimCommand::str(("A", "I"), "agent::ToggleFocus"),
1728 VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
1729 VimCommand::str(("D", "ebug"), "debug_panel::ToggleFocus"),
1730 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
1731 VimCommand::new(("$", ""), EndOfDocument),
1732 VimCommand::new(("%", ""), EndOfDocument),
1733 VimCommand::new(("0", ""), StartOfDocument),
1734 VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
1735 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
1736 VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
1737 VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
1738 VimCommand::new(("h", "elp"), OpenDocs),
1739 ]
1740}
1741
1742struct VimCommands(Vec<VimCommand>);
1743// safety: we only ever access this from the main thread (as ensured by the cx argument)
1744// actions are not Sync so we can't otherwise use a OnceLock.
1745unsafe impl Sync for VimCommands {}
1746impl Global for VimCommands {}
1747
1748fn commands(cx: &App) -> &Vec<VimCommand> {
1749 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
1750 &COMMANDS
1751 .get_or_init(|| VimCommands(generate_commands(cx)))
1752 .0
1753}
1754
1755fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1756 Some(
1757 WithRange {
1758 restore_selection: true,
1759 range: range.clone(),
1760 action: WrappedAction(action),
1761 }
1762 .boxed_clone(),
1763 )
1764}
1765
1766fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1767 Some(
1768 WithRange {
1769 restore_selection: false,
1770 range: range.clone(),
1771 action: WrappedAction(action),
1772 }
1773 .boxed_clone(),
1774 )
1775}
1776
1777fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1778 range.as_count().map(|count| {
1779 WithCount {
1780 count,
1781 action: WrappedAction(action),
1782 }
1783 .boxed_clone()
1784 })
1785}
1786
1787pub fn command_interceptor(
1788 mut input: &str,
1789 workspace: WeakEntity<Workspace>,
1790 cx: &mut App,
1791) -> Task<CommandInterceptResult> {
1792 while input.starts_with(':') {
1793 input = &input[1..];
1794 }
1795
1796 let (range, query) = VimCommand::parse_range(input);
1797 let range_prefix = input[0..(input.len() - query.len())].to_string();
1798 let has_trailing_space = query.ends_with(" ");
1799 let mut query = query.as_str().trim();
1800
1801 let on_matching_lines = (query.starts_with('g') || query.starts_with('v'))
1802 .then(|| {
1803 let (pattern, range, search, invert) = OnMatchingLines::parse(query, &range)?;
1804 let start_idx = query.len() - pattern.len();
1805 query = query[start_idx..].trim();
1806 Some((range, search, invert))
1807 })
1808 .flatten();
1809
1810 let mut action = if range.is_some() && query.is_empty() {
1811 Some(
1812 GoToLine {
1813 range: range.clone().unwrap(),
1814 }
1815 .boxed_clone(),
1816 )
1817 } else if query.starts_with('/') || query.starts_with('?') {
1818 Some(
1819 FindCommand {
1820 query: query[1..].to_string(),
1821 backwards: query.starts_with('?'),
1822 }
1823 .boxed_clone(),
1824 )
1825 } else if query.starts_with("se ") || query.starts_with("set ") {
1826 let (prefix, option) = query.split_once(' ').unwrap();
1827 let mut commands = VimOption::possible_commands(option);
1828 if !commands.is_empty() {
1829 let query = prefix.to_string() + " " + option;
1830 for command in &mut commands {
1831 command.positions = generate_positions(&command.string, &query);
1832 }
1833 }
1834 return Task::ready(CommandInterceptResult {
1835 results: commands,
1836 exclusive: false,
1837 });
1838 } else if query.starts_with('s') {
1839 let mut substitute = "substitute".chars().peekable();
1840 let mut query = query.chars().peekable();
1841 while substitute
1842 .peek()
1843 .is_some_and(|char| Some(char) == query.peek())
1844 {
1845 substitute.next();
1846 query.next();
1847 }
1848 if let Some(replacement) = Replacement::parse(query) {
1849 let range = range.clone().unwrap_or(CommandRange {
1850 start: Position::CurrentLine { offset: 0 },
1851 end: None,
1852 });
1853 Some(ReplaceCommand { replacement, range }.boxed_clone())
1854 } else {
1855 None
1856 }
1857 } else if query.contains('!') {
1858 ShellExec::parse(query, range.clone())
1859 } else if on_matching_lines.is_some() {
1860 commands(cx)
1861 .iter()
1862 .find_map(|command| command.parse(query, &range, cx))
1863 } else {
1864 None
1865 };
1866
1867 if let Some((range, search, invert)) = on_matching_lines
1868 && let Some(ref inner) = action
1869 {
1870 action = Some(Box::new(OnMatchingLines {
1871 range,
1872 search,
1873 action: WrappedAction(inner.boxed_clone()),
1874 invert,
1875 }));
1876 };
1877
1878 if let Some(action) = action {
1879 let string = input.to_string();
1880 let positions = generate_positions(&string, &(range_prefix + query));
1881 return Task::ready(CommandInterceptResult {
1882 results: vec![CommandInterceptItem {
1883 action,
1884 string,
1885 positions,
1886 }],
1887 exclusive: false,
1888 });
1889 }
1890
1891 let Some((mut results, filenames)) =
1892 commands(cx).iter().enumerate().find_map(|(idx, command)| {
1893 let action = command.parse(query, &range, cx)?;
1894 let parsed_query = command.get_parsed_query(query.into())?;
1895 let display_string = ":".to_owned()
1896 + &range_prefix
1897 + command.prefix
1898 + command.suffix
1899 + if parsed_query.has_bang { "!" } else { "" };
1900 let space = if parsed_query.has_space { " " } else { "" };
1901
1902 let string = format!("{}{}{}", &display_string, &space, &parsed_query.args);
1903 let positions = generate_positions(&string, &(range_prefix.clone() + query));
1904
1905 let results = vec![CommandInterceptItem {
1906 action,
1907 string,
1908 positions,
1909 }];
1910
1911 let no_args_positions =
1912 generate_positions(&display_string, &(range_prefix.clone() + query));
1913
1914 // The following are valid autocomplete scenarios:
1915 // :w!filename.txt
1916 // :w filename.txt
1917 // :w[space]
1918 if !command.has_filename
1919 || (!has_trailing_space && !parsed_query.has_bang && parsed_query.args.is_empty())
1920 {
1921 return Some((results, None));
1922 }
1923
1924 Some((
1925 results,
1926 Some((idx, parsed_query, display_string, no_args_positions)),
1927 ))
1928 })
1929 else {
1930 return Task::ready(CommandInterceptResult::default());
1931 };
1932
1933 if let Some((cmd_idx, parsed_query, display_string, no_args_positions)) = filenames {
1934 let filenames = VimCommand::generate_filename_completions(&parsed_query, workspace, cx);
1935 cx.spawn(async move |cx| {
1936 let filenames = filenames.await;
1937 const MAX_RESULTS: usize = 100;
1938 let executor = cx.background_executor().clone();
1939 let mut candidates = Vec::with_capacity(filenames.len());
1940
1941 for (idx, filename) in filenames.iter().enumerate() {
1942 candidates.push(fuzzy::StringMatchCandidate::new(idx, &filename));
1943 }
1944 let filenames = fuzzy::match_strings(
1945 &candidates,
1946 &parsed_query.args,
1947 false,
1948 true,
1949 MAX_RESULTS,
1950 &Default::default(),
1951 executor,
1952 )
1953 .await;
1954
1955 for fuzzy::StringMatch {
1956 candidate_id: _,
1957 score: _,
1958 positions,
1959 string,
1960 } in filenames
1961 {
1962 let offset = display_string.len() + 1;
1963 let mut positions: Vec<_> = positions.iter().map(|&pos| pos + offset).collect();
1964 positions.splice(0..0, no_args_positions.clone());
1965 let string = format!("{display_string} {string}");
1966 let (range, query) = VimCommand::parse_range(&string[1..]);
1967 let action =
1968 match cx.update(|cx| commands(cx).get(cmd_idx)?.parse(&query, &range, cx)) {
1969 Ok(Some(action)) => action,
1970 _ => continue,
1971 };
1972 results.push(CommandInterceptItem {
1973 action,
1974 string,
1975 positions,
1976 });
1977 }
1978 CommandInterceptResult {
1979 results,
1980 exclusive: true,
1981 }
1982 })
1983 } else {
1984 Task::ready(CommandInterceptResult {
1985 results,
1986 exclusive: false,
1987 })
1988 }
1989}
1990
1991fn generate_positions(string: &str, query: &str) -> Vec<usize> {
1992 let mut positions = Vec::new();
1993 let mut chars = query.chars();
1994
1995 let Some(mut current) = chars.next() else {
1996 return positions;
1997 };
1998
1999 for (i, c) in string.char_indices() {
2000 if c == current {
2001 positions.push(i);
2002 if let Some(c) = chars.next() {
2003 current = c;
2004 } else {
2005 break;
2006 }
2007 }
2008 }
2009
2010 positions
2011}
2012
2013/// Applies a command to all lines matching a pattern.
2014#[derive(Debug, PartialEq, Clone, Action)]
2015#[action(namespace = vim, no_json, no_register)]
2016pub(crate) struct OnMatchingLines {
2017 range: CommandRange,
2018 search: String,
2019 action: WrappedAction,
2020 invert: bool,
2021}
2022
2023impl OnMatchingLines {
2024 // convert a vim query into something more usable by zed.
2025 // we don't attempt to fully convert between the two regex syntaxes,
2026 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
2027 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
2028 pub(crate) fn parse(
2029 query: &str,
2030 range: &Option<CommandRange>,
2031 ) -> Option<(String, CommandRange, String, bool)> {
2032 let mut global = "global".chars().peekable();
2033 let mut query_chars = query.chars().peekable();
2034 let mut invert = false;
2035 if query_chars.peek() == Some(&'v') {
2036 invert = true;
2037 query_chars.next();
2038 }
2039 while global
2040 .peek()
2041 .is_some_and(|char| Some(char) == query_chars.peek())
2042 {
2043 global.next();
2044 query_chars.next();
2045 }
2046 if !invert && query_chars.peek() == Some(&'!') {
2047 invert = true;
2048 query_chars.next();
2049 }
2050 let range = range.clone().unwrap_or(CommandRange {
2051 start: Position::Line { row: 0, offset: 0 },
2052 end: Some(Position::LastLine { offset: 0 }),
2053 });
2054
2055 let delimiter = query_chars.next().filter(|c| {
2056 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
2057 })?;
2058
2059 let mut search = String::new();
2060 let mut escaped = false;
2061
2062 for c in query_chars.by_ref() {
2063 if escaped {
2064 escaped = false;
2065 // unescape escaped parens
2066 if c != '(' && c != ')' && c != delimiter {
2067 search.push('\\')
2068 }
2069 search.push(c)
2070 } else if c == '\\' {
2071 escaped = true;
2072 } else if c == delimiter {
2073 break;
2074 } else {
2075 // escape unescaped parens
2076 if c == '(' || c == ')' {
2077 search.push('\\')
2078 }
2079 search.push(c)
2080 }
2081 }
2082
2083 Some((query_chars.collect::<String>(), range, search, invert))
2084 }
2085
2086 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2087 let result = vim.update_editor(cx, |vim, editor, cx| {
2088 self.range.buffer_range(vim, editor, window, cx)
2089 });
2090
2091 let range = match result {
2092 None => return,
2093 Some(e @ Err(_)) => {
2094 let Some(workspace) = vim.workspace(window) else {
2095 return;
2096 };
2097 workspace.update(cx, |workspace, cx| {
2098 e.notify_err(workspace, cx);
2099 });
2100 return;
2101 }
2102 Some(Ok(result)) => result,
2103 };
2104
2105 let mut action = self.action.boxed_clone();
2106 let mut last_pattern = self.search.clone();
2107
2108 let mut regexes = match Regex::new(&self.search) {
2109 Ok(regex) => vec![(regex, !self.invert)],
2110 e @ Err(_) => {
2111 let Some(workspace) = vim.workspace(window) else {
2112 return;
2113 };
2114 workspace.update(cx, |workspace, cx| {
2115 e.notify_err(workspace, cx);
2116 });
2117 return;
2118 }
2119 };
2120 while let Some(inner) = action
2121 .boxed_clone()
2122 .as_any()
2123 .downcast_ref::<OnMatchingLines>()
2124 {
2125 let Some(regex) = Regex::new(&inner.search).ok() else {
2126 break;
2127 };
2128 last_pattern = inner.search.clone();
2129 action = inner.action.boxed_clone();
2130 regexes.push((regex, !inner.invert))
2131 }
2132
2133 if let Some(pane) = vim.pane(window, cx) {
2134 pane.update(cx, |pane, cx| {
2135 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
2136 {
2137 search_bar.update(cx, |search_bar, cx| {
2138 if search_bar.show(window, cx) {
2139 let _ = search_bar.search(
2140 &last_pattern,
2141 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
2142 false,
2143 window,
2144 cx,
2145 );
2146 }
2147 });
2148 }
2149 });
2150 };
2151
2152 vim.update_editor(cx, |_, editor, cx| {
2153 let snapshot = editor.snapshot(window, cx);
2154 let mut row = range.start.0;
2155
2156 let point_range = Point::new(range.start.0, 0)
2157 ..snapshot
2158 .buffer_snapshot()
2159 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
2160 cx.spawn_in(window, async move |editor, cx| {
2161 let new_selections = cx
2162 .background_spawn(async move {
2163 let mut line = String::new();
2164 let mut new_selections = Vec::new();
2165 let chunks = snapshot
2166 .buffer_snapshot()
2167 .text_for_range(point_range)
2168 .chain(["\n"]);
2169
2170 for chunk in chunks {
2171 for (newline_ix, text) in chunk.split('\n').enumerate() {
2172 if newline_ix > 0 {
2173 if regexes.iter().all(|(regex, should_match)| {
2174 regex.is_match(&line) == *should_match
2175 }) {
2176 new_selections
2177 .push(Point::new(row, 0).to_display_point(&snapshot))
2178 }
2179 row += 1;
2180 line.clear();
2181 }
2182 line.push_str(text)
2183 }
2184 }
2185
2186 new_selections
2187 })
2188 .await;
2189
2190 if new_selections.is_empty() {
2191 return;
2192 }
2193 editor
2194 .update_in(cx, |editor, window, cx| {
2195 editor.start_transaction_at(Instant::now(), window, cx);
2196 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2197 s.replace_cursors_with(|_| new_selections);
2198 });
2199 window.dispatch_action(action, cx);
2200 cx.defer_in(window, move |editor, window, cx| {
2201 let newest = editor
2202 .selections
2203 .newest::<Point>(&editor.display_snapshot(cx));
2204 editor.change_selections(
2205 SelectionEffects::no_scroll(),
2206 window,
2207 cx,
2208 |s| {
2209 s.select(vec![newest]);
2210 },
2211 );
2212 editor.end_transaction_at(Instant::now(), cx);
2213 })
2214 })
2215 .ok();
2216 })
2217 .detach();
2218 });
2219 }
2220}
2221
2222/// Executes a shell command and returns the output.
2223#[derive(Clone, Debug, PartialEq, Action)]
2224#[action(namespace = vim, no_json, no_register)]
2225pub struct ShellExec {
2226 command: String,
2227 range: Option<CommandRange>,
2228 is_read: bool,
2229}
2230
2231impl Vim {
2232 pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2233 if self.running_command.take().is_some() {
2234 self.update_editor(cx, |_, editor, cx| {
2235 editor.transact(window, cx, |editor, _window, _cx| {
2236 editor.clear_row_highlights::<ShellExec>();
2237 })
2238 });
2239 }
2240 }
2241
2242 fn prepare_shell_command(
2243 &mut self,
2244 command: &str,
2245 _: &mut Window,
2246 cx: &mut Context<Self>,
2247 ) -> String {
2248 let mut ret = String::new();
2249 // N.B. non-standard escaping rules:
2250 // * !echo % => "echo README.md"
2251 // * !echo \% => "echo %"
2252 // * !echo \\% => echo \%
2253 // * !echo \\\% => echo \\%
2254 for c in command.chars() {
2255 if c != '%' && c != '!' {
2256 ret.push(c);
2257 continue;
2258 } else if ret.chars().last() == Some('\\') {
2259 ret.pop();
2260 ret.push(c);
2261 continue;
2262 }
2263 match c {
2264 '%' => {
2265 self.update_editor(cx, |_, editor, cx| {
2266 if let Some((_, buffer, _)) = editor.active_excerpt(cx)
2267 && let Some(file) = buffer.read(cx).file()
2268 && let Some(local) = file.as_local()
2269 {
2270 ret.push_str(&local.path().display(local.path_style(cx)));
2271 }
2272 });
2273 }
2274 '!' => {
2275 if let Some(command) = &self.last_command {
2276 ret.push_str(command)
2277 }
2278 }
2279 _ => {}
2280 }
2281 }
2282 self.last_command = Some(ret.clone());
2283 ret
2284 }
2285
2286 pub fn shell_command_motion(
2287 &mut self,
2288 motion: Motion,
2289 times: Option<usize>,
2290 forced_motion: bool,
2291 window: &mut Window,
2292 cx: &mut Context<Vim>,
2293 ) {
2294 self.stop_recording(cx);
2295 let Some(workspace) = self.workspace(window) else {
2296 return;
2297 };
2298 let command = self.update_editor(cx, |_, editor, cx| {
2299 let snapshot = editor.snapshot(window, cx);
2300 let start = editor
2301 .selections
2302 .newest_display(&editor.display_snapshot(cx));
2303 let text_layout_details = editor.text_layout_details(window);
2304 let (mut range, _) = motion
2305 .range(
2306 &snapshot,
2307 start.clone(),
2308 times,
2309 &text_layout_details,
2310 forced_motion,
2311 )
2312 .unwrap_or((start.range(), MotionKind::Exclusive));
2313 if range.start != start.start {
2314 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2315 s.select_ranges([
2316 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2317 ]);
2318 })
2319 }
2320 if range.end.row() > range.start.row() && range.end.column() != 0 {
2321 *range.end.row_mut() -= 1
2322 }
2323 if range.end.row() == range.start.row() {
2324 ".!".to_string()
2325 } else {
2326 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2327 }
2328 });
2329 if let Some(command) = command {
2330 workspace.update(cx, |workspace, cx| {
2331 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2332 });
2333 }
2334 }
2335
2336 pub fn shell_command_object(
2337 &mut self,
2338 object: Object,
2339 around: bool,
2340 window: &mut Window,
2341 cx: &mut Context<Vim>,
2342 ) {
2343 self.stop_recording(cx);
2344 let Some(workspace) = self.workspace(window) else {
2345 return;
2346 };
2347 let command = self.update_editor(cx, |_, editor, cx| {
2348 let snapshot = editor.snapshot(window, cx);
2349 let start = editor
2350 .selections
2351 .newest_display(&editor.display_snapshot(cx));
2352 let range = object
2353 .range(&snapshot, start.clone(), around, None)
2354 .unwrap_or(start.range());
2355 if range.start != start.start {
2356 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2357 s.select_ranges([
2358 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2359 ]);
2360 })
2361 }
2362 if range.end.row() == range.start.row() {
2363 ".!".to_string()
2364 } else {
2365 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2366 }
2367 });
2368 if let Some(command) = command {
2369 workspace.update(cx, |workspace, cx| {
2370 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2371 });
2372 }
2373 }
2374}
2375
2376impl ShellExec {
2377 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
2378 let (before, after) = query.split_once('!')?;
2379 let before = before.trim();
2380
2381 if !"read".starts_with(before) {
2382 return None;
2383 }
2384
2385 Some(
2386 ShellExec {
2387 command: after.trim().to_string(),
2388 range,
2389 is_read: !before.is_empty(),
2390 }
2391 .boxed_clone(),
2392 )
2393 }
2394
2395 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2396 let Some(workspace) = vim.workspace(window) else {
2397 return;
2398 };
2399
2400 let project = workspace.read(cx).project().clone();
2401 let command = vim.prepare_shell_command(&self.command, window, cx);
2402
2403 if self.range.is_none() && !self.is_read {
2404 workspace.update(cx, |workspace, cx| {
2405 let project = workspace.project().read(cx);
2406 let cwd = project.first_project_directory(cx);
2407 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2408
2409 let spawn_in_terminal = SpawnInTerminal {
2410 id: TaskId("vim".to_string()),
2411 full_label: command.clone(),
2412 label: command.clone(),
2413 command: Some(command.clone()),
2414 args: Vec::new(),
2415 command_label: command.clone(),
2416 cwd,
2417 env: HashMap::default(),
2418 use_new_terminal: true,
2419 allow_concurrent_runs: true,
2420 reveal: RevealStrategy::NoFocus,
2421 reveal_target: RevealTarget::Dock,
2422 hide: HideStrategy::Never,
2423 shell,
2424 show_summary: false,
2425 show_command: false,
2426 show_rerun: false,
2427 };
2428
2429 let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
2430 cx.background_spawn(async move {
2431 match task_status.await {
2432 Some(Ok(status)) => {
2433 if status.success() {
2434 log::debug!("Vim shell exec succeeded");
2435 } else {
2436 log::debug!("Vim shell exec failed, code: {:?}", status.code());
2437 }
2438 }
2439 Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
2440 None => log::debug!("Vim shell exec got cancelled"),
2441 }
2442 })
2443 .detach();
2444 });
2445 return;
2446 };
2447
2448 let mut input_snapshot = None;
2449 let mut input_range = None;
2450 let mut needs_newline_prefix = false;
2451 vim.update_editor(cx, |vim, editor, cx| {
2452 let snapshot = editor.buffer().read(cx).snapshot(cx);
2453 let range = if let Some(range) = self.range.clone() {
2454 let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
2455 return;
2456 };
2457 Point::new(range.start.0, 0)
2458 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
2459 } else {
2460 let mut end = editor
2461 .selections
2462 .newest::<Point>(&editor.display_snapshot(cx))
2463 .range()
2464 .end;
2465 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
2466 needs_newline_prefix = end == snapshot.max_point();
2467 end..end
2468 };
2469 if self.is_read {
2470 input_range =
2471 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
2472 } else {
2473 input_range =
2474 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
2475 }
2476 editor.highlight_rows::<ShellExec>(
2477 input_range.clone().unwrap(),
2478 cx.theme().status().unreachable_background,
2479 Default::default(),
2480 cx,
2481 );
2482
2483 if !self.is_read {
2484 input_snapshot = Some(snapshot)
2485 }
2486 });
2487
2488 let Some(range) = input_range else { return };
2489
2490 let process_task = project.update(cx, |project, cx| project.exec_in_shell(command, cx));
2491
2492 let is_read = self.is_read;
2493
2494 let task = cx.spawn_in(window, async move |vim, cx| {
2495 let Some(mut process) = process_task.await.log_err() else {
2496 return;
2497 };
2498 process.stdout(Stdio::piped());
2499 process.stderr(Stdio::piped());
2500
2501 if input_snapshot.is_some() {
2502 process.stdin(Stdio::piped());
2503 } else {
2504 process.stdin(Stdio::null());
2505 };
2506
2507 let Some(mut running) = process.spawn().log_err() else {
2508 vim.update_in(cx, |vim, window, cx| {
2509 vim.cancel_running_command(window, cx);
2510 })
2511 .log_err();
2512 return;
2513 };
2514
2515 if let Some(mut stdin) = running.stdin.take()
2516 && let Some(snapshot) = input_snapshot
2517 {
2518 let range = range.clone();
2519 cx.background_spawn(async move {
2520 for chunk in snapshot.text_for_range(range) {
2521 if stdin.write_all(chunk.as_bytes()).await.log_err().is_none() {
2522 return;
2523 }
2524 }
2525 stdin.flush().await.log_err();
2526 })
2527 .detach();
2528 };
2529
2530 let output = cx.background_spawn(running.output()).await;
2531
2532 let Some(output) = output.log_err() else {
2533 vim.update_in(cx, |vim, window, cx| {
2534 vim.cancel_running_command(window, cx);
2535 })
2536 .log_err();
2537 return;
2538 };
2539 let mut text = String::new();
2540 if needs_newline_prefix {
2541 text.push('\n');
2542 }
2543 text.push_str(&String::from_utf8_lossy(&output.stdout));
2544 text.push_str(&String::from_utf8_lossy(&output.stderr));
2545 if !text.is_empty() && text.chars().last() != Some('\n') {
2546 text.push('\n');
2547 }
2548
2549 vim.update_in(cx, |vim, window, cx| {
2550 vim.update_editor(cx, |_, editor, cx| {
2551 editor.transact(window, cx, |editor, window, cx| {
2552 editor.edit([(range.clone(), text)], cx);
2553 let snapshot = editor.buffer().read(cx).snapshot(cx);
2554 editor.change_selections(Default::default(), window, cx, |s| {
2555 let point = if is_read {
2556 let point = range.end.to_point(&snapshot);
2557 Point::new(point.row.saturating_sub(1), 0)
2558 } else {
2559 let point = range.start.to_point(&snapshot);
2560 Point::new(point.row, 0)
2561 };
2562 s.select_ranges([point..point]);
2563 })
2564 })
2565 });
2566 vim.cancel_running_command(window, cx);
2567 })
2568 .log_err();
2569 });
2570 vim.running_command.replace(task);
2571 }
2572}
2573
2574#[cfg(test)]
2575mod test {
2576 use std::path::{Path, PathBuf};
2577
2578 use crate::{
2579 VimAddon,
2580 state::Mode,
2581 test::{NeovimBackedTestContext, VimTestContext},
2582 };
2583 use editor::{Editor, EditorSettings};
2584 use gpui::{Context, TestAppContext};
2585 use indoc::indoc;
2586 use settings::Settings;
2587 use util::path;
2588 use workspace::{OpenOptions, Workspace};
2589
2590 #[gpui::test]
2591 async fn test_command_basics(cx: &mut TestAppContext) {
2592 let mut cx = NeovimBackedTestContext::new(cx).await;
2593
2594 cx.set_shared_state(indoc! {"
2595 ˇa
2596 b
2597 c"})
2598 .await;
2599
2600 cx.simulate_shared_keystrokes(": j enter").await;
2601
2602 // hack: our cursor positioning after a join command is wrong
2603 cx.simulate_shared_keystrokes("^").await;
2604 cx.shared_state().await.assert_eq(indoc! {
2605 "ˇa b
2606 c"
2607 });
2608 }
2609
2610 #[gpui::test]
2611 async fn test_command_goto(cx: &mut TestAppContext) {
2612 let mut cx = NeovimBackedTestContext::new(cx).await;
2613
2614 cx.set_shared_state(indoc! {"
2615 ˇa
2616 b
2617 c"})
2618 .await;
2619 cx.simulate_shared_keystrokes(": 3 enter").await;
2620 cx.shared_state().await.assert_eq(indoc! {"
2621 a
2622 b
2623 ˇc"});
2624 }
2625
2626 #[gpui::test]
2627 async fn test_command_replace(cx: &mut TestAppContext) {
2628 let mut cx = NeovimBackedTestContext::new(cx).await;
2629
2630 cx.set_shared_state(indoc! {"
2631 ˇa
2632 b
2633 b
2634 c"})
2635 .await;
2636 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
2637 cx.shared_state().await.assert_eq(indoc! {"
2638 a
2639 d
2640 ˇd
2641 c"});
2642 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
2643 .await;
2644 cx.shared_state().await.assert_eq(indoc! {"
2645 aa
2646 dd
2647 dd
2648 ˇcc"});
2649 cx.simulate_shared_keystrokes("k : s / d d / e e enter")
2650 .await;
2651 cx.shared_state().await.assert_eq(indoc! {"
2652 aa
2653 dd
2654 ˇee
2655 cc"});
2656 }
2657
2658 #[gpui::test]
2659 async fn test_command_search(cx: &mut TestAppContext) {
2660 let mut cx = NeovimBackedTestContext::new(cx).await;
2661
2662 cx.set_shared_state(indoc! {"
2663 ˇa
2664 b
2665 a
2666 c"})
2667 .await;
2668 cx.simulate_shared_keystrokes(": / b enter").await;
2669 cx.shared_state().await.assert_eq(indoc! {"
2670 a
2671 ˇb
2672 a
2673 c"});
2674 cx.simulate_shared_keystrokes(": ? a enter").await;
2675 cx.shared_state().await.assert_eq(indoc! {"
2676 ˇa
2677 b
2678 a
2679 c"});
2680 }
2681
2682 #[gpui::test]
2683 async fn test_command_write(cx: &mut TestAppContext) {
2684 let mut cx = VimTestContext::new(cx, true).await;
2685 let path = Path::new(path!("/root/dir/file.rs"));
2686 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2687
2688 cx.simulate_keystrokes("i @ escape");
2689 cx.simulate_keystrokes(": w enter");
2690
2691 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
2692
2693 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
2694
2695 // conflict!
2696 cx.simulate_keystrokes("i @ escape");
2697 cx.simulate_keystrokes(": w enter");
2698 cx.simulate_prompt_answer("Cancel");
2699
2700 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
2701 assert!(!cx.has_pending_prompt());
2702 cx.simulate_keystrokes(": w !");
2703 cx.simulate_keystrokes("enter");
2704 assert!(!cx.has_pending_prompt());
2705 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
2706 }
2707
2708 #[gpui::test]
2709 async fn test_command_read(cx: &mut TestAppContext) {
2710 let mut cx = VimTestContext::new(cx, true).await;
2711
2712 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2713 let path = Path::new(path!("/root/dir/other.rs"));
2714 fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
2715
2716 cx.workspace(|workspace, _, cx| {
2717 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2718 });
2719
2720 // File without trailing newline
2721 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2722 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2723 cx.simulate_keystrokes("enter");
2724 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
2725
2726 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2727 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2728 cx.simulate_keystrokes("enter");
2729 cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
2730
2731 cx.set_state("one\nˇtwo\nthree", Mode::Normal);
2732 cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
2733 cx.simulate_keystrokes("enter");
2734 cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
2735
2736 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2737 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2738 cx.simulate_keystrokes("enter");
2739 cx.run_until_parked();
2740 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
2741
2742 // Empty filename
2743 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2744 cx.simulate_keystrokes(": r");
2745 cx.simulate_keystrokes("enter");
2746 cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
2747
2748 // File with trailing newline
2749 fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
2750 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2751 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2752 cx.simulate_keystrokes("enter");
2753 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2754
2755 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2756 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2757 cx.simulate_keystrokes("enter");
2758 cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
2759
2760 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2761 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2762 cx.simulate_keystrokes("enter");
2763 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
2764
2765 cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
2766 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2767 cx.simulate_keystrokes("enter");
2768 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2769
2770 // Empty file
2771 fs.as_fake().insert_file(path, "".into()).await;
2772 cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
2773 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2774 cx.simulate_keystrokes("enter");
2775 cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
2776 }
2777
2778 #[gpui::test]
2779 async fn test_command_quit(cx: &mut TestAppContext) {
2780 let mut cx = VimTestContext::new(cx, true).await;
2781
2782 cx.simulate_keystrokes(": n e w enter");
2783 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2784 cx.simulate_keystrokes(": q enter");
2785 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
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 a enter");
2789 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
2790 }
2791
2792 #[gpui::test]
2793 async fn test_offsets(cx: &mut TestAppContext) {
2794 let mut cx = NeovimBackedTestContext::new(cx).await;
2795
2796 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
2797 .await;
2798
2799 cx.simulate_shared_keystrokes(": + enter").await;
2800 cx.shared_state()
2801 .await
2802 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
2803
2804 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
2805 cx.shared_state()
2806 .await
2807 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
2808
2809 cx.simulate_shared_keystrokes(": . - 2 enter").await;
2810 cx.shared_state()
2811 .await
2812 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
2813
2814 cx.simulate_shared_keystrokes(": % enter").await;
2815 cx.shared_state()
2816 .await
2817 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
2818 }
2819
2820 #[gpui::test]
2821 async fn test_command_ranges(cx: &mut TestAppContext) {
2822 let mut cx = NeovimBackedTestContext::new(cx).await;
2823
2824 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2825
2826 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
2827 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
2828
2829 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
2830 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
2831
2832 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
2833 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
2834 }
2835
2836 #[gpui::test]
2837 async fn test_command_visual_replace(cx: &mut TestAppContext) {
2838 let mut cx = NeovimBackedTestContext::new(cx).await;
2839
2840 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2841
2842 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
2843 .await;
2844 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
2845 }
2846
2847 #[track_caller]
2848 fn assert_active_item(
2849 workspace: &mut Workspace,
2850 expected_path: &str,
2851 expected_text: &str,
2852 cx: &mut Context<Workspace>,
2853 ) {
2854 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2855
2856 let buffer = active_editor
2857 .read(cx)
2858 .buffer()
2859 .read(cx)
2860 .as_singleton()
2861 .unwrap();
2862
2863 let text = buffer.read(cx).text();
2864 let file = buffer.read(cx).file().unwrap();
2865 let file_path = file.as_local().unwrap().abs_path(cx);
2866
2867 assert_eq!(text, expected_text);
2868 assert_eq!(file_path, Path::new(expected_path));
2869 }
2870
2871 #[gpui::test]
2872 async fn test_command_gf(cx: &mut TestAppContext) {
2873 let mut cx = VimTestContext::new(cx, true).await;
2874
2875 // Assert base state, that we're in /root/dir/file.rs
2876 cx.workspace(|workspace, _, cx| {
2877 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2878 });
2879
2880 // Insert a new file
2881 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2882 fs.as_fake()
2883 .insert_file(
2884 path!("/root/dir/file2.rs"),
2885 "This is file2.rs".as_bytes().to_vec(),
2886 )
2887 .await;
2888 fs.as_fake()
2889 .insert_file(
2890 path!("/root/dir/file3.rs"),
2891 "go to file3".as_bytes().to_vec(),
2892 )
2893 .await;
2894
2895 // Put the path to the second file into the currently open buffer
2896 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
2897
2898 // Go to file2.rs
2899 cx.simulate_keystrokes("g f");
2900
2901 // We now have two items
2902 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2903 cx.workspace(|workspace, _, cx| {
2904 assert_active_item(
2905 workspace,
2906 path!("/root/dir/file2.rs"),
2907 "This is file2.rs",
2908 cx,
2909 );
2910 });
2911
2912 // Update editor to point to `file2.rs`
2913 cx.editor =
2914 cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
2915
2916 // Put the path to the third file into the currently open buffer,
2917 // but remove its suffix, because we want that lookup to happen automatically.
2918 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
2919
2920 // Go to file3.rs
2921 cx.simulate_keystrokes("g f");
2922
2923 // We now have three items
2924 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2925 cx.workspace(|workspace, _, cx| {
2926 assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
2927 });
2928 }
2929
2930 #[gpui::test]
2931 async fn test_command_write_filename(cx: &mut TestAppContext) {
2932 let mut cx = VimTestContext::new(cx, true).await;
2933
2934 cx.workspace(|workspace, _, cx| {
2935 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2936 });
2937
2938 cx.simulate_keystrokes(": w space other.rs");
2939 cx.simulate_keystrokes("enter");
2940
2941 cx.workspace(|workspace, _, cx| {
2942 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2943 });
2944
2945 cx.simulate_keystrokes(": w space dir/file.rs");
2946 cx.simulate_keystrokes("enter");
2947
2948 cx.simulate_prompt_answer("Replace");
2949 cx.run_until_parked();
2950
2951 cx.workspace(|workspace, _, cx| {
2952 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2953 });
2954
2955 cx.simulate_keystrokes(": w ! space other.rs");
2956 cx.simulate_keystrokes("enter");
2957
2958 cx.workspace(|workspace, _, cx| {
2959 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
2960 });
2961 }
2962
2963 #[gpui::test]
2964 async fn test_command_write_range(cx: &mut TestAppContext) {
2965 let mut cx = VimTestContext::new(cx, true).await;
2966
2967 cx.workspace(|workspace, _, cx| {
2968 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2969 });
2970
2971 cx.set_state(
2972 indoc! {"
2973 The quick
2974 brown« fox
2975 jumpsˇ» over
2976 the lazy dog
2977 "},
2978 Mode::Visual,
2979 );
2980
2981 cx.simulate_keystrokes(": w space dir/other.rs");
2982 cx.simulate_keystrokes("enter");
2983
2984 let other = path!("/root/dir/other.rs");
2985
2986 let _ = cx
2987 .workspace(|workspace, window, cx| {
2988 workspace.open_abs_path(PathBuf::from(other), OpenOptions::default(), window, cx)
2989 })
2990 .await;
2991
2992 cx.workspace(|workspace, _, cx| {
2993 assert_active_item(
2994 workspace,
2995 other,
2996 indoc! {"
2997 brown fox
2998 jumps over
2999 "},
3000 cx,
3001 );
3002 });
3003 }
3004
3005 #[gpui::test]
3006 async fn test_command_matching_lines(cx: &mut TestAppContext) {
3007 let mut cx = NeovimBackedTestContext::new(cx).await;
3008
3009 cx.set_shared_state(indoc! {"
3010 ˇa
3011 b
3012 a
3013 b
3014 a
3015 "})
3016 .await;
3017
3018 cx.simulate_shared_keystrokes(":").await;
3019 cx.simulate_shared_keystrokes("g / a / d").await;
3020 cx.simulate_shared_keystrokes("enter").await;
3021
3022 cx.shared_state().await.assert_eq(indoc! {"
3023 b
3024 b
3025 ˇ"});
3026
3027 cx.simulate_shared_keystrokes("u").await;
3028
3029 cx.shared_state().await.assert_eq(indoc! {"
3030 ˇa
3031 b
3032 a
3033 b
3034 a
3035 "});
3036
3037 cx.simulate_shared_keystrokes(":").await;
3038 cx.simulate_shared_keystrokes("v / a / d").await;
3039 cx.simulate_shared_keystrokes("enter").await;
3040
3041 cx.shared_state().await.assert_eq(indoc! {"
3042 a
3043 a
3044 ˇa"});
3045 }
3046
3047 #[gpui::test]
3048 async fn test_del_marks(cx: &mut TestAppContext) {
3049 let mut cx = NeovimBackedTestContext::new(cx).await;
3050
3051 cx.set_shared_state(indoc! {"
3052 ˇa
3053 b
3054 a
3055 b
3056 a
3057 "})
3058 .await;
3059
3060 cx.simulate_shared_keystrokes("m a").await;
3061
3062 let mark = cx.update_editor(|editor, window, cx| {
3063 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3064 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3065 });
3066 assert!(mark.is_some());
3067
3068 cx.simulate_shared_keystrokes(": d e l m space a").await;
3069 cx.simulate_shared_keystrokes("enter").await;
3070
3071 let mark = cx.update_editor(|editor, window, cx| {
3072 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3073 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3074 });
3075 assert!(mark.is_none())
3076 }
3077
3078 #[gpui::test]
3079 async fn test_normal_command(cx: &mut TestAppContext) {
3080 let mut cx = NeovimBackedTestContext::new(cx).await;
3081
3082 cx.set_shared_state(indoc! {"
3083 The quick
3084 brown« fox
3085 jumpsˇ» over
3086 the lazy dog
3087 "})
3088 .await;
3089
3090 cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
3091 .await;
3092 cx.simulate_shared_keystrokes("enter").await;
3093
3094 cx.shared_state().await.assert_eq(indoc! {"
3095 The quick
3096 brown word
3097 jumps worˇd
3098 the lazy dog
3099 "});
3100
3101 cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
3102 .await;
3103 cx.simulate_shared_keystrokes("enter").await;
3104
3105 cx.shared_state().await.assert_eq(indoc! {"
3106 The quick
3107 brown word
3108 jumps tesˇt
3109 the lazy dog
3110 "});
3111
3112 cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
3113 .await;
3114 cx.simulate_shared_keystrokes("enter").await;
3115
3116 cx.shared_state().await.assert_eq(indoc! {"
3117 The quick
3118 brown word
3119 lˇaumps test
3120 the lazy dog
3121 "});
3122
3123 cx.set_shared_state(indoc! {"
3124 ˇThe quick
3125 brown fox
3126 jumps over
3127 the lazy dog
3128 "})
3129 .await;
3130
3131 cx.simulate_shared_keystrokes("c i w M y escape").await;
3132
3133 cx.shared_state().await.assert_eq(indoc! {"
3134 Mˇy quick
3135 brown fox
3136 jumps over
3137 the lazy dog
3138 "});
3139
3140 cx.simulate_shared_keystrokes(": n o r m space u").await;
3141 cx.simulate_shared_keystrokes("enter").await;
3142
3143 cx.shared_state().await.assert_eq(indoc! {"
3144 ˇThe quick
3145 brown fox
3146 jumps over
3147 the lazy dog
3148 "});
3149 // Once ctrl-v to input character literals is added there should be a test for redo
3150 }
3151
3152 #[gpui::test]
3153 async fn test_command_tabnew(cx: &mut TestAppContext) {
3154 let mut cx = VimTestContext::new(cx, true).await;
3155
3156 // Create a new file to ensure that, when the filename is used with
3157 // `:tabnew`, it opens the existing file in a new tab.
3158 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3159 fs.as_fake()
3160 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3161 .await;
3162
3163 cx.simulate_keystrokes(": tabnew");
3164 cx.simulate_keystrokes("enter");
3165 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3166
3167 // Assert that the new tab is empty and not associated with any file, as
3168 // no file path was provided to the `:tabnew` command.
3169 cx.workspace(|workspace, _window, cx| {
3170 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3171 let buffer = active_editor
3172 .read(cx)
3173 .buffer()
3174 .read(cx)
3175 .as_singleton()
3176 .unwrap();
3177
3178 assert!(&buffer.read(cx).file().is_none());
3179 });
3180
3181 // Leverage the filename as an argument to the `:tabnew` command,
3182 // ensuring that the file, instead of an empty buffer, is opened in a
3183 // new tab.
3184 cx.simulate_keystrokes(": tabnew space dir/file_2.rs");
3185 cx.simulate_keystrokes("enter");
3186
3187 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3188 cx.workspace(|workspace, _, cx| {
3189 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3190 });
3191
3192 // If the `filename` argument provided to the `:tabnew` command is for a
3193 // file that doesn't yet exist, it should still associate the buffer
3194 // with that file path, so that when the buffer contents are saved, the
3195 // file is created.
3196 cx.simulate_keystrokes(": tabnew space dir/file_3.rs");
3197 cx.simulate_keystrokes("enter");
3198
3199 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3200 cx.workspace(|workspace, _, cx| {
3201 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3202 });
3203 }
3204
3205 #[gpui::test]
3206 async fn test_command_tabedit(cx: &mut TestAppContext) {
3207 let mut cx = VimTestContext::new(cx, true).await;
3208
3209 // Create a new file to ensure that, when the filename is used with
3210 // `:tabedit`, it opens the existing file in a new tab.
3211 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3212 fs.as_fake()
3213 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3214 .await;
3215
3216 cx.simulate_keystrokes(": tabedit");
3217 cx.simulate_keystrokes("enter");
3218 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3219
3220 // Assert that the new tab is empty and not associated with any file, as
3221 // no file path was provided to the `:tabedit` command.
3222 cx.workspace(|workspace, _window, cx| {
3223 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3224 let buffer = active_editor
3225 .read(cx)
3226 .buffer()
3227 .read(cx)
3228 .as_singleton()
3229 .unwrap();
3230
3231 assert!(&buffer.read(cx).file().is_none());
3232 });
3233
3234 // Leverage the filename as an argument to the `:tabedit` command,
3235 // ensuring that the file, instead of an empty buffer, is opened in a
3236 // new tab.
3237 cx.simulate_keystrokes(": tabedit space dir/file_2.rs");
3238 cx.simulate_keystrokes("enter");
3239
3240 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3241 cx.workspace(|workspace, _, cx| {
3242 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3243 });
3244
3245 // If the `filename` argument provided to the `:tabedit` command is for a
3246 // file that doesn't yet exist, it should still associate the buffer
3247 // with that file path, so that when the buffer contents are saved, the
3248 // file is created.
3249 cx.simulate_keystrokes(": tabedit space dir/file_3.rs");
3250 cx.simulate_keystrokes("enter");
3251
3252 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3253 cx.workspace(|workspace, _, cx| {
3254 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3255 });
3256 }
3257
3258 #[gpui::test]
3259 async fn test_ignorecase_command(cx: &mut TestAppContext) {
3260 let mut cx = VimTestContext::new(cx, true).await;
3261 cx.read(|cx| {
3262 assert_eq!(
3263 EditorSettings::get_global(cx).search.case_sensitive,
3264 false,
3265 "The `case_sensitive` setting should be `false` by default."
3266 );
3267 });
3268 cx.simulate_keystrokes(": set space noignorecase");
3269 cx.simulate_keystrokes("enter");
3270 cx.read(|cx| {
3271 assert_eq!(
3272 EditorSettings::get_global(cx).search.case_sensitive,
3273 true,
3274 "The `case_sensitive` setting should have been enabled with `:set noignorecase`."
3275 );
3276 });
3277 cx.simulate_keystrokes(": set space ignorecase");
3278 cx.simulate_keystrokes("enter");
3279 cx.read(|cx| {
3280 assert_eq!(
3281 EditorSettings::get_global(cx).search.case_sensitive,
3282 false,
3283 "The `case_sensitive` setting should have been disabled with `:set ignorecase`."
3284 );
3285 });
3286 cx.simulate_keystrokes(": set space noic");
3287 cx.simulate_keystrokes("enter");
3288 cx.read(|cx| {
3289 assert_eq!(
3290 EditorSettings::get_global(cx).search.case_sensitive,
3291 true,
3292 "The `case_sensitive` setting should have been enabled with `:set noic`."
3293 );
3294 });
3295 cx.simulate_keystrokes(": set space ic");
3296 cx.simulate_keystrokes("enter");
3297 cx.read(|cx| {
3298 assert_eq!(
3299 EditorSettings::get_global(cx).search.case_sensitive,
3300 false,
3301 "The `case_sensitive` setting should have been disabled with `:set ic`."
3302 );
3303 });
3304 }
3305
3306 #[gpui::test]
3307 async fn test_sort_commands(cx: &mut TestAppContext) {
3308 let mut cx = VimTestContext::new(cx, true).await;
3309
3310 cx.set_state(
3311 indoc! {"
3312 «hornet
3313 quirrel
3314 elderbug
3315 cornifer
3316 idaˇ»
3317 "},
3318 Mode::Visual,
3319 );
3320
3321 cx.simulate_keystrokes(": sort");
3322 cx.simulate_keystrokes("enter");
3323
3324 cx.assert_state(
3325 indoc! {"
3326 ˇcornifer
3327 elderbug
3328 hornet
3329 ida
3330 quirrel
3331 "},
3332 Mode::Normal,
3333 );
3334
3335 // Assert that, by default, `:sort` takes case into consideration.
3336 cx.set_state(
3337 indoc! {"
3338 «hornet
3339 quirrel
3340 Elderbug
3341 cornifer
3342 idaˇ»
3343 "},
3344 Mode::Visual,
3345 );
3346
3347 cx.simulate_keystrokes(": sort");
3348 cx.simulate_keystrokes("enter");
3349
3350 cx.assert_state(
3351 indoc! {"
3352 ˇElderbug
3353 cornifer
3354 hornet
3355 ida
3356 quirrel
3357 "},
3358 Mode::Normal,
3359 );
3360
3361 // Assert that, if the `i` option is passed, `:sort` ignores case.
3362 cx.set_state(
3363 indoc! {"
3364 «hornet
3365 quirrel
3366 Elderbug
3367 cornifer
3368 idaˇ»
3369 "},
3370 Mode::Visual,
3371 );
3372
3373 cx.simulate_keystrokes(": sort space i");
3374 cx.simulate_keystrokes("enter");
3375
3376 cx.assert_state(
3377 indoc! {"
3378 ˇcornifer
3379 Elderbug
3380 hornet
3381 ida
3382 quirrel
3383 "},
3384 Mode::Normal,
3385 );
3386
3387 // When no range is provided, sorts the whole buffer.
3388 cx.set_state(
3389 indoc! {"
3390 ˇhornet
3391 quirrel
3392 elderbug
3393 cornifer
3394 ida
3395 "},
3396 Mode::Normal,
3397 );
3398
3399 cx.simulate_keystrokes(": sort");
3400 cx.simulate_keystrokes("enter");
3401
3402 cx.assert_state(
3403 indoc! {"
3404 ˇcornifer
3405 elderbug
3406 hornet
3407 ida
3408 quirrel
3409 "},
3410 Mode::Normal,
3411 );
3412 }
3413}