1use anyhow::{anyhow, Result};
2use collections::HashMap;
3use command_palette_hooks::CommandInterceptResult;
4use editor::{
5 actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
6 display_map::ToDisplayPoint,
7 scroll::Autoscroll,
8 Bias, Editor, ToPoint,
9};
10use gpui::{
11 actions, impl_internal_actions, Action, AppContext, Global, ViewContext, WindowContext,
12};
13use language::Point;
14use multi_buffer::MultiBufferRow;
15use regex::Regex;
16use schemars::JsonSchema;
17use search::{BufferSearchBar, SearchOptions};
18use serde::Deserialize;
19use std::{
20 io::Write,
21 iter::Peekable,
22 ops::{Deref, Range},
23 process::Stdio,
24 str::Chars,
25 sync::OnceLock,
26 time::Instant,
27};
28use task::{HideStrategy, RevealStrategy, SpawnInTerminal, TaskId};
29use ui::ActiveTheme;
30use util::ResultExt;
31use workspace::{notifications::NotifyResultExt, SaveIntent};
32use zed_actions::RevealTarget;
33
34use crate::{
35 motion::{EndOfDocument, Motion, StartOfDocument},
36 normal::{
37 search::{FindCommand, ReplaceCommand, Replacement},
38 JoinLines,
39 },
40 object::Object,
41 state::Mode,
42 visual::VisualDeleteLine,
43 Vim,
44};
45
46#[derive(Clone, Debug, PartialEq)]
47pub struct GoToLine {
48 range: CommandRange,
49}
50
51#[derive(Clone, Debug, PartialEq)]
52pub struct YankCommand {
53 range: CommandRange,
54}
55
56#[derive(Clone, Debug, PartialEq)]
57pub struct WithRange {
58 restore_selection: bool,
59 range: CommandRange,
60 action: WrappedAction,
61}
62
63#[derive(Clone, Debug, PartialEq)]
64pub struct WithCount {
65 count: u32,
66 action: WrappedAction,
67}
68
69#[derive(Debug)]
70struct WrappedAction(Box<dyn Action>);
71
72actions!(vim, [VisualCommand, CountCommand, ShellCommand]);
73impl_internal_actions!(
74 vim,
75 [
76 GoToLine,
77 YankCommand,
78 WithRange,
79 WithCount,
80 OnMatchingLines,
81 ShellExec
82 ]
83);
84
85impl PartialEq for WrappedAction {
86 fn eq(&self, other: &Self) -> bool {
87 self.0.partial_eq(&*other.0)
88 }
89}
90
91impl Clone for WrappedAction {
92 fn clone(&self) -> Self {
93 Self(self.0.boxed_clone())
94 }
95}
96
97impl Deref for WrappedAction {
98 type Target = dyn Action;
99 fn deref(&self) -> &dyn Action {
100 &*self.0
101 }
102}
103
104pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
105 Vim::action(editor, cx, |vim, _: &VisualCommand, cx| {
106 let Some(workspace) = vim.workspace(cx) else {
107 return;
108 };
109 workspace.update(cx, |workspace, cx| {
110 command_palette::CommandPalette::toggle(workspace, "'<,'>", cx);
111 })
112 });
113
114 Vim::action(editor, cx, |vim, _: &ShellCommand, cx| {
115 let Some(workspace) = vim.workspace(cx) else {
116 return;
117 };
118 workspace.update(cx, |workspace, cx| {
119 command_palette::CommandPalette::toggle(workspace, "'<,'>!", cx);
120 })
121 });
122
123 Vim::action(editor, cx, |vim, _: &CountCommand, cx| {
124 let Some(workspace) = vim.workspace(cx) else {
125 return;
126 };
127 let count = Vim::take_count(cx).unwrap_or(1);
128 let n = if count > 1 {
129 format!(".,.+{}", count.saturating_sub(1))
130 } else {
131 ".".to_string()
132 };
133 workspace.update(cx, |workspace, cx| {
134 command_palette::CommandPalette::toggle(workspace, &n, cx);
135 })
136 });
137
138 Vim::action(editor, cx, |vim, action: &GoToLine, cx| {
139 vim.switch_mode(Mode::Normal, false, cx);
140 let result = vim.update_editor(cx, |vim, editor, cx| {
141 let snapshot = editor.snapshot(cx);
142 let buffer_row = action.range.head().buffer_row(vim, editor, cx)?;
143 let current = editor.selections.newest::<Point>(cx);
144 let target = snapshot
145 .buffer_snapshot
146 .clip_point(Point::new(buffer_row.0, current.head().column), Bias::Left);
147 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
148 s.select_ranges([target..target]);
149 });
150
151 anyhow::Ok(())
152 });
153 if let Some(e @ Err(_)) = result {
154 let Some(workspace) = vim.workspace(cx) else {
155 return;
156 };
157 workspace.update(cx, |workspace, cx| {
158 e.notify_err(workspace, cx);
159 });
160 return;
161 }
162 });
163
164 Vim::action(editor, cx, |vim, action: &YankCommand, cx| {
165 vim.update_editor(cx, |vim, editor, cx| {
166 let snapshot = editor.snapshot(cx);
167 if let Ok(range) = action.range.buffer_range(vim, editor, cx) {
168 let end = if range.end < snapshot.buffer_snapshot.max_row() {
169 Point::new(range.end.0 + 1, 0)
170 } else {
171 snapshot.buffer_snapshot.max_point()
172 };
173 vim.copy_ranges(
174 editor,
175 true,
176 true,
177 vec![Point::new(range.start.0, 0)..end],
178 cx,
179 )
180 }
181 });
182 });
183
184 Vim::action(editor, cx, |_, action: &WithCount, cx| {
185 for _ in 0..action.count {
186 cx.dispatch_action(action.action.boxed_clone())
187 }
188 });
189
190 Vim::action(editor, cx, |vim, action: &WithRange, cx| {
191 let result = vim.update_editor(cx, |vim, editor, cx| {
192 action.range.buffer_range(vim, editor, cx)
193 });
194
195 let range = match result {
196 None => return,
197 Some(e @ Err(_)) => {
198 let Some(workspace) = vim.workspace(cx) else {
199 return;
200 };
201 workspace.update(cx, |workspace, cx| {
202 e.notify_err(workspace, cx);
203 });
204 return;
205 }
206 Some(Ok(result)) => result,
207 };
208
209 let previous_selections = vim
210 .update_editor(cx, |_, editor, cx| {
211 let selections = action.restore_selection.then(|| {
212 editor
213 .selections
214 .disjoint_anchor_ranges()
215 .collect::<Vec<_>>()
216 });
217 editor.change_selections(None, cx, |s| {
218 let end = Point::new(range.end.0, s.buffer().line_len(range.end));
219 s.select_ranges([end..Point::new(range.start.0, 0)]);
220 });
221 selections
222 })
223 .flatten();
224 cx.dispatch_action(action.action.boxed_clone());
225 cx.defer(move |vim, cx| {
226 vim.update_editor(cx, |_, editor, cx| {
227 editor.change_selections(None, cx, |s| {
228 if let Some(previous_selections) = previous_selections {
229 s.select_ranges(previous_selections);
230 } else {
231 s.select_ranges([
232 Point::new(range.start.0, 0)..Point::new(range.start.0, 0)
233 ]);
234 }
235 })
236 });
237 });
238 });
239
240 Vim::action(editor, cx, |vim, action: &OnMatchingLines, cx| {
241 action.run(vim, cx)
242 });
243
244 Vim::action(editor, cx, |vim, action: &ShellExec, cx| {
245 action.run(vim, cx)
246 })
247}
248
249#[derive(Default)]
250struct VimCommand {
251 prefix: &'static str,
252 suffix: &'static str,
253 action: Option<Box<dyn Action>>,
254 action_name: Option<&'static str>,
255 bang_action: Option<Box<dyn Action>>,
256 range: Option<
257 Box<
258 dyn Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>>
259 + Send
260 + Sync
261 + 'static,
262 >,
263 >,
264 has_count: bool,
265}
266
267impl VimCommand {
268 fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
269 Self {
270 prefix: pattern.0,
271 suffix: pattern.1,
272 action: Some(action.boxed_clone()),
273 ..Default::default()
274 }
275 }
276
277 // from_str is used for actions in other crates.
278 fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
279 Self {
280 prefix: pattern.0,
281 suffix: pattern.1,
282 action_name: Some(action_name),
283 ..Default::default()
284 }
285 }
286
287 fn bang(mut self, bang_action: impl Action) -> Self {
288 self.bang_action = Some(bang_action.boxed_clone());
289 self
290 }
291
292 fn range(
293 mut self,
294 f: impl Fn(Box<dyn Action>, &CommandRange) -> Option<Box<dyn Action>> + Send + Sync + 'static,
295 ) -> Self {
296 self.range = Some(Box::new(f));
297 self
298 }
299
300 fn count(mut self) -> Self {
301 self.has_count = true;
302 self
303 }
304
305 fn parse(
306 &self,
307 mut query: &str,
308 range: &Option<CommandRange>,
309 cx: &AppContext,
310 ) -> Option<Box<dyn Action>> {
311 let has_bang = query.ends_with('!');
312 if has_bang {
313 query = &query[..query.len() - 1];
314 }
315
316 let suffix = query.strip_prefix(self.prefix)?;
317 if !self.suffix.starts_with(suffix) {
318 return None;
319 }
320
321 let action = if has_bang && self.bang_action.is_some() {
322 self.bang_action.as_ref().unwrap().boxed_clone()
323 } else if let Some(action) = self.action.as_ref() {
324 action.boxed_clone()
325 } else if let Some(action_name) = self.action_name {
326 cx.build_action(action_name, None).log_err()?
327 } else {
328 return None;
329 };
330
331 if let Some(range) = range {
332 self.range.as_ref().and_then(|f| f(action, range))
333 } else {
334 Some(action)
335 }
336 }
337
338 // TODO: ranges with search queries
339 fn parse_range(query: &str) -> (Option<CommandRange>, String) {
340 let mut chars = query.chars().peekable();
341
342 match chars.peek() {
343 Some('%') => {
344 chars.next();
345 return (
346 Some(CommandRange {
347 start: Position::Line { row: 1, offset: 0 },
348 end: Some(Position::LastLine { offset: 0 }),
349 }),
350 chars.collect(),
351 );
352 }
353 Some('*') => {
354 chars.next();
355 return (
356 Some(CommandRange {
357 start: Position::Mark {
358 name: '<',
359 offset: 0,
360 },
361 end: Some(Position::Mark {
362 name: '>',
363 offset: 0,
364 }),
365 }),
366 chars.collect(),
367 );
368 }
369 _ => {}
370 }
371
372 let start = Self::parse_position(&mut chars);
373
374 match chars.peek() {
375 Some(',' | ';') => {
376 chars.next();
377 (
378 Some(CommandRange {
379 start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
380 end: Self::parse_position(&mut chars),
381 }),
382 chars.collect(),
383 )
384 }
385 _ => (
386 start.map(|start| CommandRange { start, end: None }),
387 chars.collect(),
388 ),
389 }
390 }
391
392 fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
393 match chars.peek()? {
394 '0'..='9' => {
395 let row = Self::parse_u32(chars);
396 Some(Position::Line {
397 row,
398 offset: Self::parse_offset(chars),
399 })
400 }
401 '\'' => {
402 chars.next();
403 let name = chars.next()?;
404 Some(Position::Mark {
405 name,
406 offset: Self::parse_offset(chars),
407 })
408 }
409 '.' => {
410 chars.next();
411 Some(Position::CurrentLine {
412 offset: Self::parse_offset(chars),
413 })
414 }
415 '+' | '-' => Some(Position::CurrentLine {
416 offset: Self::parse_offset(chars),
417 }),
418 '$' => {
419 chars.next();
420 Some(Position::LastLine {
421 offset: Self::parse_offset(chars),
422 })
423 }
424 _ => None,
425 }
426 }
427
428 fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
429 let mut res: i32 = 0;
430 while matches!(chars.peek(), Some('+' | '-')) {
431 let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
432 let amount = if matches!(chars.peek(), Some('0'..='9')) {
433 (Self::parse_u32(chars) as i32).saturating_mul(sign)
434 } else {
435 sign
436 };
437 res = res.saturating_add(amount)
438 }
439 res
440 }
441
442 fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
443 let mut res: u32 = 0;
444 while matches!(chars.peek(), Some('0'..='9')) {
445 res = res
446 .saturating_mul(10)
447 .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
448 }
449 res
450 }
451}
452
453#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)]
454enum Position {
455 Line { row: u32, offset: i32 },
456 Mark { name: char, offset: i32 },
457 LastLine { offset: i32 },
458 CurrentLine { offset: i32 },
459}
460
461impl Position {
462 fn buffer_row(
463 &self,
464 vim: &Vim,
465 editor: &mut Editor,
466 cx: &mut WindowContext,
467 ) -> Result<MultiBufferRow> {
468 let snapshot = editor.snapshot(cx);
469 let target = match self {
470 Position::Line { row, offset } => {
471 if let Some(anchor) = editor.active_excerpt(cx).and_then(|(_, buffer, _)| {
472 editor.buffer().read(cx).buffer_point_to_anchor(
473 &buffer,
474 Point::new(row.saturating_sub(1), 0),
475 cx,
476 )
477 }) {
478 anchor
479 .to_point(&snapshot.buffer_snapshot)
480 .row
481 .saturating_add_signed(*offset)
482 } else {
483 row.saturating_add_signed(offset.saturating_sub(1))
484 }
485 }
486 Position::Mark { name, offset } => {
487 let Some(mark) = vim.marks.get(&name.to_string()).and_then(|vec| vec.last()) else {
488 return Err(anyhow!("mark {} not set", name));
489 };
490 mark.to_point(&snapshot.buffer_snapshot)
491 .row
492 .saturating_add_signed(*offset)
493 }
494 Position::LastLine { offset } => snapshot
495 .buffer_snapshot
496 .max_row()
497 .0
498 .saturating_add_signed(*offset),
499 Position::CurrentLine { offset } => editor
500 .selections
501 .newest_anchor()
502 .head()
503 .to_point(&snapshot.buffer_snapshot)
504 .row
505 .saturating_add_signed(*offset),
506 };
507
508 Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row()))
509 }
510}
511
512#[derive(Clone, Debug, PartialEq)]
513pub(crate) struct CommandRange {
514 start: Position,
515 end: Option<Position>,
516}
517
518impl CommandRange {
519 fn head(&self) -> &Position {
520 self.end.as_ref().unwrap_or(&self.start)
521 }
522
523 pub(crate) fn buffer_range(
524 &self,
525 vim: &Vim,
526 editor: &mut Editor,
527 cx: &mut WindowContext,
528 ) -> Result<Range<MultiBufferRow>> {
529 let start = self.start.buffer_row(vim, editor, cx)?;
530 let end = if let Some(end) = self.end.as_ref() {
531 end.buffer_row(vim, editor, cx)?
532 } else {
533 start
534 };
535 if end < start {
536 anyhow::Ok(end..start)
537 } else {
538 anyhow::Ok(start..end)
539 }
540 }
541
542 pub fn as_count(&self) -> Option<u32> {
543 if let CommandRange {
544 start: Position::Line { row, offset: 0 },
545 end: None,
546 } = &self
547 {
548 Some(*row)
549 } else {
550 None
551 }
552 }
553}
554
555fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
556 vec![
557 VimCommand::new(
558 ("w", "rite"),
559 workspace::Save {
560 save_intent: Some(SaveIntent::Save),
561 },
562 )
563 .bang(workspace::Save {
564 save_intent: Some(SaveIntent::Overwrite),
565 }),
566 VimCommand::new(
567 ("q", "uit"),
568 workspace::CloseActiveItem {
569 save_intent: Some(SaveIntent::Close),
570 },
571 )
572 .bang(workspace::CloseActiveItem {
573 save_intent: Some(SaveIntent::Skip),
574 }),
575 VimCommand::new(
576 ("wq", ""),
577 workspace::CloseActiveItem {
578 save_intent: Some(SaveIntent::Save),
579 },
580 )
581 .bang(workspace::CloseActiveItem {
582 save_intent: Some(SaveIntent::Overwrite),
583 }),
584 VimCommand::new(
585 ("x", "it"),
586 workspace::CloseActiveItem {
587 save_intent: Some(SaveIntent::SaveAll),
588 },
589 )
590 .bang(workspace::CloseActiveItem {
591 save_intent: Some(SaveIntent::Overwrite),
592 }),
593 VimCommand::new(
594 ("ex", "it"),
595 workspace::CloseActiveItem {
596 save_intent: Some(SaveIntent::SaveAll),
597 },
598 )
599 .bang(workspace::CloseActiveItem {
600 save_intent: Some(SaveIntent::Overwrite),
601 }),
602 VimCommand::new(
603 ("up", "date"),
604 workspace::Save {
605 save_intent: Some(SaveIntent::SaveAll),
606 },
607 ),
608 VimCommand::new(
609 ("wa", "ll"),
610 workspace::SaveAll {
611 save_intent: Some(SaveIntent::SaveAll),
612 },
613 )
614 .bang(workspace::SaveAll {
615 save_intent: Some(SaveIntent::Overwrite),
616 }),
617 VimCommand::new(
618 ("qa", "ll"),
619 workspace::CloseAllItemsAndPanes {
620 save_intent: Some(SaveIntent::Close),
621 },
622 )
623 .bang(workspace::CloseAllItemsAndPanes {
624 save_intent: Some(SaveIntent::Skip),
625 }),
626 VimCommand::new(
627 ("quita", "ll"),
628 workspace::CloseAllItemsAndPanes {
629 save_intent: Some(SaveIntent::Close),
630 },
631 )
632 .bang(workspace::CloseAllItemsAndPanes {
633 save_intent: Some(SaveIntent::Skip),
634 }),
635 VimCommand::new(
636 ("xa", "ll"),
637 workspace::CloseAllItemsAndPanes {
638 save_intent: Some(SaveIntent::SaveAll),
639 },
640 )
641 .bang(workspace::CloseAllItemsAndPanes {
642 save_intent: Some(SaveIntent::Overwrite),
643 }),
644 VimCommand::new(
645 ("wqa", "ll"),
646 workspace::CloseAllItemsAndPanes {
647 save_intent: Some(SaveIntent::SaveAll),
648 },
649 )
650 .bang(workspace::CloseAllItemsAndPanes {
651 save_intent: Some(SaveIntent::Overwrite),
652 }),
653 VimCommand::new(("cq", "uit"), zed_actions::Quit),
654 VimCommand::new(("sp", "lit"), workspace::SplitHorizontal),
655 VimCommand::new(("vs", "plit"), workspace::SplitVertical),
656 VimCommand::new(
657 ("bd", "elete"),
658 workspace::CloseActiveItem {
659 save_intent: Some(SaveIntent::Close),
660 },
661 )
662 .bang(workspace::CloseActiveItem {
663 save_intent: Some(SaveIntent::Skip),
664 }),
665 VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
666 VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem).count(),
667 VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem).count(),
668 VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
669 VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
670 VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
671 VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
672 VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
673 VimCommand::new(("tabe", "dit"), workspace::NewFile),
674 VimCommand::new(("tabnew", ""), workspace::NewFile),
675 VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
676 VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem).count(),
677 VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem).count(),
678 VimCommand::new(
679 ("tabc", "lose"),
680 workspace::CloseActiveItem {
681 save_intent: Some(SaveIntent::Close),
682 },
683 ),
684 VimCommand::new(
685 ("tabo", "nly"),
686 workspace::CloseInactiveItems {
687 save_intent: Some(SaveIntent::Close),
688 close_pinned: false,
689 },
690 )
691 .bang(workspace::CloseInactiveItems {
692 save_intent: Some(SaveIntent::Skip),
693 close_pinned: false,
694 }),
695 VimCommand::new(
696 ("on", "ly"),
697 workspace::CloseInactiveTabsAndPanes {
698 save_intent: Some(SaveIntent::Close),
699 },
700 )
701 .bang(workspace::CloseInactiveTabsAndPanes {
702 save_intent: Some(SaveIntent::Skip),
703 }),
704 VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
705 VimCommand::new(("cc", ""), editor::actions::Hover),
706 VimCommand::new(("ll", ""), editor::actions::Hover),
707 VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic).range(wrap_count),
708 VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic).range(wrap_count),
709 VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic).range(wrap_count),
710 VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic).range(wrap_count),
711 VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic).range(wrap_count),
712 VimCommand::new(("j", "oin"), JoinLines).range(select_range),
713 VimCommand::new(("fo", "ld"), editor::actions::FoldSelectedRanges).range(act_on_range),
714 VimCommand::new(("foldo", "pen"), editor::actions::UnfoldLines)
715 .bang(editor::actions::UnfoldRecursive)
716 .range(act_on_range),
717 VimCommand::new(("foldc", "lose"), editor::actions::Fold)
718 .bang(editor::actions::FoldRecursive)
719 .range(act_on_range),
720 VimCommand::new(("dif", "fupdate"), editor::actions::ToggleSelectedDiffHunks)
721 .range(act_on_range),
722 VimCommand::new(("rev", "ert"), editor::actions::RevertSelectedHunks).range(act_on_range),
723 VimCommand::new(("d", "elete"), VisualDeleteLine).range(select_range),
724 VimCommand::new(("y", "ank"), gpui::NoAction).range(|_, range| {
725 Some(
726 YankCommand {
727 range: range.clone(),
728 }
729 .boxed_clone(),
730 )
731 }),
732 VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(select_range),
733 VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(select_range),
734 VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
735 VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
736 VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
737 VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
738 VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
739 VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
740 VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
741 VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
742 VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
743 VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
744 VimCommand::str(("A", "I"), "assistant::ToggleFocus"),
745 VimCommand::new(("noh", "lsearch"), search::buffer_search::Dismiss),
746 VimCommand::new(("$", ""), EndOfDocument),
747 VimCommand::new(("%", ""), EndOfDocument),
748 VimCommand::new(("0", ""), StartOfDocument),
749 VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
750 .bang(editor::actions::ReloadFile),
751 VimCommand::new(("cpp", "link"), editor::actions::CopyPermalinkToLine).range(act_on_range),
752 ]
753}
754
755struct VimCommands(Vec<VimCommand>);
756// safety: we only ever access this from the main thread (as ensured by the cx argument)
757// actions are not Sync so we can't otherwise use a OnceLock.
758unsafe impl Sync for VimCommands {}
759impl Global for VimCommands {}
760
761fn commands(cx: &AppContext) -> &Vec<VimCommand> {
762 static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
763 &COMMANDS
764 .get_or_init(|| VimCommands(generate_commands(cx)))
765 .0
766}
767
768fn act_on_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
769 Some(
770 WithRange {
771 restore_selection: true,
772 range: range.clone(),
773 action: WrappedAction(action),
774 }
775 .boxed_clone(),
776 )
777}
778
779fn select_range(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
780 Some(
781 WithRange {
782 restore_selection: false,
783 range: range.clone(),
784 action: WrappedAction(action),
785 }
786 .boxed_clone(),
787 )
788}
789
790fn wrap_count(action: Box<dyn Action>, range: &CommandRange) -> Option<Box<dyn Action>> {
791 range.as_count().map(|count| {
792 WithCount {
793 count,
794 action: WrappedAction(action),
795 }
796 .boxed_clone()
797 })
798}
799
800pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
801 // NOTE: We also need to support passing arguments to commands like :w
802 // (ideally with filename autocompletion).
803 while input.starts_with(':') {
804 input = &input[1..];
805 }
806
807 let (range, query) = VimCommand::parse_range(input);
808 let range_prefix = input[0..(input.len() - query.len())].to_string();
809 let query = query.as_str().trim();
810
811 let action = if range.is_some() && query.is_empty() {
812 Some(
813 GoToLine {
814 range: range.clone().unwrap(),
815 }
816 .boxed_clone(),
817 )
818 } else if query.starts_with('/') || query.starts_with('?') {
819 Some(
820 FindCommand {
821 query: query[1..].to_string(),
822 backwards: query.starts_with('?'),
823 }
824 .boxed_clone(),
825 )
826 } else if query.starts_with('s') {
827 let mut substitute = "substitute".chars().peekable();
828 let mut query = query.chars().peekable();
829 while substitute
830 .peek()
831 .is_some_and(|char| Some(char) == query.peek())
832 {
833 substitute.next();
834 query.next();
835 }
836 if let Some(replacement) = Replacement::parse(query) {
837 let range = range.clone().unwrap_or(CommandRange {
838 start: Position::CurrentLine { offset: 0 },
839 end: None,
840 });
841 Some(ReplaceCommand { replacement, range }.boxed_clone())
842 } else {
843 None
844 }
845 } else if query.starts_with('g') || query.starts_with('v') {
846 let mut global = "global".chars().peekable();
847 let mut query = query.chars().peekable();
848 let mut invert = false;
849 if query.peek() == Some(&'v') {
850 invert = true;
851 query.next();
852 }
853 while global.peek().is_some_and(|char| Some(char) == query.peek()) {
854 global.next();
855 query.next();
856 }
857 if !invert && query.peek() == Some(&'!') {
858 invert = true;
859 query.next();
860 }
861 let range = range.clone().unwrap_or(CommandRange {
862 start: Position::Line { row: 0, offset: 0 },
863 end: Some(Position::LastLine { offset: 0 }),
864 });
865 if let Some(action) = OnMatchingLines::parse(query, invert, range, cx) {
866 Some(action.boxed_clone())
867 } else {
868 None
869 }
870 } else if query.contains('!') {
871 ShellExec::parse(query, range.clone())
872 } else {
873 None
874 };
875 if let Some(action) = action {
876 let string = input.to_string();
877 let positions = generate_positions(&string, &(range_prefix + query));
878 return Some(CommandInterceptResult {
879 action,
880 string,
881 positions,
882 });
883 }
884
885 for command in commands(cx).iter() {
886 if let Some(action) = command.parse(query, &range, cx) {
887 let mut string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
888 if query.ends_with('!') {
889 string.push('!');
890 }
891 let positions = generate_positions(&string, &(range_prefix + query));
892
893 return Some(CommandInterceptResult {
894 action,
895 string,
896 positions,
897 });
898 }
899 }
900 None
901}
902
903fn generate_positions(string: &str, query: &str) -> Vec<usize> {
904 let mut positions = Vec::new();
905 let mut chars = query.chars();
906
907 let Some(mut current) = chars.next() else {
908 return positions;
909 };
910
911 for (i, c) in string.char_indices() {
912 if c == current {
913 positions.push(i);
914 if let Some(c) = chars.next() {
915 current = c;
916 } else {
917 break;
918 }
919 }
920 }
921
922 positions
923}
924
925#[derive(Debug, PartialEq, Clone)]
926pub(crate) struct OnMatchingLines {
927 range: CommandRange,
928 search: String,
929 action: WrappedAction,
930 invert: bool,
931}
932
933impl OnMatchingLines {
934 // convert a vim query into something more usable by zed.
935 // we don't attempt to fully convert between the two regex syntaxes,
936 // but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
937 // and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
938 pub(crate) fn parse(
939 mut chars: Peekable<Chars>,
940 invert: bool,
941 range: CommandRange,
942 cx: &AppContext,
943 ) -> Option<Self> {
944 let delimiter = chars.next().filter(|c| {
945 !c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
946 })?;
947
948 let mut search = String::new();
949 let mut escaped = false;
950
951 while let Some(c) = chars.next() {
952 if escaped {
953 escaped = false;
954 // unescape escaped parens
955 if c != '(' && c != ')' && c != delimiter {
956 search.push('\\')
957 }
958 search.push(c)
959 } else if c == '\\' {
960 escaped = true;
961 } else if c == delimiter {
962 break;
963 } else {
964 // escape unescaped parens
965 if c == '(' || c == ')' {
966 search.push('\\')
967 }
968 search.push(c)
969 }
970 }
971
972 let command: String = chars.collect();
973
974 let action = WrappedAction(command_interceptor(&command, cx)?.action);
975
976 Some(Self {
977 range,
978 search,
979 invert,
980 action,
981 })
982 }
983
984 pub fn run(&self, vim: &mut Vim, cx: &mut ViewContext<Vim>) {
985 let result = vim.update_editor(cx, |vim, editor, cx| {
986 self.range.buffer_range(vim, editor, cx)
987 });
988
989 let range = match result {
990 None => return,
991 Some(e @ Err(_)) => {
992 let Some(workspace) = vim.workspace(cx) else {
993 return;
994 };
995 workspace.update(cx, |workspace, cx| {
996 e.notify_err(workspace, cx);
997 });
998 return;
999 }
1000 Some(Ok(result)) => result,
1001 };
1002
1003 let mut action = self.action.boxed_clone();
1004 let mut last_pattern = self.search.clone();
1005
1006 let mut regexes = match Regex::new(&self.search) {
1007 Ok(regex) => vec![(regex, !self.invert)],
1008 e @ Err(_) => {
1009 let Some(workspace) = vim.workspace(cx) else {
1010 return;
1011 };
1012 workspace.update(cx, |workspace, cx| {
1013 e.notify_err(workspace, cx);
1014 });
1015 return;
1016 }
1017 };
1018 while let Some(inner) = action
1019 .boxed_clone()
1020 .as_any()
1021 .downcast_ref::<OnMatchingLines>()
1022 {
1023 let Some(regex) = Regex::new(&inner.search).ok() else {
1024 break;
1025 };
1026 last_pattern = inner.search.clone();
1027 action = inner.action.boxed_clone();
1028 regexes.push((regex, !inner.invert))
1029 }
1030
1031 if let Some(pane) = vim.pane(cx) {
1032 pane.update(cx, |pane, cx| {
1033 if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
1034 {
1035 search_bar.update(cx, |search_bar, cx| {
1036 if search_bar.show(cx) {
1037 let _ = search_bar.search(
1038 &last_pattern,
1039 Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
1040 cx,
1041 );
1042 }
1043 });
1044 }
1045 });
1046 };
1047
1048 vim.update_editor(cx, |_, editor, cx| {
1049 let snapshot = editor.snapshot(cx);
1050 let mut row = range.start.0;
1051
1052 let point_range = Point::new(range.start.0, 0)
1053 ..snapshot
1054 .buffer_snapshot
1055 .clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
1056 cx.spawn(|editor, mut cx| async move {
1057 let new_selections = cx
1058 .background_executor()
1059 .spawn(async move {
1060 let mut line = String::new();
1061 let mut new_selections = Vec::new();
1062 let chunks = snapshot
1063 .buffer_snapshot
1064 .text_for_range(point_range)
1065 .chain(["\n"]);
1066
1067 for chunk in chunks {
1068 for (newline_ix, text) in chunk.split('\n').enumerate() {
1069 if newline_ix > 0 {
1070 if regexes.iter().all(|(regex, should_match)| {
1071 regex.is_match(&line) == *should_match
1072 }) {
1073 new_selections
1074 .push(Point::new(row, 0).to_display_point(&snapshot))
1075 }
1076 row += 1;
1077 line.clear();
1078 }
1079 line.push_str(text)
1080 }
1081 }
1082
1083 new_selections
1084 })
1085 .await;
1086
1087 if new_selections.is_empty() {
1088 return;
1089 }
1090 editor
1091 .update(&mut cx, |editor, cx| {
1092 editor.start_transaction_at(Instant::now(), cx);
1093 editor.change_selections(None, cx, |s| {
1094 s.replace_cursors_with(|_| new_selections);
1095 });
1096 cx.dispatch_action(action);
1097 cx.defer(move |editor, cx| {
1098 let newest = editor.selections.newest::<Point>(cx).clone();
1099 editor.change_selections(None, cx, |s| {
1100 s.select(vec![newest]);
1101 });
1102 editor.end_transaction_at(Instant::now(), cx);
1103 })
1104 })
1105 .ok();
1106 })
1107 .detach();
1108 });
1109 }
1110}
1111
1112#[derive(Clone, Debug, PartialEq)]
1113pub struct ShellExec {
1114 command: String,
1115 range: Option<CommandRange>,
1116 is_read: bool,
1117}
1118
1119impl Vim {
1120 pub fn cancel_running_command(&mut self, cx: &mut ViewContext<Self>) {
1121 if self.running_command.take().is_some() {
1122 self.update_editor(cx, |_, editor, cx| {
1123 editor.transact(cx, |editor, _| {
1124 editor.clear_row_highlights::<ShellExec>();
1125 })
1126 });
1127 }
1128 }
1129
1130 fn prepare_shell_command(&mut self, command: &str, cx: &mut ViewContext<Self>) -> String {
1131 let mut ret = String::new();
1132 // N.B. non-standard escaping rules:
1133 // * !echo % => "echo README.md"
1134 // * !echo \% => "echo %"
1135 // * !echo \\% => echo \%
1136 // * !echo \\\% => echo \\%
1137 for c in command.chars() {
1138 if c != '%' && c != '!' {
1139 ret.push(c);
1140 continue;
1141 } else if ret.chars().last() == Some('\\') {
1142 ret.pop();
1143 ret.push(c);
1144 continue;
1145 }
1146 match c {
1147 '%' => {
1148 self.update_editor(cx, |_, editor, cx| {
1149 if let Some((_, buffer, _)) = editor.active_excerpt(cx) {
1150 if let Some(file) = buffer.read(cx).file() {
1151 if let Some(local) = file.as_local() {
1152 if let Some(str) = local.path().to_str() {
1153 ret.push_str(str)
1154 }
1155 }
1156 }
1157 }
1158 });
1159 }
1160 '!' => {
1161 if let Some(command) = &self.last_command {
1162 ret.push_str(command)
1163 }
1164 }
1165 _ => {}
1166 }
1167 }
1168 self.last_command = Some(ret.clone());
1169 ret
1170 }
1171
1172 pub fn shell_command_motion(
1173 &mut self,
1174 motion: Motion,
1175 times: Option<usize>,
1176 cx: &mut ViewContext<Vim>,
1177 ) {
1178 self.stop_recording(cx);
1179 let Some(workspace) = self.workspace(cx) else {
1180 return;
1181 };
1182 let command = self.update_editor(cx, |_, editor, cx| {
1183 let snapshot = editor.snapshot(cx);
1184 let start = editor.selections.newest_display(cx);
1185 let text_layout_details = editor.text_layout_details(cx);
1186 let mut range = motion
1187 .range(&snapshot, start.clone(), times, false, &text_layout_details)
1188 .unwrap_or(start.range());
1189 if range.start != start.start {
1190 editor.change_selections(None, cx, |s| {
1191 s.select_ranges([
1192 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1193 ]);
1194 })
1195 }
1196 if range.end.row() > range.start.row() && range.end.column() != 0 {
1197 *range.end.row_mut() -= 1
1198 }
1199 if range.end.row() == range.start.row() {
1200 ".!".to_string()
1201 } else {
1202 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1203 }
1204 });
1205 if let Some(command) = command {
1206 workspace.update(cx, |workspace, cx| {
1207 command_palette::CommandPalette::toggle(workspace, &command, cx);
1208 });
1209 }
1210 }
1211
1212 pub fn shell_command_object(
1213 &mut self,
1214 object: Object,
1215 around: bool,
1216 cx: &mut ViewContext<Vim>,
1217 ) {
1218 self.stop_recording(cx);
1219 let Some(workspace) = self.workspace(cx) else {
1220 return;
1221 };
1222 let command = self.update_editor(cx, |_, editor, cx| {
1223 let snapshot = editor.snapshot(cx);
1224 let start = editor.selections.newest_display(cx);
1225 let range = object
1226 .range(&snapshot, start.clone(), around)
1227 .unwrap_or(start.range());
1228 if range.start != start.start {
1229 editor.change_selections(None, cx, |s| {
1230 s.select_ranges([
1231 range.start.to_point(&snapshot)..range.start.to_point(&snapshot)
1232 ]);
1233 })
1234 }
1235 if range.end.row() == range.start.row() {
1236 ".!".to_string()
1237 } else {
1238 format!(".,.+{}!", (range.end.row() - range.start.row()).0)
1239 }
1240 });
1241 if let Some(command) = command {
1242 workspace.update(cx, |workspace, cx| {
1243 command_palette::CommandPalette::toggle(workspace, &command, cx);
1244 });
1245 }
1246 }
1247}
1248
1249impl ShellExec {
1250 pub fn parse(query: &str, range: Option<CommandRange>) -> Option<Box<dyn Action>> {
1251 let (before, after) = query.split_once('!')?;
1252 let before = before.trim();
1253
1254 if !"read".starts_with(before) {
1255 return None;
1256 }
1257
1258 Some(
1259 ShellExec {
1260 command: after.trim().to_string(),
1261 range,
1262 is_read: !before.is_empty(),
1263 }
1264 .boxed_clone(),
1265 )
1266 }
1267
1268 pub fn run(&self, vim: &mut Vim, cx: &mut ViewContext<Vim>) {
1269 let Some(workspace) = vim.workspace(cx) else {
1270 return;
1271 };
1272
1273 let project = workspace.read(cx).project().clone();
1274 let command = vim.prepare_shell_command(&self.command, cx);
1275
1276 if self.range.is_none() && !self.is_read {
1277 workspace.update(cx, |workspace, cx| {
1278 let project = workspace.project().read(cx);
1279 let cwd = project.first_project_directory(cx);
1280 let shell = project.terminal_settings(&cwd, cx).shell.clone();
1281 cx.emit(workspace::Event::SpawnTask {
1282 action: Box::new(SpawnInTerminal {
1283 id: TaskId("vim".to_string()),
1284 full_label: self.command.clone(),
1285 label: self.command.clone(),
1286 command: command.clone(),
1287 args: Vec::new(),
1288 command_label: self.command.clone(),
1289 cwd,
1290 env: HashMap::default(),
1291 use_new_terminal: true,
1292 allow_concurrent_runs: true,
1293 reveal: RevealStrategy::NoFocus,
1294 reveal_target: RevealTarget::Dock,
1295 hide: HideStrategy::Never,
1296 shell,
1297 show_summary: false,
1298 show_command: false,
1299 }),
1300 });
1301 });
1302 return;
1303 };
1304
1305 let mut input_snapshot = None;
1306 let mut input_range = None;
1307 let mut needs_newline_prefix = false;
1308 vim.update_editor(cx, |vim, editor, cx| {
1309 let snapshot = editor.buffer().read(cx).snapshot(cx);
1310 let range = if let Some(range) = self.range.clone() {
1311 let Some(range) = range.buffer_range(vim, editor, cx).log_err() else {
1312 return;
1313 };
1314 Point::new(range.start.0, 0)
1315 ..snapshot.clip_point(Point::new(range.end.0 + 1, 0), Bias::Right)
1316 } else {
1317 let mut end = editor.selections.newest::<Point>(cx).range().end;
1318 end = snapshot.clip_point(Point::new(end.row + 1, 0), Bias::Right);
1319 needs_newline_prefix = end == snapshot.max_point();
1320 end..end
1321 };
1322 if self.is_read {
1323 input_range =
1324 Some(snapshot.anchor_after(range.end)..snapshot.anchor_after(range.end));
1325 } else {
1326 input_range =
1327 Some(snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end));
1328 }
1329 editor.highlight_rows::<ShellExec>(
1330 input_range.clone().unwrap(),
1331 cx.theme().status().unreachable_background,
1332 false,
1333 cx,
1334 );
1335
1336 if !self.is_read {
1337 input_snapshot = Some(snapshot)
1338 }
1339 });
1340
1341 let Some(range) = input_range else { return };
1342
1343 let mut process = project.read(cx).exec_in_shell(command, cx);
1344 process.stdout(Stdio::piped());
1345 process.stderr(Stdio::piped());
1346
1347 if input_snapshot.is_some() {
1348 process.stdin(Stdio::piped());
1349 } else {
1350 process.stdin(Stdio::null());
1351 };
1352
1353 // https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell
1354 //
1355 // safety: code in pre_exec should be signal safe.
1356 // https://man7.org/linux/man-pages/man7/signal-safety.7.html
1357 #[cfg(not(target_os = "windows"))]
1358 unsafe {
1359 use std::os::unix::process::CommandExt;
1360 process.pre_exec(|| {
1361 libc::setsid();
1362 Ok(())
1363 });
1364 };
1365 let is_read = self.is_read;
1366
1367 let task = cx.spawn(|vim, mut cx| async move {
1368 let Some(mut running) = process.spawn().log_err() else {
1369 vim.update(&mut cx, |vim, cx| {
1370 vim.cancel_running_command(cx);
1371 })
1372 .log_err();
1373 return;
1374 };
1375
1376 if let Some(mut stdin) = running.stdin.take() {
1377 if let Some(snapshot) = input_snapshot {
1378 let range = range.clone();
1379 cx.background_executor()
1380 .spawn(async move {
1381 for chunk in snapshot.text_for_range(range) {
1382 if stdin.write_all(chunk.as_bytes()).log_err().is_none() {
1383 return;
1384 }
1385 }
1386 stdin.flush().log_err();
1387 })
1388 .detach();
1389 }
1390 };
1391
1392 let output = cx
1393 .background_executor()
1394 .spawn(async move { running.wait_with_output() })
1395 .await;
1396
1397 let Some(output) = output.log_err() else {
1398 vim.update(&mut cx, |vim, cx| {
1399 vim.cancel_running_command(cx);
1400 })
1401 .log_err();
1402 return;
1403 };
1404 let mut text = String::new();
1405 if needs_newline_prefix {
1406 text.push('\n');
1407 }
1408 text.push_str(&String::from_utf8_lossy(&output.stdout));
1409 text.push_str(&String::from_utf8_lossy(&output.stderr));
1410 if !text.is_empty() && text.chars().last() != Some('\n') {
1411 text.push('\n');
1412 }
1413
1414 vim.update(&mut cx, |vim, cx| {
1415 vim.update_editor(cx, |_, editor, cx| {
1416 editor.transact(cx, |editor, cx| {
1417 editor.edit([(range.clone(), text)], cx);
1418 let snapshot = editor.buffer().read(cx).snapshot(cx);
1419 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1420 let point = if is_read {
1421 let point = range.end.to_point(&snapshot);
1422 Point::new(point.row.saturating_sub(1), 0)
1423 } else {
1424 let point = range.start.to_point(&snapshot);
1425 Point::new(point.row, 0)
1426 };
1427 s.select_ranges([point..point]);
1428 })
1429 })
1430 });
1431 vim.cancel_running_command(cx);
1432 })
1433 .log_err();
1434 });
1435 vim.running_command.replace(task);
1436 }
1437}
1438
1439#[cfg(test)]
1440mod test {
1441 use std::path::Path;
1442
1443 use crate::{
1444 state::Mode,
1445 test::{NeovimBackedTestContext, VimTestContext},
1446 };
1447 use editor::Editor;
1448 use gpui::TestAppContext;
1449 use indoc::indoc;
1450 use ui::ViewContext;
1451 use workspace::Workspace;
1452
1453 #[gpui::test]
1454 async fn test_command_basics(cx: &mut TestAppContext) {
1455 let mut cx = NeovimBackedTestContext::new(cx).await;
1456
1457 cx.set_shared_state(indoc! {"
1458 ˇa
1459 b
1460 c"})
1461 .await;
1462
1463 cx.simulate_shared_keystrokes(": j enter").await;
1464
1465 // hack: our cursor positioning after a join command is wrong
1466 cx.simulate_shared_keystrokes("^").await;
1467 cx.shared_state().await.assert_eq(indoc! {
1468 "ˇa b
1469 c"
1470 });
1471 }
1472
1473 #[gpui::test]
1474 async fn test_command_goto(cx: &mut TestAppContext) {
1475 let mut cx = NeovimBackedTestContext::new(cx).await;
1476
1477 cx.set_shared_state(indoc! {"
1478 ˇa
1479 b
1480 c"})
1481 .await;
1482 cx.simulate_shared_keystrokes(": 3 enter").await;
1483 cx.shared_state().await.assert_eq(indoc! {"
1484 a
1485 b
1486 ˇc"});
1487 }
1488
1489 #[gpui::test]
1490 async fn test_command_replace(cx: &mut TestAppContext) {
1491 let mut cx = NeovimBackedTestContext::new(cx).await;
1492
1493 cx.set_shared_state(indoc! {"
1494 ˇa
1495 b
1496 b
1497 c"})
1498 .await;
1499 cx.simulate_shared_keystrokes(": % s / b / d enter").await;
1500 cx.shared_state().await.assert_eq(indoc! {"
1501 a
1502 d
1503 ˇd
1504 c"});
1505 cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
1506 .await;
1507 cx.shared_state().await.assert_eq(indoc! {"
1508 aa
1509 dd
1510 dd
1511 ˇcc"});
1512 cx.simulate_shared_keystrokes("k : s / dd / ee enter").await;
1513 cx.shared_state().await.assert_eq(indoc! {"
1514 aa
1515 dd
1516 ˇee
1517 cc"});
1518 }
1519
1520 #[gpui::test]
1521 async fn test_command_search(cx: &mut TestAppContext) {
1522 let mut cx = NeovimBackedTestContext::new(cx).await;
1523
1524 cx.set_shared_state(indoc! {"
1525 ˇa
1526 b
1527 a
1528 c"})
1529 .await;
1530 cx.simulate_shared_keystrokes(": / b enter").await;
1531 cx.shared_state().await.assert_eq(indoc! {"
1532 a
1533 ˇb
1534 a
1535 c"});
1536 cx.simulate_shared_keystrokes(": ? a enter").await;
1537 cx.shared_state().await.assert_eq(indoc! {"
1538 ˇa
1539 b
1540 a
1541 c"});
1542 }
1543
1544 #[gpui::test]
1545 async fn test_command_write(cx: &mut TestAppContext) {
1546 let mut cx = VimTestContext::new(cx, true).await;
1547 let path = Path::new("/root/dir/file.rs");
1548 let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
1549
1550 cx.simulate_keystrokes("i @ escape");
1551 cx.simulate_keystrokes(": w enter");
1552
1553 assert_eq!(fs.load(path).await.unwrap(), "@\n");
1554
1555 fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
1556
1557 // conflict!
1558 cx.simulate_keystrokes("i @ escape");
1559 cx.simulate_keystrokes(": w enter");
1560 assert!(cx.has_pending_prompt());
1561 // "Cancel"
1562 cx.simulate_prompt_answer(0);
1563 assert_eq!(fs.load(path).await.unwrap(), "oops\n");
1564 assert!(!cx.has_pending_prompt());
1565 // force overwrite
1566 cx.simulate_keystrokes(": w ! enter");
1567 assert!(!cx.has_pending_prompt());
1568 assert_eq!(fs.load(path).await.unwrap(), "@@\n");
1569 }
1570
1571 #[gpui::test]
1572 async fn test_command_quit(cx: &mut TestAppContext) {
1573 let mut cx = VimTestContext::new(cx, true).await;
1574
1575 cx.simulate_keystrokes(": n e w enter");
1576 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
1577 cx.simulate_keystrokes(": q enter");
1578 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 1));
1579 cx.simulate_keystrokes(": n e w enter");
1580 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
1581 cx.simulate_keystrokes(": q a enter");
1582 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 0));
1583 }
1584
1585 #[gpui::test]
1586 async fn test_offsets(cx: &mut TestAppContext) {
1587 let mut cx = NeovimBackedTestContext::new(cx).await;
1588
1589 cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
1590 .await;
1591
1592 cx.simulate_shared_keystrokes(": + enter").await;
1593 cx.shared_state()
1594 .await
1595 .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
1596
1597 cx.simulate_shared_keystrokes(": 1 0 - enter").await;
1598 cx.shared_state()
1599 .await
1600 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
1601
1602 cx.simulate_shared_keystrokes(": . - 2 enter").await;
1603 cx.shared_state()
1604 .await
1605 .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
1606
1607 cx.simulate_shared_keystrokes(": % enter").await;
1608 cx.shared_state()
1609 .await
1610 .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
1611 }
1612
1613 #[gpui::test]
1614 async fn test_command_ranges(cx: &mut TestAppContext) {
1615 let mut cx = NeovimBackedTestContext::new(cx).await;
1616
1617 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
1618
1619 cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
1620 cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
1621
1622 cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
1623 cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
1624
1625 cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
1626 cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
1627 }
1628
1629 #[gpui::test]
1630 async fn test_command_visual_replace(cx: &mut TestAppContext) {
1631 let mut cx = NeovimBackedTestContext::new(cx).await;
1632
1633 cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
1634
1635 cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
1636 .await;
1637 cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
1638 }
1639
1640 fn assert_active_item(
1641 workspace: &mut Workspace,
1642 expected_path: &str,
1643 expected_text: &str,
1644 cx: &mut ViewContext<Workspace>,
1645 ) {
1646 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1647
1648 let buffer = active_editor
1649 .read(cx)
1650 .buffer()
1651 .read(cx)
1652 .as_singleton()
1653 .unwrap();
1654
1655 let text = buffer.read(cx).text();
1656 let file = buffer.read(cx).file().unwrap();
1657 let file_path = file.as_local().unwrap().abs_path(cx);
1658
1659 assert_eq!(text, expected_text);
1660 assert_eq!(file_path.to_str().unwrap(), expected_path);
1661 }
1662
1663 #[gpui::test]
1664 async fn test_command_gf(cx: &mut TestAppContext) {
1665 let mut cx = VimTestContext::new(cx, true).await;
1666
1667 // Assert base state, that we're in /root/dir/file.rs
1668 cx.workspace(|workspace, cx| {
1669 assert_active_item(workspace, "/root/dir/file.rs", "", cx);
1670 });
1671
1672 // Insert a new file
1673 let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
1674 fs.as_fake()
1675 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1676 .await;
1677 fs.as_fake()
1678 .insert_file("/root/dir/file3.rs", "go to file3".as_bytes().to_vec())
1679 .await;
1680
1681 // Put the path to the second file into the currently open buffer
1682 cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
1683
1684 // Go to file2.rs
1685 cx.simulate_keystrokes("g f");
1686
1687 // We now have two items
1688 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
1689 cx.workspace(|workspace, cx| {
1690 assert_active_item(workspace, "/root/dir/file2.rs", "This is file2.rs", cx);
1691 });
1692
1693 // Update editor to point to `file2.rs`
1694 cx.editor = cx.workspace(|workspace, cx| workspace.active_item_as::<Editor>(cx).unwrap());
1695
1696 // Put the path to the third file into the currently open buffer,
1697 // but remove its suffix, because we want that lookup to happen automatically.
1698 cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
1699
1700 // Go to file3.rs
1701 cx.simulate_keystrokes("g f");
1702
1703 // We now have three items
1704 cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 3));
1705 cx.workspace(|workspace, cx| {
1706 assert_active_item(workspace, "/root/dir/file3.rs", "go to file3", cx);
1707 });
1708 }
1709
1710 #[gpui::test]
1711 async fn test_command_matching_lines(cx: &mut TestAppContext) {
1712 let mut cx = NeovimBackedTestContext::new(cx).await;
1713
1714 cx.set_shared_state(indoc! {"
1715 ˇa
1716 b
1717 a
1718 b
1719 a
1720 "})
1721 .await;
1722
1723 cx.simulate_shared_keystrokes(":").await;
1724 cx.simulate_shared_keystrokes("g / a / d").await;
1725 cx.simulate_shared_keystrokes("enter").await;
1726
1727 cx.shared_state().await.assert_eq(indoc! {"
1728 b
1729 b
1730 ˇ"});
1731
1732 cx.simulate_shared_keystrokes("u").await;
1733
1734 cx.shared_state().await.assert_eq(indoc! {"
1735 ˇa
1736 b
1737 a
1738 b
1739 a
1740 "});
1741
1742 cx.simulate_shared_keystrokes(":").await;
1743 cx.simulate_shared_keystrokes("v / a / d").await;
1744 cx.simulate_shared_keystrokes("enter").await;
1745
1746 cx.shared_state().await.assert_eq(indoc! {"
1747 a
1748 a
1749 ˇa"});
1750 }
1751}