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(("A", "I"), "agent::ToggleFocus"),
1786 VimCommand::str(("G", "it"), "git_panel::ToggleFocus"),
1787 VimCommand::str(("D", "ebug"), "debug_panel::ToggleFocus"),
1788 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
1789 VimCommand::new(("$", ""), EndOfDocument),
1790 VimCommand::new(("%", ""), EndOfDocument),
1791 VimCommand::new(("0", ""), StartOfDocument),
1792 VimCommand::new(("ex", ""), editor::actions::ReloadFile).bang(editor::actions::ReloadFile),
1793 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
1794 VimCommand::str(("opt", "ions"), "zed::OpenDefaultSettings"),
1795 VimCommand::str(("map", ""), "vim::OpenDefaultKeymap"),
1796 VimCommand::new(("h", "elp"), OpenDocs),
1797 ]
1798}
1799
1800struct VimCommands(Vec<VimCommand>);
1801// safety: we only ever access this from the main thread (as ensured by the cx argument)
1802// actions are not Sync so we can't otherwise use a OnceLock.
1803unsafe impl Sync for VimCommands {}
1804impl Global for VimCommands {}
1805
1806fn commands(cx: &App) -> &Vec<VimCommand> {
1807 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
1808 &COMMANDS
1809 .get_or_init(|| VimCommands(generate_commands(cx)))
1810 .0
1811}
1812
1813fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1814 Some(
1815 WithRange {
1816 restore_selection: true,
1817 range: range.clone(),
1818 action: WrappedAction(action),
1819 }
1820 .boxed_clone(),
1821 )
1822}
1823
1824fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1825 Some(
1826 WithRange {
1827 restore_selection: false,
1828 range: range.clone(),
1829 action: WrappedAction(action),
1830 }
1831 .boxed_clone(),
1832 )
1833}
1834
1835fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
1836 range.as_count().map(|count| {
1837 WithCount {
1838 count,
1839 action: WrappedAction(action),
1840 }
1841 .boxed_clone()
1842 })
1843}
1844
1845pub fn command_interceptor(
1846 mut input: &str,
1847 workspace: WeakEntity<Workspace>,
1848 cx: &mut App,
1849) -> Task<CommandInterceptResult> {
1850 while input.starts_with(':') {
1851 input = &input[1..];
1852 }
1853
1854 let (range, query) = VimCommand::parse_range(input);
1855 let range_prefix = input[0..(input.len() - query.len())].to_string();
1856 let has_trailing_space = query.ends_with(" ");
1857 let mut query = query.as_str().trim_start();
1858
1859 let on_matching_lines = (query.starts_with('g') || query.starts_with('v'))
1860 .then(|| {
1861 let (pattern, range, search, invert) = OnMatchingLines::parse(query, &range)?;
1862 let start_idx = query.len() - pattern.len();
1863 query = query[start_idx..].trim();
1864 Some((range, search, invert))
1865 })
1866 .flatten();
1867
1868 let mut action = if range.is_some() && query.is_empty() {
1869 Some(
1870 GoToLine {
1871 range: range.clone().unwrap(),
1872 }
1873 .boxed_clone(),
1874 )
1875 } else if query.starts_with('/') || query.starts_with('?') {
1876 Some(
1877 FindCommand {
1878 query: query[1..].to_string(),
1879 backwards: query.starts_with('?'),
1880 }
1881 .boxed_clone(),
1882 )
1883 } else if query.starts_with("se ") || query.starts_with("set ") {
1884 let (prefix, option) = query.split_once(' ').unwrap();
1885 let mut commands = VimOption::possible_commands(option);
1886 if !commands.is_empty() {
1887 let query = prefix.to_string() + " " + option;
1888 for command in &mut commands {
1889 command.positions = generate_positions(&command.string, &query);
1890 }
1891 }
1892 return Task::ready(CommandInterceptResult {
1893 results: commands,
1894 exclusive: false,
1895 });
1896 } else if query.starts_with('s') {
1897 let mut substitute = "substitute".chars().peekable();
1898 let mut query = query.chars().peekable();
1899 while substitute
1900 .peek()
1901 .is_some_and(|char| Some(char) == query.peek())
1902 {
1903 substitute.next();
1904 query.next();
1905 }
1906 if let Some(replacement) = Replacement::parse(query) {
1907 let range = range.clone().unwrap_or(CommandRange {
1908 start: Position::CurrentLine { offset: 0 },
1909 end: None,
1910 });
1911 Some(ReplaceCommand { replacement, range }.boxed_clone())
1912 } else {
1913 None
1914 }
1915 } else if query.contains('!') {
1916 ShellExec::parse(query, range.clone())
1917 } else if on_matching_lines.is_some() {
1918 commands(cx)
1919 .iter()
1920 .find_map(|command| command.parse(query, &None, cx))
1921 } else {
1922 None
1923 };
1924
1925 if let Some((range, search, invert)) = on_matching_lines
1926 && let Some(ref inner) = action
1927 {
1928 action = Some(Box::new(OnMatchingLines {
1929 range,
1930 search,
1931 action: WrappedAction(inner.boxed_clone()),
1932 invert,
1933 }));
1934 };
1935
1936 if let Some(action) = action {
1937 let string = input.to_string();
1938 let positions = generate_positions(&string, &(range_prefix + query));
1939 return Task::ready(CommandInterceptResult {
1940 results: vec![CommandInterceptItem {
1941 action,
1942 string,
1943 positions,
1944 }],
1945 exclusive: false,
1946 });
1947 }
1948
1949 let Some((mut results, filenames)) =
1950 commands(cx).iter().enumerate().find_map(|(idx, command)| {
1951 let action = command.parse(query, &range, cx)?;
1952 let parsed_query = command.get_parsed_query(query.into())?;
1953 let display_string = ":".to_owned()
1954 + &range_prefix
1955 + command.prefix
1956 + command.suffix
1957 + if parsed_query.has_bang { "!" } else { "" };
1958 let space = if parsed_query.has_space { " " } else { "" };
1959
1960 let string = format!("{}{}{}", &display_string, &space, &parsed_query.args);
1961 let positions = generate_positions(&string, &(range_prefix.clone() + query));
1962
1963 let results = vec![CommandInterceptItem {
1964 action,
1965 string,
1966 positions,
1967 }];
1968
1969 let no_args_positions =
1970 generate_positions(&display_string, &(range_prefix.clone() + query));
1971
1972 // The following are valid autocomplete scenarios:
1973 // :w!filename.txt
1974 // :w filename.txt
1975 // :w[space]
1976 if !command.has_filename
1977 || (!has_trailing_space && !parsed_query.has_bang && parsed_query.args.is_empty())
1978 {
1979 return Some((results, None));
1980 }
1981
1982 Some((
1983 results,
1984 Some((idx, parsed_query, display_string, no_args_positions)),
1985 ))
1986 })
1987 else {
1988 return Task::ready(CommandInterceptResult::default());
1989 };
1990
1991 if let Some((cmd_idx, parsed_query, display_string, no_args_positions)) = filenames {
1992 let filenames = VimCommand::generate_filename_completions(&parsed_query, workspace, cx);
1993 cx.spawn(async move |cx| {
1994 let filenames = filenames.await;
1995 const MAX_RESULTS: usize = 100;
1996 let executor = cx.background_executor().clone();
1997 let mut candidates = Vec::with_capacity(filenames.len());
1998
1999 for (idx, filename) in filenames.iter().enumerate() {
2000 candidates.push(fuzzy::StringMatchCandidate::new(idx, &filename));
2001 }
2002 let filenames = fuzzy::match_strings(
2003 &candidates,
2004 &parsed_query.args,
2005 false,
2006 true,
2007 MAX_RESULTS,
2008 &Default::default(),
2009 executor,
2010 )
2011 .await;
2012
2013 for fuzzy::StringMatch {
2014 candidate_id: _,
2015 score: _,
2016 positions,
2017 string,
2018 } in filenames
2019 {
2020 let offset = display_string.len() + 1;
2021 let mut positions: Vec<_> = positions.iter().map(|&pos| pos + offset).collect();
2022 positions.splice(0..0, no_args_positions.clone());
2023 let string = format!("{display_string} {string}");
2024 let (range, query) = VimCommand::parse_range(&string[1..]);
2025 let action =
2026 match cx.update(|cx| commands(cx).get(cmd_idx)?.parse(&query, &range, cx)) {
2027 Some(action) => action,
2028 _ => continue,
2029 };
2030 results.push(CommandInterceptItem {
2031 action,
2032 string,
2033 positions,
2034 });
2035 }
2036 CommandInterceptResult {
2037 results,
2038 exclusive: true,
2039 }
2040 })
2041 } else {
2042 Task::ready(CommandInterceptResult {
2043 results,
2044 exclusive: false,
2045 })
2046 }
2047}
2048
2049fn generate_positions(string: &str, query: &str) -> Vec<usize> {
2050 let mut positions = Vec::new();
2051 let mut chars = query.chars();
2052
2053 let Some(mut current) = chars.next() else {
2054 return positions;
2055 };
2056
2057 for (i, c) in string.char_indices() {
2058 if c == current {
2059 positions.push(i);
2060 if let Some(c) = chars.next() {
2061 current = c;
2062 } else {
2063 break;
2064 }
2065 }
2066 }
2067
2068 positions
2069}
2070
2071/// Applies a command to all lines matching a pattern.
2072#[derive(Debug, PartialEq, Clone, Action)]
2073#[action(namespace = vim, no_json, no_register)]
2074pub(crate) struct OnMatchingLines {
2075 range: CommandRange,
2076 search: String,
2077 action: WrappedAction,
2078 invert: bool,
2079}
2080
2081impl OnMatchingLines {
2082 // convert a vim query into something more usable by zed.
2083 // we don't attempt to fully convert between the two regex syntaxes,
2084 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
2085 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
2086 pub(crate) fn parse(
2087 query: &str,
2088 range: &Option<CommandRange>,
2089 ) -> Option<(String, CommandRange, String, bool)> {
2090 let mut global = "global".chars().peekable();
2091 let mut query_chars = query.chars().peekable();
2092 let mut invert = false;
2093 if query_chars.peek() == Some(&'v') {
2094 invert = true;
2095 query_chars.next();
2096 }
2097 while global
2098 .peek()
2099 .is_some_and(|char| Some(char) == query_chars.peek())
2100 {
2101 global.next();
2102 query_chars.next();
2103 }
2104 if !invert && query_chars.peek() == Some(&'!') {
2105 invert = true;
2106 query_chars.next();
2107 }
2108 let range = range.clone().unwrap_or(CommandRange {
2109 start: Position::Line { row: 0, offset: 0 },
2110 end: Some(Position::LastLine { offset: 0 }),
2111 });
2112
2113 let delimiter = query_chars.next().filter(|c| {
2114 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
2115 })?;
2116
2117 let mut search = String::new();
2118 let mut escaped = false;
2119
2120 for c in query_chars.by_ref() {
2121 if escaped {
2122 escaped = false;
2123 // unescape escaped parens
2124 if c != '(' && c != ')' && c != delimiter {
2125 search.push('\\')
2126 }
2127 search.push(c)
2128 } else if c == '\\' {
2129 escaped = true;
2130 } else if c == delimiter {
2131 break;
2132 } else {
2133 // escape unescaped parens
2134 if c == '(' || c == ')' {
2135 search.push('\\')
2136 }
2137 search.push(c)
2138 }
2139 }
2140
2141 Some((query_chars.collect::<String>(), range, search, invert))
2142 }
2143
2144 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2145 let result = vim.update_editor(cx, |vim, editor, cx| {
2146 self.range.buffer_range(vim, editor, window, cx)
2147 });
2148
2149 let range = match result {
2150 None => return,
2151 Some(e @ Err(_)) => {
2152 let Some(workspace) = vim.workspace(window, cx) else {
2153 return;
2154 };
2155 workspace.update(cx, |workspace, cx| {
2156 e.notify_err(workspace, cx);
2157 });
2158 return;
2159 }
2160 Some(Ok(result)) => result,
2161 };
2162
2163 let mut action = self.action.boxed_clone();
2164 let mut last_pattern = self.search.clone();
2165
2166 let mut regexes = match Regex::new(&self.search) {
2167 Ok(regex) => vec![(regex, !self.invert)],
2168 e @ Err(_) => {
2169 let Some(workspace) = vim.workspace(window, cx) else {
2170 return;
2171 };
2172 workspace.update(cx, |workspace, cx| {
2173 e.notify_err(workspace, cx);
2174 });
2175 return;
2176 }
2177 };
2178 while let Some(inner) = action
2179 .boxed_clone()
2180 .as_any()
2181 .downcast_ref::<OnMatchingLines>()
2182 {
2183 let Some(regex) = Regex::new(&inner.search).ok() else {
2184 break;
2185 };
2186 last_pattern = inner.search.clone();
2187 action = inner.action.boxed_clone();
2188 regexes.push((regex, !inner.invert))
2189 }
2190
2191 if let Some(pane) = vim.pane(window, cx) {
2192 pane.update(cx, |pane, cx| {
2193 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
2194 {
2195 search_bar.update(cx, |search_bar, cx| {
2196 if search_bar.show(window, cx) {
2197 let _ = search_bar.search(
2198 &last_pattern,
2199 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
2200 false,
2201 window,
2202 cx,
2203 );
2204 }
2205 });
2206 }
2207 });
2208 };
2209
2210 vim.update_editor(cx, |_, editor, cx| {
2211 let snapshot = editor.snapshot(window, cx);
2212 let mut row = range.start.0;
2213
2214 let point_range = Point::new(range.start.0, 0)
2215 ..snapshot
2216 .buffer_snapshot()
2217 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
2218 cx.spawn_in(window, async move |editor, cx| {
2219 let new_selections = cx
2220 .background_spawn(async move {
2221 let mut line = String::new();
2222 let mut new_selections = Vec::new();
2223 let chunks = snapshot
2224 .buffer_snapshot()
2225 .text_for_range(point_range)
2226 .chain(["\n"]);
2227
2228 for chunk in chunks {
2229 for (newline_ix, text) in chunk.split('\n').enumerate() {
2230 if newline_ix > 0 {
2231 if regexes.iter().all(|(regex, should_match)| {
2232 regex.is_match(&line) == *should_match
2233 }) {
2234 new_selections
2235 .push(Point::new(row, 0).to_display_point(&snapshot))
2236 }
2237 row += 1;
2238 line.clear();
2239 }
2240 line.push_str(text)
2241 }
2242 }
2243
2244 new_selections
2245 })
2246 .await;
2247
2248 if new_selections.is_empty() {
2249 return;
2250 }
2251
2252 if let Some(vim_norm) = action.as_any().downcast_ref::<VimNorm>() {
2253 let mut vim_norm = vim_norm.clone();
2254 vim_norm.override_rows =
2255 Some(new_selections.iter().map(|point| point.row().0).collect());
2256 editor
2257 .update_in(cx, |_, window, cx| {
2258 window.dispatch_action(vim_norm.boxed_clone(), cx);
2259 })
2260 .log_err();
2261 return;
2262 }
2263
2264 editor
2265 .update_in(cx, |editor, window, cx| {
2266 editor.start_transaction_at(Instant::now(), window, cx);
2267 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2268 s.replace_cursors_with(|_| new_selections);
2269 });
2270 window.dispatch_action(action, cx);
2271
2272 cx.defer_in(window, move |editor, window, cx| {
2273 let newest = editor
2274 .selections
2275 .newest::<Point>(&editor.display_snapshot(cx));
2276 editor.change_selections(
2277 SelectionEffects::no_scroll(),
2278 window,
2279 cx,
2280 |s| {
2281 s.select(vec![newest]);
2282 },
2283 );
2284 editor.end_transaction_at(Instant::now(), cx);
2285 })
2286 })
2287 .log_err();
2288 })
2289 .detach();
2290 });
2291 }
2292}
2293
2294/// Executes a shell command and returns the output.
2295#[derive(Clone, Debug, PartialEq, Action)]
2296#[action(namespace = vim, no_json, no_register)]
2297pub struct ShellExec {
2298 command: String,
2299 range: Option<CommandRange>,
2300 is_read: bool,
2301}
2302
2303impl Vim {
2304 pub fn cancel_running_command(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2305 if self.running_command.take().is_some() {
2306 self.update_editor(cx, |_, editor, cx| {
2307 editor.transact(window, cx, |editor, _window, _cx| {
2308 editor.clear_row_highlights::<ShellExec>();
2309 })
2310 });
2311 }
2312 }
2313
2314 fn prepare_shell_command(
2315 &mut self,
2316 command: &str,
2317 _: &mut Window,
2318 cx: &mut Context<Self>,
2319 ) -> String {
2320 let mut ret = String::new();
2321 // N.B. non-standard escaping rules:
2322 // * !echo % => "echo README.md"
2323 // * !echo \% => "echo %"
2324 // * !echo \\% => echo \%
2325 // * !echo \\\% => echo \\%
2326 for c in command.chars() {
2327 if c != '%' && c != '!' {
2328 ret.push(c);
2329 continue;
2330 } else if ret.chars().last() == Some('\\') {
2331 ret.pop();
2332 ret.push(c);
2333 continue;
2334 }
2335 match c {
2336 '%' => {
2337 self.update_editor(cx, |_, editor, cx| {
2338 if let Some(buffer) = editor.active_buffer(cx)
2339 && let Some(file) = buffer.read(cx).file()
2340 && let Some(local) = file.as_local()
2341 {
2342 ret.push_str(&local.path().display(local.path_style(cx)));
2343 }
2344 });
2345 }
2346 '!' => {
2347 if let Some(command) = &self.last_command {
2348 ret.push_str(command)
2349 }
2350 }
2351 _ => {}
2352 }
2353 }
2354 self.last_command = Some(ret.clone());
2355 ret
2356 }
2357
2358 pub fn shell_command_motion(
2359 &mut self,
2360 motion: Motion,
2361 times: Option<usize>,
2362 forced_motion: bool,
2363 window: &mut Window,
2364 cx: &mut Context<Vim>,
2365 ) {
2366 self.stop_recording(cx);
2367 let Some(workspace) = self.workspace(window, cx) else {
2368 return;
2369 };
2370 let command = self.update_editor(cx, |_, editor, cx| {
2371 let snapshot = editor.snapshot(window, cx);
2372 let start = editor
2373 .selections
2374 .newest_display(&editor.display_snapshot(cx));
2375 let text_layout_details = editor.text_layout_details(window, cx);
2376 let (mut range, _) = motion
2377 .range(
2378 &snapshot,
2379 start.clone(),
2380 times,
2381 &text_layout_details,
2382 forced_motion,
2383 )
2384 .unwrap_or((start.range(), MotionKind::Exclusive));
2385 if range.start != start.start {
2386 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2387 s.select_ranges([
2388 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2389 ]);
2390 })
2391 }
2392 if range.end.row() > range.start.row() && range.end.column() != 0 {
2393 *range.end.row_mut() -= 1
2394 }
2395 if range.end.row() == range.start.row() {
2396 ".!".to_string()
2397 } else {
2398 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2399 }
2400 });
2401 if let Some(command) = command {
2402 workspace.update(cx, |workspace, cx| {
2403 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2404 });
2405 }
2406 }
2407
2408 pub fn shell_command_object(
2409 &mut self,
2410 object: Object,
2411 around: bool,
2412 window: &mut Window,
2413 cx: &mut Context<Vim>,
2414 ) {
2415 self.stop_recording(cx);
2416 let Some(workspace) = self.workspace(window, cx) else {
2417 return;
2418 };
2419 let command = self.update_editor(cx, |_, editor, cx| {
2420 let snapshot = editor.snapshot(window, cx);
2421 let start = editor
2422 .selections
2423 .newest_display(&editor.display_snapshot(cx));
2424 let range = object
2425 .range(&snapshot, start.clone(), around, None)
2426 .unwrap_or(start.range());
2427 if range.start != start.start {
2428 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
2429 s.select_ranges([
2430 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
2431 ]);
2432 })
2433 }
2434 if range.end.row() == range.start.row() {
2435 ".!".to_string()
2436 } else {
2437 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
2438 }
2439 });
2440 if let Some(command) = command {
2441 workspace.update(cx, |workspace, cx| {
2442 command_palette::CommandPalette::toggle(workspace, &command, window, cx);
2443 });
2444 }
2445 }
2446}
2447
2448impl ShellExec {
2449 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
2450 let (before, after) = query.split_once('!')?;
2451 let before = before.trim();
2452
2453 if !"read".starts_with(before) {
2454 return None;
2455 }
2456
2457 Some(
2458 ShellExec {
2459 command: after.trim().to_string(),
2460 range,
2461 is_read: !before.is_empty(),
2462 }
2463 .boxed_clone(),
2464 )
2465 }
2466
2467 pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context<Vim>) {
2468 let Some(workspace) = vim.workspace(window, cx) else {
2469 return;
2470 };
2471
2472 let project = workspace.read(cx).project().clone();
2473 let command = vim.prepare_shell_command(&self.command, window, cx);
2474
2475 if self.range.is_none() && !self.is_read {
2476 workspace.update(cx, |workspace, cx| {
2477 let project = workspace.project().read(cx);
2478 let cwd = project.first_project_directory(cx);
2479 let shell = project.terminal_settings(&cwd, cx).shell.clone();
2480
2481 let spawn_in_terminal = SpawnInTerminal {
2482 id: TaskId("vim".to_string()),
2483 full_label: command.clone(),
2484 label: command.clone(),
2485 command: Some(command.clone()),
2486 args: Vec::new(),
2487 command_label: command.clone(),
2488 cwd,
2489 env: HashMap::default(),
2490 use_new_terminal: true,
2491 allow_concurrent_runs: true,
2492 reveal: RevealStrategy::NoFocus,
2493 reveal_target: RevealTarget::Dock,
2494 hide: HideStrategy::Never,
2495 shell,
2496 show_summary: false,
2497 show_command: false,
2498 show_rerun: false,
2499 save: SaveStrategy::default(),
2500 };
2501
2502 let task_status = workspace.spawn_in_terminal(spawn_in_terminal, window, cx);
2503 cx.background_spawn(async move {
2504 match task_status.await {
2505 Some(Ok(status)) => {
2506 if status.success() {
2507 log::debug!("Vim shell exec succeeded");
2508 } else {
2509 log::debug!("Vim shell exec failed, code: {:?}", status.code());
2510 }
2511 }
2512 Some(Err(e)) => log::error!("Vim shell exec failed: {e}"),
2513 None => log::debug!("Vim shell exec got cancelled"),
2514 }
2515 })
2516 .detach();
2517 });
2518 return;
2519 };
2520
2521 let mut input_snapshot = None;
2522 let mut input_range = None;
2523 let mut needs_newline_prefix = false;
2524 vim.update_editor(cx, |vim, editor, cx| {
2525 let snapshot = editor.buffer().read(cx).snapshot(cx);
2526 let range = if let Some(range) = self.range.clone() {
2527 let Some(range) = range.buffer_range(vim, editor, window, cx).log_err() else {
2528 return;
2529 };
2530 Point::new(range.start.0, 0)
2531 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
2532 } else {
2533 let mut end = editor
2534 .selections
2535 .newest::<Point>(&editor.display_snapshot(cx))
2536 .range()
2537 .end;
2538 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
2539 needs_newline_prefix = end == snapshot.max_point();
2540 end..end
2541 };
2542 if self.is_read {
2543 input_range =
2544 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
2545 } else {
2546 input_range =
2547 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
2548 }
2549 editor.highlight_rows::<ShellExec>(
2550 input_range.clone().unwrap(),
2551 cx.theme().status().unreachable_background,
2552 Default::default(),
2553 cx,
2554 );
2555
2556 if !self.is_read {
2557 input_snapshot = Some(snapshot)
2558 }
2559 });
2560
2561 let Some(range) = input_range else { return };
2562
2563 let process_task = project.update(cx, |project, cx| project.exec_in_shell(command, cx));
2564
2565 let is_read = self.is_read;
2566
2567 let task = cx.spawn_in(window, async move |vim, cx| {
2568 let Some(mut process) = process_task.await.log_err() else {
2569 return;
2570 };
2571 process.stdout(Stdio::piped());
2572 process.stderr(Stdio::piped());
2573
2574 if input_snapshot.is_some() {
2575 process.stdin(Stdio::piped());
2576 } else {
2577 process.stdin(Stdio::null());
2578 };
2579
2580 let Some(mut running) = process.spawn().log_err() else {
2581 vim.update_in(cx, |vim, window, cx| {
2582 vim.cancel_running_command(window, cx);
2583 })
2584 .log_err();
2585 return;
2586 };
2587
2588 if let Some(mut stdin) = running.stdin.take()
2589 && let Some(snapshot) = input_snapshot
2590 {
2591 let range = range.clone();
2592 cx.background_spawn(async move {
2593 for chunk in snapshot.text_for_range(range) {
2594 if stdin.write_all(chunk.as_bytes()).await.log_err().is_none() {
2595 return;
2596 }
2597 }
2598 stdin.flush().await.log_err();
2599 })
2600 .detach();
2601 };
2602
2603 let output = cx.background_spawn(running.output()).await;
2604
2605 let Some(output) = output.log_err() else {
2606 vim.update_in(cx, |vim, window, cx| {
2607 vim.cancel_running_command(window, cx);
2608 })
2609 .log_err();
2610 return;
2611 };
2612 let mut text = String::new();
2613 if needs_newline_prefix {
2614 text.push('\n');
2615 }
2616 text.push_str(&String::from_utf8_lossy(&output.stdout));
2617 text.push_str(&String::from_utf8_lossy(&output.stderr));
2618 if !text.is_empty() && text.chars().last() != Some('\n') {
2619 text.push('\n');
2620 }
2621
2622 vim.update_in(cx, |vim, window, cx| {
2623 vim.update_editor(cx, |_, editor, cx| {
2624 editor.transact(window, cx, |editor, window, cx| {
2625 editor.edit([(range.clone(), text)], cx);
2626 let snapshot = editor.buffer().read(cx).snapshot(cx);
2627 editor.change_selections(Default::default(), window, cx, |s| {
2628 let point = if is_read {
2629 let point = range.end.to_point(&snapshot);
2630 Point::new(point.row.saturating_sub(1), 0)
2631 } else {
2632 let point = range.start.to_point(&snapshot);
2633 Point::new(point.row, 0)
2634 };
2635 s.select_ranges([point..point]);
2636 })
2637 })
2638 });
2639 vim.cancel_running_command(window, cx);
2640 })
2641 .log_err();
2642 });
2643 vim.running_command.replace(task);
2644 }
2645}
2646
2647#[cfg(test)]
2648mod test {
2649 use std::path::{Path, PathBuf};
2650
2651 use crate::{
2652 VimAddon,
2653 state::Mode,
2654 test::{NeovimBackedTestContext, VimTestContext},
2655 };
2656 use editor::{Editor, EditorSettings};
2657 use gpui::{Context, TestAppContext};
2658 use indoc::indoc;
2659 use settings::Settings;
2660 use util::path;
2661 use workspace::{OpenOptions, Workspace};
2662
2663 #[gpui::test]
2664 async fn test_command_basics(cx: &mut TestAppContext) {
2665 let mut cx = NeovimBackedTestContext::new(cx).await;
2666
2667 cx.set_shared_state(indoc! {"
2668 ˇa
2669 b
2670 c"})
2671 .await;
2672
2673 cx.simulate_shared_keystrokes(": j enter").await;
2674
2675 // hack: our cursor positioning after a join command is wrong
2676 cx.simulate_shared_keystrokes("^").await;
2677 cx.shared_state().await.assert_eq(indoc! {
2678 "ˇa b
2679 c"
2680 });
2681 }
2682
2683 #[gpui::test]
2684 async fn test_command_goto(cx: &mut TestAppContext) {
2685 let mut cx = NeovimBackedTestContext::new(cx).await;
2686
2687 cx.set_shared_state(indoc! {"
2688 ˇa
2689 b
2690 c"})
2691 .await;
2692 cx.simulate_shared_keystrokes(": 3 enter").await;
2693 cx.shared_state().await.assert_eq(indoc! {"
2694 a
2695 b
2696 ˇc"});
2697 }
2698
2699 #[gpui::test]
2700 async fn test_command_replace(cx: &mut TestAppContext) {
2701 let mut cx = NeovimBackedTestContext::new(cx).await;
2702
2703 cx.set_shared_state(indoc! {"
2704 ˇa
2705 b
2706 b
2707 c"})
2708 .await;
2709 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
2710 cx.shared_state().await.assert_eq(indoc! {"
2711 a
2712 d
2713 ˇd
2714 c"});
2715 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
2716 .await;
2717 cx.shared_state().await.assert_eq(indoc! {"
2718 aa
2719 dd
2720 dd
2721 ˇcc"});
2722 cx.simulate_shared_keystrokes("k : s / d d / e e enter")
2723 .await;
2724 cx.shared_state().await.assert_eq(indoc! {"
2725 aa
2726 dd
2727 ˇee
2728 cc"});
2729 }
2730
2731 #[gpui::test]
2732 async fn test_command_search(cx: &mut TestAppContext) {
2733 let mut cx = NeovimBackedTestContext::new(cx).await;
2734
2735 cx.set_shared_state(indoc! {"
2736 ˇa
2737 b
2738 a
2739 c"})
2740 .await;
2741 cx.simulate_shared_keystrokes(": / b enter").await;
2742 cx.shared_state().await.assert_eq(indoc! {"
2743 a
2744 ˇb
2745 a
2746 c"});
2747 cx.simulate_shared_keystrokes(": ? a enter").await;
2748 cx.shared_state().await.assert_eq(indoc! {"
2749 ˇa
2750 b
2751 a
2752 c"});
2753 }
2754
2755 #[gpui::test]
2756 async fn test_command_write(cx: &mut TestAppContext) {
2757 let mut cx = VimTestContext::new(cx, true).await;
2758 let path = Path::new(path!("/root/dir/file.rs"));
2759 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2760
2761 cx.simulate_keystrokes("i @ escape");
2762 cx.simulate_keystrokes(": w enter");
2763
2764 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@\n");
2765
2766 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
2767
2768 // conflict!
2769 cx.simulate_keystrokes("i @ escape");
2770 cx.simulate_keystrokes(": w enter");
2771 cx.simulate_prompt_answer("Cancel");
2772
2773 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "oops\n");
2774 assert!(!cx.has_pending_prompt());
2775 cx.simulate_keystrokes(": w !");
2776 cx.simulate_keystrokes("enter");
2777 assert!(!cx.has_pending_prompt());
2778 assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
2779 }
2780
2781 #[gpui::test]
2782 async fn test_command_read(cx: &mut TestAppContext) {
2783 let mut cx = VimTestContext::new(cx, true).await;
2784
2785 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2786 let path = Path::new(path!("/root/dir/other.rs"));
2787 fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
2788
2789 cx.workspace(|workspace, _, cx| {
2790 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2791 });
2792
2793 // File without trailing newline
2794 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2795 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2796 cx.simulate_keystrokes("enter");
2797 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
2798
2799 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2800 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2801 cx.simulate_keystrokes("enter");
2802 cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
2803
2804 cx.set_state("one\nˇtwo\nthree", Mode::Normal);
2805 cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
2806 cx.simulate_keystrokes("enter");
2807 cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
2808
2809 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2810 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2811 cx.simulate_keystrokes("enter");
2812 cx.run_until_parked();
2813 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
2814
2815 // Empty filename
2816 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2817 cx.simulate_keystrokes(": r");
2818 cx.simulate_keystrokes("enter");
2819 cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
2820
2821 // File with trailing newline
2822 fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
2823 cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
2824 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2825 cx.simulate_keystrokes("enter");
2826 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2827
2828 cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
2829 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2830 cx.simulate_keystrokes("enter");
2831 cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
2832
2833 cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
2834 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2835 cx.simulate_keystrokes("enter");
2836 cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
2837
2838 cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
2839 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2840 cx.simulate_keystrokes("enter");
2841 cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
2842
2843 // Empty file
2844 fs.as_fake().insert_file(path, "".into()).await;
2845 cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
2846 cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
2847 cx.simulate_keystrokes("enter");
2848 cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
2849 }
2850
2851 #[gpui::test]
2852 async fn test_command_quit(cx: &mut TestAppContext) {
2853 let mut cx = VimTestContext::new(cx, true).await;
2854
2855 cx.simulate_keystrokes(": n e w enter");
2856 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2857 cx.simulate_keystrokes(": q enter");
2858 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2859 cx.simulate_keystrokes(": n e w enter");
2860 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2861 cx.simulate_keystrokes(": q a enter");
2862 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 0));
2863 }
2864
2865 #[gpui::test]
2866 async fn test_offsets(cx: &mut TestAppContext) {
2867 let mut cx = NeovimBackedTestContext::new(cx).await;
2868
2869 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
2870 .await;
2871
2872 cx.simulate_shared_keystrokes(": + enter").await;
2873 cx.shared_state()
2874 .await
2875 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
2876
2877 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
2878 cx.shared_state()
2879 .await
2880 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
2881
2882 cx.simulate_shared_keystrokes(": . - 2 enter").await;
2883 cx.shared_state()
2884 .await
2885 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
2886
2887 cx.simulate_shared_keystrokes(": % enter").await;
2888 cx.shared_state()
2889 .await
2890 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
2891 }
2892
2893 #[gpui::test]
2894 async fn test_command_ranges(cx: &mut TestAppContext) {
2895 let mut cx = NeovimBackedTestContext::new(cx).await;
2896
2897 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2898
2899 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
2900 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
2901
2902 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
2903 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
2904
2905 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
2906 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
2907 }
2908
2909 #[gpui::test]
2910 async fn test_command_visual_replace(cx: &mut TestAppContext) {
2911 let mut cx = NeovimBackedTestContext::new(cx).await;
2912
2913 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
2914
2915 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
2916 .await;
2917 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
2918 }
2919
2920 #[track_caller]
2921 fn assert_active_item(
2922 workspace: &mut Workspace,
2923 expected_path: &str,
2924 expected_text: &str,
2925 cx: &mut Context<Workspace>,
2926 ) {
2927 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2928
2929 let buffer = active_editor
2930 .read(cx)
2931 .buffer()
2932 .read(cx)
2933 .as_singleton()
2934 .unwrap();
2935
2936 let text = buffer.read(cx).text();
2937 let file = buffer.read(cx).file().unwrap();
2938 let file_path = file.as_local().unwrap().abs_path(cx);
2939
2940 assert_eq!(text, expected_text);
2941 assert_eq!(file_path, Path::new(expected_path));
2942 }
2943
2944 #[gpui::test]
2945 async fn test_command_gf(cx: &mut TestAppContext) {
2946 let mut cx = VimTestContext::new(cx, true).await;
2947
2948 // Assert base state, that we're in /root/dir/file.rs
2949 cx.workspace(|workspace, _, cx| {
2950 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
2951 });
2952
2953 // Insert a new file
2954 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2955 fs.as_fake()
2956 .insert_file(
2957 path!("/root/dir/file2.rs"),
2958 "This is file2.rs".as_bytes().to_vec(),
2959 )
2960 .await;
2961 fs.as_fake()
2962 .insert_file(
2963 path!("/root/dir/file3.rs"),
2964 "go to file3".as_bytes().to_vec(),
2965 )
2966 .await;
2967
2968 // Put the path to the second file into the currently open buffer
2969 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
2970
2971 // Go to file2.rs
2972 cx.simulate_keystrokes("g f");
2973
2974 // We now have two items
2975 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2976 cx.workspace(|workspace, _, cx| {
2977 assert_active_item(
2978 workspace,
2979 path!("/root/dir/file2.rs"),
2980 "This is file2.rs",
2981 cx,
2982 );
2983 });
2984
2985 // Update editor to point to `file2.rs`
2986 cx.editor =
2987 cx.workspace(|workspace, _, cx| workspace.active_item_as::<Editor>(cx).unwrap());
2988
2989 // Put the path to the third file into the currently open buffer,
2990 // but remove its suffix, because we want that lookup to happen automatically.
2991 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
2992
2993 // Go to file3.rs
2994 cx.simulate_keystrokes("g f");
2995
2996 // We now have three items
2997 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
2998 cx.workspace(|workspace, _, cx| {
2999 assert_active_item(workspace, path!("/root/dir/file3.rs"), "go to file3", cx);
3000 });
3001 }
3002
3003 #[gpui::test]
3004 async fn test_command_write_filename(cx: &mut TestAppContext) {
3005 let mut cx = VimTestContext::new(cx, true).await;
3006
3007 cx.workspace(|workspace, _, cx| {
3008 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3009 });
3010
3011 cx.simulate_keystrokes(": w space other.rs");
3012 cx.simulate_keystrokes("enter");
3013
3014 cx.workspace(|workspace, _, cx| {
3015 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
3016 });
3017
3018 cx.simulate_keystrokes(": w space dir/file.rs");
3019 cx.simulate_keystrokes("enter");
3020
3021 cx.simulate_prompt_answer("Replace");
3022 cx.run_until_parked();
3023
3024 cx.workspace(|workspace, _, cx| {
3025 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3026 });
3027
3028 cx.simulate_keystrokes(": w ! space other.rs");
3029 cx.simulate_keystrokes("enter");
3030
3031 cx.workspace(|workspace, _, cx| {
3032 assert_active_item(workspace, path!("/root/other.rs"), "", cx);
3033 });
3034 }
3035
3036 #[gpui::test]
3037 async fn test_command_write_range(cx: &mut TestAppContext) {
3038 let mut cx = VimTestContext::new(cx, true).await;
3039
3040 cx.workspace(|workspace, _, cx| {
3041 assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
3042 });
3043
3044 cx.set_state(
3045 indoc! {"
3046 The quick
3047 brown« fox
3048 jumpsˇ» over
3049 the lazy dog
3050 "},
3051 Mode::Visual,
3052 );
3053
3054 cx.simulate_keystrokes(": w space dir/other.rs");
3055 cx.simulate_keystrokes("enter");
3056
3057 let other = path!("/root/dir/other.rs");
3058
3059 let _ = cx
3060 .workspace(|workspace, window, cx| {
3061 workspace.open_abs_path(PathBuf::from(other), OpenOptions::default(), window, cx)
3062 })
3063 .await;
3064
3065 cx.workspace(|workspace, _, cx| {
3066 assert_active_item(
3067 workspace,
3068 other,
3069 indoc! {"
3070 brown fox
3071 jumps over
3072 "},
3073 cx,
3074 );
3075 });
3076 }
3077
3078 #[gpui::test]
3079 async fn test_command_matching_lines(cx: &mut TestAppContext) {
3080 let mut cx = NeovimBackedTestContext::new(cx).await;
3081
3082 cx.set_shared_state(indoc! {"
3083 ˇa
3084 b
3085 a
3086 b
3087 a
3088 "})
3089 .await;
3090
3091 cx.simulate_shared_keystrokes(":").await;
3092 cx.simulate_shared_keystrokes("g / a / d").await;
3093 cx.simulate_shared_keystrokes("enter").await;
3094
3095 cx.shared_state().await.assert_eq(indoc! {"
3096 b
3097 b
3098 ˇ"});
3099
3100 cx.simulate_shared_keystrokes("u").await;
3101
3102 cx.shared_state().await.assert_eq(indoc! {"
3103 ˇa
3104 b
3105 a
3106 b
3107 a
3108 "});
3109
3110 cx.simulate_shared_keystrokes(":").await;
3111 cx.simulate_shared_keystrokes("v / a / d").await;
3112 cx.simulate_shared_keystrokes("enter").await;
3113
3114 cx.shared_state().await.assert_eq(indoc! {"
3115 a
3116 a
3117 ˇa"});
3118 }
3119
3120 #[gpui::test]
3121 async fn test_del_marks(cx: &mut TestAppContext) {
3122 let mut cx = NeovimBackedTestContext::new(cx).await;
3123
3124 cx.set_shared_state(indoc! {"
3125 ˇa
3126 b
3127 a
3128 b
3129 a
3130 "})
3131 .await;
3132
3133 cx.simulate_shared_keystrokes("m a").await;
3134
3135 let mark = cx.update_editor(|editor, window, cx| {
3136 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3137 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3138 });
3139 assert!(mark.is_some());
3140
3141 cx.simulate_shared_keystrokes(": d e l m space a").await;
3142 cx.simulate_shared_keystrokes("enter").await;
3143
3144 let mark = cx.update_editor(|editor, window, cx| {
3145 let vim = editor.addon::<VimAddon>().unwrap().entity.clone();
3146 vim.update(cx, |vim, cx| vim.get_mark("a", editor, window, cx))
3147 });
3148 assert!(mark.is_none())
3149 }
3150
3151 #[gpui::test]
3152 async fn test_normal_command(cx: &mut TestAppContext) {
3153 let mut cx = NeovimBackedTestContext::new(cx).await;
3154
3155 cx.set_shared_state(indoc! {"
3156 The quick
3157 brown« fox
3158 jumpsˇ» over
3159 the lazy dog
3160 "})
3161 .await;
3162
3163 cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
3164 .await;
3165 cx.simulate_shared_keystrokes("enter").await;
3166
3167 cx.shared_state().await.assert_eq(indoc! {"
3168 The quick
3169 brown word
3170 jumps worˇd
3171 the lazy dog
3172 "});
3173
3174 cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
3175 .await;
3176 cx.simulate_shared_keystrokes("enter").await;
3177
3178 cx.shared_state().await.assert_eq(indoc! {"
3179 The quick
3180 brown word
3181 jumps tesˇt
3182 the lazy dog
3183 "});
3184
3185 cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
3186 .await;
3187 cx.simulate_shared_keystrokes("enter").await;
3188
3189 cx.shared_state().await.assert_eq(indoc! {"
3190 The quick
3191 brown word
3192 lˇaumps test
3193 the lazy dog
3194 "});
3195
3196 cx.set_shared_state(indoc! {"
3197 ˇThe quick
3198 brown fox
3199 jumps over
3200 the lazy dog
3201 "})
3202 .await;
3203
3204 cx.simulate_shared_keystrokes("c i w M y escape").await;
3205
3206 cx.shared_state().await.assert_eq(indoc! {"
3207 Mˇy quick
3208 brown fox
3209 jumps over
3210 the lazy dog
3211 "});
3212
3213 cx.simulate_shared_keystrokes(": n o r m space u").await;
3214 cx.simulate_shared_keystrokes("enter").await;
3215
3216 cx.shared_state().await.assert_eq(indoc! {"
3217 ˇThe quick
3218 brown fox
3219 jumps over
3220 the lazy dog
3221 "});
3222
3223 cx.set_shared_state(indoc! {"
3224 The« quick
3225 brownˇ» fox
3226 jumps over
3227 the lazy dog
3228 "})
3229 .await;
3230
3231 cx.simulate_shared_keystrokes(": n o r m space I 1 2 3")
3232 .await;
3233 cx.simulate_shared_keystrokes("enter").await;
3234 cx.simulate_shared_keystrokes("u").await;
3235
3236 cx.shared_state().await.assert_eq(indoc! {"
3237 ˇThe quick
3238 brown fox
3239 jumps over
3240 the lazy dog
3241 "});
3242
3243 cx.set_shared_state(indoc! {"
3244 ˇquick
3245 brown fox
3246 jumps over
3247 the lazy dog
3248 "})
3249 .await;
3250
3251 cx.simulate_shared_keystrokes(": n o r m space I T h e space")
3252 .await;
3253 cx.simulate_shared_keystrokes("enter").await;
3254
3255 cx.shared_state().await.assert_eq(indoc! {"
3256 Theˇ quick
3257 brown fox
3258 jumps over
3259 the lazy dog
3260 "});
3261
3262 // Once ctrl-v to input character literals is added there should be a test for redo
3263 }
3264
3265 #[gpui::test]
3266 async fn test_command_g_normal(cx: &mut TestAppContext) {
3267 let mut cx = NeovimBackedTestContext::new(cx).await;
3268
3269 cx.set_shared_state(indoc! {"
3270 ˇfoo
3271
3272 foo
3273 "})
3274 .await;
3275
3276 cx.simulate_shared_keystrokes(": % g / f o o / n o r m space A b a r")
3277 .await;
3278 cx.simulate_shared_keystrokes("enter").await;
3279 cx.run_until_parked();
3280
3281 cx.shared_state().await.assert_eq(indoc! {"
3282 foobar
3283
3284 foobaˇr
3285 "});
3286
3287 cx.simulate_shared_keystrokes("u").await;
3288
3289 cx.shared_state().await.assert_eq(indoc! {"
3290 foˇo
3291
3292 foo
3293 "});
3294 }
3295
3296 #[gpui::test]
3297 async fn test_command_tabnew(cx: &mut TestAppContext) {
3298 let mut cx = VimTestContext::new(cx, true).await;
3299
3300 // Create a new file to ensure that, when the filename is used with
3301 // `:tabnew`, it opens the existing file in a new tab.
3302 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3303 fs.as_fake()
3304 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3305 .await;
3306
3307 cx.simulate_keystrokes(": tabnew");
3308 cx.simulate_keystrokes("enter");
3309 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3310
3311 // Assert that the new tab is empty and not associated with any file, as
3312 // no file path was provided to the `:tabnew` command.
3313 cx.workspace(|workspace, _window, cx| {
3314 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3315 let buffer = active_editor
3316 .read(cx)
3317 .buffer()
3318 .read(cx)
3319 .as_singleton()
3320 .unwrap();
3321
3322 assert!(&buffer.read(cx).file().is_none());
3323 });
3324
3325 // Leverage the filename as an argument to the `:tabnew` command,
3326 // ensuring that the file, instead of an empty buffer, is opened in a
3327 // new tab.
3328 cx.simulate_keystrokes(": tabnew space dir/file_2.rs");
3329 cx.simulate_keystrokes("enter");
3330
3331 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3332 cx.workspace(|workspace, _, cx| {
3333 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3334 });
3335
3336 // If the `filename` argument provided to the `:tabnew` command is for a
3337 // file that doesn't yet exist, it should still associate the buffer
3338 // with that file path, so that when the buffer contents are saved, the
3339 // file is created.
3340 cx.simulate_keystrokes(": tabnew space dir/file_3.rs");
3341 cx.simulate_keystrokes("enter");
3342
3343 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3344 cx.workspace(|workspace, _, cx| {
3345 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3346 });
3347 }
3348
3349 #[gpui::test]
3350 async fn test_command_tabedit(cx: &mut TestAppContext) {
3351 let mut cx = VimTestContext::new(cx, true).await;
3352
3353 // Create a new file to ensure that, when the filename is used with
3354 // `:tabedit`, it opens the existing file in a new tab.
3355 let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
3356 fs.as_fake()
3357 .insert_file(path!("/root/dir/file_2.rs"), "file_2".as_bytes().to_vec())
3358 .await;
3359
3360 cx.simulate_keystrokes(": tabedit");
3361 cx.simulate_keystrokes("enter");
3362 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
3363
3364 // Assert that the new tab is empty and not associated with any file, as
3365 // no file path was provided to the `:tabedit` command.
3366 cx.workspace(|workspace, _window, cx| {
3367 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
3368 let buffer = active_editor
3369 .read(cx)
3370 .buffer()
3371 .read(cx)
3372 .as_singleton()
3373 .unwrap();
3374
3375 assert!(&buffer.read(cx).file().is_none());
3376 });
3377
3378 // Leverage the filename as an argument to the `:tabedit` command,
3379 // ensuring that the file, instead of an empty buffer, is opened in a
3380 // new tab.
3381 cx.simulate_keystrokes(": tabedit space dir/file_2.rs");
3382 cx.simulate_keystrokes("enter");
3383
3384 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 3));
3385 cx.workspace(|workspace, _, cx| {
3386 assert_active_item(workspace, path!("/root/dir/file_2.rs"), "file_2", cx);
3387 });
3388
3389 // If the `filename` argument provided to the `:tabedit` command is for a
3390 // file that doesn't yet exist, it should still associate the buffer
3391 // with that file path, so that when the buffer contents are saved, the
3392 // file is created.
3393 cx.simulate_keystrokes(": tabedit space dir/file_3.rs");
3394 cx.simulate_keystrokes("enter");
3395
3396 cx.workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 4));
3397 cx.workspace(|workspace, _, cx| {
3398 assert_active_item(workspace, path!("/root/dir/file_3.rs"), "", cx);
3399 });
3400 }
3401
3402 #[gpui::test]
3403 async fn test_ignorecase_command(cx: &mut TestAppContext) {
3404 let mut cx = VimTestContext::new(cx, true).await;
3405 cx.read(|cx| {
3406 assert_eq!(
3407 EditorSettings::get_global(cx).search.case_sensitive,
3408 false,
3409 "The `case_sensitive` setting should be `false` by default."
3410 );
3411 });
3412 cx.simulate_keystrokes(": set space noignorecase");
3413 cx.simulate_keystrokes("enter");
3414 cx.read(|cx| {
3415 assert_eq!(
3416 EditorSettings::get_global(cx).search.case_sensitive,
3417 true,
3418 "The `case_sensitive` setting should have been enabled with `:set noignorecase`."
3419 );
3420 });
3421 cx.simulate_keystrokes(": set space ignorecase");
3422 cx.simulate_keystrokes("enter");
3423 cx.read(|cx| {
3424 assert_eq!(
3425 EditorSettings::get_global(cx).search.case_sensitive,
3426 false,
3427 "The `case_sensitive` setting should have been disabled with `:set ignorecase`."
3428 );
3429 });
3430 cx.simulate_keystrokes(": set space noic");
3431 cx.simulate_keystrokes("enter");
3432 cx.read(|cx| {
3433 assert_eq!(
3434 EditorSettings::get_global(cx).search.case_sensitive,
3435 true,
3436 "The `case_sensitive` setting should have been enabled with `:set noic`."
3437 );
3438 });
3439 cx.simulate_keystrokes(": set space ic");
3440 cx.simulate_keystrokes("enter");
3441 cx.read(|cx| {
3442 assert_eq!(
3443 EditorSettings::get_global(cx).search.case_sensitive,
3444 false,
3445 "The `case_sensitive` setting should have been disabled with `:set ic`."
3446 );
3447 });
3448 }
3449
3450 #[gpui::test]
3451 async fn test_sort_commands(cx: &mut TestAppContext) {
3452 let mut cx = VimTestContext::new(cx, true).await;
3453
3454 cx.set_state(
3455 indoc! {"
3456 «hornet
3457 quirrel
3458 elderbug
3459 cornifer
3460 idaˇ»
3461 "},
3462 Mode::Visual,
3463 );
3464
3465 cx.simulate_keystrokes(": sort");
3466 cx.simulate_keystrokes("enter");
3467
3468 cx.assert_state(
3469 indoc! {"
3470 ˇcornifer
3471 elderbug
3472 hornet
3473 ida
3474 quirrel
3475 "},
3476 Mode::Normal,
3477 );
3478
3479 // Assert that, by default, `:sort` takes case into consideration.
3480 cx.set_state(
3481 indoc! {"
3482 «hornet
3483 quirrel
3484 Elderbug
3485 cornifer
3486 idaˇ»
3487 "},
3488 Mode::Visual,
3489 );
3490
3491 cx.simulate_keystrokes(": sort");
3492 cx.simulate_keystrokes("enter");
3493
3494 cx.assert_state(
3495 indoc! {"
3496 ˇElderbug
3497 cornifer
3498 hornet
3499 ida
3500 quirrel
3501 "},
3502 Mode::Normal,
3503 );
3504
3505 // Assert that, if the `i` option is passed, `:sort` ignores case.
3506 cx.set_state(
3507 indoc! {"
3508 «hornet
3509 quirrel
3510 Elderbug
3511 cornifer
3512 idaˇ»
3513 "},
3514 Mode::Visual,
3515 );
3516
3517 cx.simulate_keystrokes(": sort space i");
3518 cx.simulate_keystrokes("enter");
3519
3520 cx.assert_state(
3521 indoc! {"
3522 ˇcornifer
3523 Elderbug
3524 hornet
3525 ida
3526 quirrel
3527 "},
3528 Mode::Normal,
3529 );
3530
3531 // When no range is provided, sorts the whole buffer.
3532 cx.set_state(
3533 indoc! {"
3534 ˇhornet
3535 quirrel
3536 elderbug
3537 cornifer
3538 ida
3539 "},
3540 Mode::Normal,
3541 );
3542
3543 cx.simulate_keystrokes(": sort");
3544 cx.simulate_keystrokes("enter");
3545
3546 cx.assert_state(
3547 indoc! {"
3548 ˇcornifer
3549 elderbug
3550 hornet
3551 ida
3552 quirrel
3553 "},
3554 Mode::Normal,
3555 );
3556 }
3557
3558 #[gpui::test]
3559 async fn test_reflow(cx: &mut TestAppContext) {
3560 let mut cx = VimTestContext::new(cx, true).await;
3561
3562 cx.update_editor(|editor, _window, cx| {
3563 editor.set_hard_wrap(Some(10), cx);
3564 });
3565
3566 cx.set_state(
3567 indoc! {"
3568 ˇ0123456789 0123456789
3569 "},
3570 Mode::Normal,
3571 );
3572
3573 cx.simulate_keystrokes(": reflow");
3574 cx.simulate_keystrokes("enter");
3575
3576 cx.assert_state(
3577 indoc! {"
3578 0123456789
3579 ˇ0123456789
3580 "},
3581 Mode::Normal,
3582 );
3583
3584 cx.set_state(
3585 indoc! {"
3586 ˇ0123456789 0123456789
3587 "},
3588 Mode::VisualLine,
3589 );
3590
3591 cx.simulate_keystrokes("shift-v : reflow");
3592 cx.simulate_keystrokes("enter");
3593
3594 cx.assert_state(
3595 indoc! {"
3596 0123456789
3597 ˇ0123456789
3598 "},
3599 Mode::Normal,
3600 );
3601
3602 cx.set_state(
3603 indoc! {"
3604 ˇ0123 4567 0123 4567
3605 "},
3606 Mode::VisualLine,
3607 );
3608
3609 cx.simulate_keystrokes(": reflow space 7");
3610 cx.simulate_keystrokes("enter");
3611
3612 cx.assert_state(
3613 indoc! {"
3614 ˇ0123
3615 4567
3616 0123
3617 4567
3618 "},
3619 Mode::Normal,
3620 );
3621
3622 // Assert that, if `:reflow` is invoked with an invalid argument, it
3623 // does not actually have any effect in the buffer's contents.
3624 cx.set_state(
3625 indoc! {"
3626 ˇ0123 4567 0123 4567
3627 "},
3628 Mode::VisualLine,
3629 );
3630
3631 cx.simulate_keystrokes(": reflow space a");
3632 cx.simulate_keystrokes("enter");
3633
3634 cx.assert_state(
3635 indoc! {"
3636 ˇ0123 4567 0123 4567
3637 "},
3638 Mode::VisualLine,
3639 );
3640 }
3641}