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