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