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