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