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