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