command.rs

   1use std::{iter::Peekable, ops::Range, str::Chars, sync::OnceLock};
   2
   3use anyhow::{anyhow, Result};
   4use command_palette_hooks::CommandInterceptResult;
   5use editor::{
   6    actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
   7    Editor, ToPoint,
   8};
   9use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext};
  10use language::Point;
  11use multi_buffer::MultiBufferRow;
  12use serde::Deserialize;
  13use ui::WindowContext;
  14use util::ResultExt;
  15use workspace::{notifications::NotifyResultExt, SaveIntent};
  16
  17use crate::{
  18    motion::{EndOfDocument, Motion, StartOfDocument},
  19    normal::{
  20        search::{FindCommand, ReplaceCommand, Replacement},
  21        JoinLines,
  22    },
  23    state::Mode,
  24    visual::{VisualDeleteLine, VisualYankLine},
  25    Vim,
  26};
  27
  28#[derive(Debug, Clone, PartialEq, Deserialize)]
  29pub struct GoToLine {
  30    range: CommandRange,
  31}
  32
  33#[derive(Debug)]
  34pub struct WithRange {
  35    is_count: bool,
  36    range: CommandRange,
  37    action: Box<dyn Action>,
  38}
  39
  40actions!(vim, [VisualCommand, CountCommand]);
  41impl_actions!(vim, [GoToLine, WithRange]);
  42
  43impl<'de> Deserialize<'de> for WithRange {
  44    fn deserialize<D>(_: D) -> Result<Self, D::Error>
  45    where
  46        D: serde::Deserializer<'de>,
  47    {
  48        Err(serde::de::Error::custom("Cannot deserialize WithRange"))
  49    }
  50}
  51
  52impl PartialEq for WithRange {
  53    fn eq(&self, other: &Self) -> bool {
  54        self.range == other.range && self.action.partial_eq(&*other.action)
  55    }
  56}
  57
  58impl Clone for WithRange {
  59    fn clone(&self) -> Self {
  60        Self {
  61            is_count: self.is_count,
  62            range: self.range.clone(),
  63            action: self.action.boxed_clone(),
  64        }
  65    }
  66}
  67
  68pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
  69    Vim::action(editor, cx, |vim, _: &VisualCommand, cx| {
  70        let Some(workspace) = vim.workspace(cx) else {
  71            return;
  72        };
  73        workspace.update(cx, |workspace, cx| {
  74            command_palette::CommandPalette::toggle(workspace, "'<,'>", cx);
  75        })
  76    });
  77
  78    Vim::action(editor, cx, |vim, _: &CountCommand, cx| {
  79        let Some(workspace) = vim.workspace(cx) else {
  80            return;
  81        };
  82        let count = vim.take_count(cx).unwrap_or(1);
  83        workspace.update(cx, |workspace, cx| {
  84            command_palette::CommandPalette::toggle(
  85                workspace,
  86                &format!(".,.+{}", count.saturating_sub(1)),
  87                cx,
  88            );
  89        })
  90    });
  91
  92    Vim::action(editor, cx, |vim, action: &GoToLine, cx| {
  93        vim.switch_mode(Mode::Normal, false, cx);
  94        let result = vim.update_editor(cx, |vim, editor, cx| {
  95            action.range.head().buffer_row(vim, editor, cx)
  96        });
  97        let buffer_row = match result {
  98            None => return,
  99            Some(e @ Err(_)) => {
 100                let Some(workspace) = vim.workspace(cx) else {
 101                    return;
 102                };
 103                workspace.update(cx, |workspace, cx| {
 104                    e.notify_err(workspace, cx);
 105                });
 106                return;
 107            }
 108            Some(Ok(result)) => result,
 109        };
 110        vim.move_cursor(Motion::StartOfDocument, Some(buffer_row.0 as usize + 1), cx);
 111    });
 112
 113    Vim::action(editor, cx, |vim, action: &WithRange, cx| {
 114        if action.is_count {
 115            for _ in 0..action.range.as_count() {
 116                cx.dispatch_action(action.action.boxed_clone())
 117            }
 118            return;
 119        }
 120        let result = vim.update_editor(cx, |vim, editor, cx| {
 121            action.range.buffer_range(vim, editor, cx)
 122        });
 123
 124        let range = match result {
 125            None => return,
 126            Some(e @ Err(_)) => {
 127                let Some(workspace) = vim.workspace(cx) else {
 128                    return;
 129                };
 130                workspace.update(cx, |workspace, cx| {
 131                    e.notify_err(workspace, cx);
 132                });
 133                return;
 134            }
 135            Some(Ok(result)) => result,
 136        };
 137        vim.update_editor(cx, |_, editor, cx| {
 138            editor.change_selections(None, cx, |s| {
 139                let end = Point::new(range.end.0, s.buffer().line_len(range.end));
 140                s.select_ranges([end..Point::new(range.start.0, 0)]);
 141            })
 142        });
 143        cx.dispatch_action(action.action.boxed_clone());
 144        cx.defer(move |vim, cx| {
 145            vim.update_editor(cx, |_, editor, cx| {
 146                editor.change_selections(None, cx, |s| {
 147                    s.select_ranges([Point::new(range.start.0, 0)..Point::new(range.start.0, 0)]);
 148                })
 149            });
 150        });
 151    });
 152}
 153
 154#[derive(Debug, Default)]
 155struct VimCommand {
 156    prefix: &'static str,
 157    suffix: &'static str,
 158    action: Option<Box<dyn Action>>,
 159    action_name: Option<&'static str>,
 160    bang_action: Option<Box<dyn Action>>,
 161    has_range: bool,
 162    has_count: bool,
 163}
 164
 165impl VimCommand {
 166    fn new(pattern: (&'static str, &'static str), action: impl Action) -> Self {
 167        Self {
 168            prefix: pattern.0,
 169            suffix: pattern.1,
 170            action: Some(action.boxed_clone()),
 171            ..Default::default()
 172        }
 173    }
 174
 175    // from_str is used for actions in other crates.
 176    fn str(pattern: (&'static str, &'static str), action_name: &'static str) -> Self {
 177        Self {
 178            prefix: pattern.0,
 179            suffix: pattern.1,
 180            action_name: Some(action_name),
 181            ..Default::default()
 182        }
 183    }
 184
 185    fn bang(mut self, bang_action: impl Action) -> Self {
 186        self.bang_action = Some(bang_action.boxed_clone());
 187        self
 188    }
 189
 190    fn range(mut self) -> Self {
 191        self.has_range = true;
 192        self
 193    }
 194    fn count(mut self) -> Self {
 195        self.has_count = true;
 196        self
 197    }
 198
 199    fn parse(&self, mut query: &str, cx: &AppContext) -> Option<Box<dyn Action>> {
 200        let has_bang = query.ends_with('!');
 201        if has_bang {
 202            query = &query[..query.len() - 1];
 203        }
 204
 205        let suffix = query.strip_prefix(self.prefix)?;
 206        if !self.suffix.starts_with(suffix) {
 207            return None;
 208        }
 209
 210        if has_bang && self.bang_action.is_some() {
 211            Some(self.bang_action.as_ref().unwrap().boxed_clone())
 212        } else if let Some(action) = self.action.as_ref() {
 213            Some(action.boxed_clone())
 214        } else if let Some(action_name) = self.action_name {
 215            cx.build_action(action_name, None).log_err()
 216        } else {
 217            None
 218        }
 219    }
 220
 221    // TODO: ranges with search queries
 222    fn parse_range(query: &str) -> (Option<CommandRange>, String) {
 223        let mut chars = query.chars().peekable();
 224
 225        match chars.peek() {
 226            Some('%') => {
 227                chars.next();
 228                return (
 229                    Some(CommandRange {
 230                        start: Position::Line { row: 1, offset: 0 },
 231                        end: Some(Position::LastLine { offset: 0 }),
 232                    }),
 233                    chars.collect(),
 234                );
 235            }
 236            Some('*') => {
 237                chars.next();
 238                return (
 239                    Some(CommandRange {
 240                        start: Position::Mark {
 241                            name: '<',
 242                            offset: 0,
 243                        },
 244                        end: Some(Position::Mark {
 245                            name: '>',
 246                            offset: 0,
 247                        }),
 248                    }),
 249                    chars.collect(),
 250                );
 251            }
 252            _ => {}
 253        }
 254
 255        let start = Self::parse_position(&mut chars);
 256
 257        match chars.peek() {
 258            Some(',' | ';') => {
 259                chars.next();
 260                (
 261                    Some(CommandRange {
 262                        start: start.unwrap_or(Position::CurrentLine { offset: 0 }),
 263                        end: Self::parse_position(&mut chars),
 264                    }),
 265                    chars.collect(),
 266                )
 267            }
 268            _ => (
 269                start.map(|start| CommandRange { start, end: None }),
 270                chars.collect(),
 271            ),
 272        }
 273    }
 274
 275    fn parse_position(chars: &mut Peekable<Chars>) -> Option<Position> {
 276        match chars.peek()? {
 277            '0'..='9' => {
 278                let row = Self::parse_u32(chars);
 279                Some(Position::Line {
 280                    row,
 281                    offset: Self::parse_offset(chars),
 282                })
 283            }
 284            '\'' => {
 285                chars.next();
 286                let name = chars.next()?;
 287                Some(Position::Mark {
 288                    name,
 289                    offset: Self::parse_offset(chars),
 290                })
 291            }
 292            '.' => {
 293                chars.next();
 294                Some(Position::CurrentLine {
 295                    offset: Self::parse_offset(chars),
 296                })
 297            }
 298            '+' | '-' => Some(Position::CurrentLine {
 299                offset: Self::parse_offset(chars),
 300            }),
 301            '$' => {
 302                chars.next();
 303                Some(Position::LastLine {
 304                    offset: Self::parse_offset(chars),
 305                })
 306            }
 307            _ => None,
 308        }
 309    }
 310
 311    fn parse_offset(chars: &mut Peekable<Chars>) -> i32 {
 312        let mut res: i32 = 0;
 313        while matches!(chars.peek(), Some('+' | '-')) {
 314            let sign = if chars.next().unwrap() == '+' { 1 } else { -1 };
 315            let amount = if matches!(chars.peek(), Some('0'..='9')) {
 316                (Self::parse_u32(chars) as i32).saturating_mul(sign)
 317            } else {
 318                sign
 319            };
 320            res = res.saturating_add(amount)
 321        }
 322        res
 323    }
 324
 325    fn parse_u32(chars: &mut Peekable<Chars>) -> u32 {
 326        let mut res: u32 = 0;
 327        while matches!(chars.peek(), Some('0'..='9')) {
 328            res = res
 329                .saturating_mul(10)
 330                .saturating_add(chars.next().unwrap() as u32 - '0' as u32);
 331        }
 332        res
 333    }
 334}
 335
 336#[derive(Debug, Clone, PartialEq, Deserialize)]
 337enum Position {
 338    Line { row: u32, offset: i32 },
 339    Mark { name: char, offset: i32 },
 340    LastLine { offset: i32 },
 341    CurrentLine { offset: i32 },
 342}
 343
 344impl Position {
 345    fn buffer_row(
 346        &self,
 347        vim: &Vim,
 348        editor: &mut Editor,
 349        cx: &mut WindowContext,
 350    ) -> Result<MultiBufferRow> {
 351        let snapshot = editor.snapshot(cx);
 352        let target = match self {
 353            Position::Line { row, offset } => row.saturating_add_signed(offset.saturating_sub(1)),
 354            Position::Mark { name, offset } => {
 355                let Some(mark) = vim.marks.get(&name.to_string()).and_then(|vec| vec.last()) else {
 356                    return Err(anyhow!("mark {} not set", name));
 357                };
 358                mark.to_point(&snapshot.buffer_snapshot)
 359                    .row
 360                    .saturating_add_signed(*offset)
 361            }
 362            Position::LastLine { offset } => {
 363                snapshot.max_buffer_row().0.saturating_add_signed(*offset)
 364            }
 365            Position::CurrentLine { offset } => editor
 366                .selections
 367                .newest_anchor()
 368                .head()
 369                .to_point(&snapshot.buffer_snapshot)
 370                .row
 371                .saturating_add_signed(*offset),
 372        };
 373
 374        Ok(MultiBufferRow(target).min(snapshot.max_buffer_row()))
 375    }
 376}
 377
 378#[derive(Debug, Clone, PartialEq, Deserialize)]
 379pub(crate) struct CommandRange {
 380    start: Position,
 381    end: Option<Position>,
 382}
 383
 384impl CommandRange {
 385    fn head(&self) -> &Position {
 386        self.end.as_ref().unwrap_or(&self.start)
 387    }
 388
 389    pub(crate) fn buffer_range(
 390        &self,
 391        vim: &Vim,
 392        editor: &mut Editor,
 393        cx: &mut WindowContext,
 394    ) -> Result<Range<MultiBufferRow>> {
 395        let start = self.start.buffer_row(vim, editor, cx)?;
 396        let end = if let Some(end) = self.end.as_ref() {
 397            end.buffer_row(vim, editor, cx)?
 398        } else {
 399            start
 400        };
 401        if end < start {
 402            anyhow::Ok(end..start)
 403        } else {
 404            anyhow::Ok(start..end)
 405        }
 406    }
 407
 408    pub fn as_count(&self) -> u32 {
 409        if let CommandRange {
 410            start: Position::Line { row, offset: 0 },
 411            end: None,
 412        } = &self
 413        {
 414            *row
 415        } else {
 416            0
 417        }
 418    }
 419
 420    pub fn is_count(&self) -> bool {
 421        matches!(
 422            &self,
 423            CommandRange {
 424                start: Position::Line { row: _, offset: 0 },
 425                end: None
 426            }
 427        )
 428    }
 429}
 430
 431fn generate_commands(_: &AppContext) -> Vec<VimCommand> {
 432    vec![
 433        VimCommand::new(
 434            ("w", "rite"),
 435            workspace::Save {
 436                save_intent: Some(SaveIntent::Save),
 437            },
 438        )
 439        .bang(workspace::Save {
 440            save_intent: Some(SaveIntent::Overwrite),
 441        }),
 442        VimCommand::new(
 443            ("q", "uit"),
 444            workspace::CloseActiveItem {
 445                save_intent: Some(SaveIntent::Close),
 446            },
 447        )
 448        .bang(workspace::CloseActiveItem {
 449            save_intent: Some(SaveIntent::Skip),
 450        }),
 451        VimCommand::new(
 452            ("wq", ""),
 453            workspace::CloseActiveItem {
 454                save_intent: Some(SaveIntent::Save),
 455            },
 456        )
 457        .bang(workspace::CloseActiveItem {
 458            save_intent: Some(SaveIntent::Overwrite),
 459        }),
 460        VimCommand::new(
 461            ("x", "it"),
 462            workspace::CloseActiveItem {
 463                save_intent: Some(SaveIntent::SaveAll),
 464            },
 465        )
 466        .bang(workspace::CloseActiveItem {
 467            save_intent: Some(SaveIntent::Overwrite),
 468        }),
 469        VimCommand::new(
 470            ("ex", "it"),
 471            workspace::CloseActiveItem {
 472                save_intent: Some(SaveIntent::SaveAll),
 473            },
 474        )
 475        .bang(workspace::CloseActiveItem {
 476            save_intent: Some(SaveIntent::Overwrite),
 477        }),
 478        VimCommand::new(
 479            ("up", "date"),
 480            workspace::Save {
 481                save_intent: Some(SaveIntent::SaveAll),
 482            },
 483        ),
 484        VimCommand::new(
 485            ("wa", "ll"),
 486            workspace::SaveAll {
 487                save_intent: Some(SaveIntent::SaveAll),
 488            },
 489        )
 490        .bang(workspace::SaveAll {
 491            save_intent: Some(SaveIntent::Overwrite),
 492        }),
 493        VimCommand::new(
 494            ("qa", "ll"),
 495            workspace::CloseAllItemsAndPanes {
 496                save_intent: Some(SaveIntent::Close),
 497            },
 498        )
 499        .bang(workspace::CloseAllItemsAndPanes {
 500            save_intent: Some(SaveIntent::Skip),
 501        }),
 502        VimCommand::new(
 503            ("quita", "ll"),
 504            workspace::CloseAllItemsAndPanes {
 505                save_intent: Some(SaveIntent::Close),
 506            },
 507        )
 508        .bang(workspace::CloseAllItemsAndPanes {
 509            save_intent: Some(SaveIntent::Skip),
 510        }),
 511        VimCommand::new(
 512            ("xa", "ll"),
 513            workspace::CloseAllItemsAndPanes {
 514                save_intent: Some(SaveIntent::SaveAll),
 515            },
 516        )
 517        .bang(workspace::CloseAllItemsAndPanes {
 518            save_intent: Some(SaveIntent::Overwrite),
 519        }),
 520        VimCommand::new(
 521            ("wqa", "ll"),
 522            workspace::CloseAllItemsAndPanes {
 523                save_intent: Some(SaveIntent::SaveAll),
 524            },
 525        )
 526        .bang(workspace::CloseAllItemsAndPanes {
 527            save_intent: Some(SaveIntent::Overwrite),
 528        }),
 529        VimCommand::new(("cq", "uit"), zed_actions::Quit),
 530        VimCommand::new(("sp", "lit"), workspace::SplitHorizontal),
 531        VimCommand::new(("vs", "plit"), workspace::SplitVertical),
 532        VimCommand::new(
 533            ("bd", "elete"),
 534            workspace::CloseActiveItem {
 535                save_intent: Some(SaveIntent::Close),
 536            },
 537        )
 538        .bang(workspace::CloseActiveItem {
 539            save_intent: Some(SaveIntent::Skip),
 540        }),
 541        VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
 542        VimCommand::new(("bN", "ext"), workspace::ActivatePrevItem).count(),
 543        VimCommand::new(("bp", "revious"), workspace::ActivatePrevItem).count(),
 544        VimCommand::new(("bf", "irst"), workspace::ActivateItem(0)),
 545        VimCommand::new(("br", "ewind"), workspace::ActivateItem(0)),
 546        VimCommand::new(("bl", "ast"), workspace::ActivateLastItem),
 547        VimCommand::new(("new", ""), workspace::NewFileSplitHorizontal),
 548        VimCommand::new(("vne", "w"), workspace::NewFileSplitVertical),
 549        VimCommand::new(("tabe", "dit"), workspace::NewFile),
 550        VimCommand::new(("tabnew", ""), workspace::NewFile),
 551        VimCommand::new(("tabn", "ext"), workspace::ActivateNextItem).count(),
 552        VimCommand::new(("tabp", "revious"), workspace::ActivatePrevItem).count(),
 553        VimCommand::new(("tabN", "ext"), workspace::ActivatePrevItem).count(),
 554        VimCommand::new(
 555            ("tabc", "lose"),
 556            workspace::CloseActiveItem {
 557                save_intent: Some(SaveIntent::Close),
 558            },
 559        ),
 560        VimCommand::new(
 561            ("tabo", "nly"),
 562            workspace::CloseInactiveItems {
 563                save_intent: Some(SaveIntent::Close),
 564            },
 565        )
 566        .bang(workspace::CloseInactiveItems {
 567            save_intent: Some(SaveIntent::Skip),
 568        }),
 569        VimCommand::new(
 570            ("on", "ly"),
 571            workspace::CloseInactiveTabsAndPanes {
 572                save_intent: Some(SaveIntent::Close),
 573            },
 574        )
 575        .bang(workspace::CloseInactiveTabsAndPanes {
 576            save_intent: Some(SaveIntent::Skip),
 577        }),
 578        VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
 579        VimCommand::new(("cc", ""), editor::actions::Hover),
 580        VimCommand::new(("ll", ""), editor::actions::Hover),
 581        VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic).count(),
 582        VimCommand::new(("cp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
 583        VimCommand::new(("cN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
 584        VimCommand::new(("lp", "revious"), editor::actions::GoToPrevDiagnostic).count(),
 585        VimCommand::new(("lN", "ext"), editor::actions::GoToPrevDiagnostic).count(),
 586        VimCommand::new(("j", "oin"), JoinLines).range(),
 587        VimCommand::new(("dif", "fupdate"), editor::actions::ToggleHunkDiff).range(),
 588        VimCommand::new(("rev", "ert"), editor::actions::RevertSelectedHunks).range(),
 589        VimCommand::new(("d", "elete"), VisualDeleteLine).range(),
 590        VimCommand::new(("y", "ank"), VisualYankLine).range(),
 591        VimCommand::new(("sor", "t"), SortLinesCaseSensitive).range(),
 592        VimCommand::new(("sort i", ""), SortLinesCaseInsensitive).range(),
 593        VimCommand::str(("E", "xplore"), "project_panel::ToggleFocus"),
 594        VimCommand::str(("H", "explore"), "project_panel::ToggleFocus"),
 595        VimCommand::str(("L", "explore"), "project_panel::ToggleFocus"),
 596        VimCommand::str(("S", "explore"), "project_panel::ToggleFocus"),
 597        VimCommand::str(("Ve", "xplore"), "project_panel::ToggleFocus"),
 598        VimCommand::str(("te", "rm"), "terminal_panel::ToggleFocus"),
 599        VimCommand::str(("T", "erm"), "terminal_panel::ToggleFocus"),
 600        VimCommand::str(("C", "ollab"), "collab_panel::ToggleFocus"),
 601        VimCommand::str(("Ch", "at"), "chat_panel::ToggleFocus"),
 602        VimCommand::str(("No", "tifications"), "notification_panel::ToggleFocus"),
 603        VimCommand::str(("A", "I"), "assistant::ToggleFocus"),
 604        VimCommand::new(("$", ""), EndOfDocument),
 605        VimCommand::new(("%", ""), EndOfDocument),
 606        VimCommand::new(("0", ""), StartOfDocument),
 607    ]
 608}
 609
 610struct VimCommands(Vec<VimCommand>);
 611// safety: we only ever access this from the main thread (as ensured by the cx argument)
 612// actions are not Sync so we can't otherwise use a OnceLock.
 613unsafe impl Sync for VimCommands {}
 614impl Global for VimCommands {}
 615
 616fn commands(cx: &AppContext) -> &Vec<VimCommand> {
 617    static COMMANDS: OnceLock<VimCommands> = OnceLock::new();
 618    &COMMANDS
 619        .get_or_init(|| VimCommands(generate_commands(cx)))
 620        .0
 621}
 622
 623pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandInterceptResult> {
 624    // NOTE: We also need to support passing arguments to commands like :w
 625    // (ideally with filename autocompletion).
 626    while input.starts_with(':') {
 627        input = &input[1..];
 628    }
 629
 630    let (range, query) = VimCommand::parse_range(input);
 631    let range_prefix = input[0..(input.len() - query.len())].to_string();
 632    let query = query.as_str().trim();
 633
 634    let action = if range.is_some() && query.is_empty() {
 635        Some(
 636            GoToLine {
 637                range: range.clone().unwrap(),
 638            }
 639            .boxed_clone(),
 640        )
 641    } else if query.starts_with('/') || query.starts_with('?') {
 642        Some(
 643            FindCommand {
 644                query: query[1..].to_string(),
 645                backwards: query.starts_with('?'),
 646            }
 647            .boxed_clone(),
 648        )
 649    } else if query.starts_with('s') {
 650        let mut substitute = "substitute".chars().peekable();
 651        let mut query = query.chars().peekable();
 652        while substitute
 653            .peek()
 654            .is_some_and(|char| Some(char) == query.peek())
 655        {
 656            substitute.next();
 657            query.next();
 658        }
 659        if let Some(replacement) = Replacement::parse(query) {
 660            let range = range.clone().unwrap_or(CommandRange {
 661                start: Position::CurrentLine { offset: 0 },
 662                end: None,
 663            });
 664            Some(ReplaceCommand { replacement, range }.boxed_clone())
 665        } else {
 666            None
 667        }
 668    } else {
 669        None
 670    };
 671    if let Some(action) = action {
 672        let string = input.to_string();
 673        let positions = generate_positions(&string, &(range_prefix + query));
 674        return Some(CommandInterceptResult {
 675            action,
 676            string,
 677            positions,
 678        });
 679    }
 680
 681    for command in commands(cx).iter() {
 682        if let Some(action) = command.parse(query, cx) {
 683            let string = ":".to_owned() + &range_prefix + command.prefix + command.suffix;
 684            let positions = generate_positions(&string, &(range_prefix + query));
 685
 686            if let Some(range) = &range {
 687                if command.has_range || (range.is_count() && command.has_count) {
 688                    return Some(CommandInterceptResult {
 689                        action: Box::new(WithRange {
 690                            is_count: command.has_count,
 691                            range: range.clone(),
 692                            action,
 693                        }),
 694                        string,
 695                        positions,
 696                    });
 697                } else {
 698                    return None;
 699                }
 700            }
 701
 702            return Some(CommandInterceptResult {
 703                action,
 704                string,
 705                positions,
 706            });
 707        }
 708    }
 709    None
 710}
 711
 712fn generate_positions(string: &str, query: &str) -> Vec<usize> {
 713    let mut positions = Vec::new();
 714    let mut chars = query.chars();
 715
 716    let Some(mut current) = chars.next() else {
 717        return positions;
 718    };
 719
 720    for (i, c) in string.char_indices() {
 721        if c == current {
 722            positions.push(i);
 723            if let Some(c) = chars.next() {
 724                current = c;
 725            } else {
 726                break;
 727            }
 728        }
 729    }
 730
 731    positions
 732}
 733
 734#[cfg(test)]
 735mod test {
 736    use std::path::Path;
 737
 738    use crate::{
 739        state::Mode,
 740        test::{NeovimBackedTestContext, VimTestContext},
 741    };
 742    use editor::Editor;
 743    use gpui::TestAppContext;
 744    use indoc::indoc;
 745    use ui::ViewContext;
 746    use workspace::Workspace;
 747
 748    #[gpui::test]
 749    async fn test_command_basics(cx: &mut TestAppContext) {
 750        let mut cx = NeovimBackedTestContext::new(cx).await;
 751
 752        cx.set_shared_state(indoc! {"
 753            ˇa
 754            b
 755            c"})
 756            .await;
 757
 758        cx.simulate_shared_keystrokes(": j enter").await;
 759
 760        // hack: our cursor positionining after a join command is wrong
 761        cx.simulate_shared_keystrokes("^").await;
 762        cx.shared_state().await.assert_eq(indoc! {
 763            "ˇa b
 764            c"
 765        });
 766    }
 767
 768    #[gpui::test]
 769    async fn test_command_goto(cx: &mut TestAppContext) {
 770        let mut cx = NeovimBackedTestContext::new(cx).await;
 771
 772        cx.set_shared_state(indoc! {"
 773            ˇa
 774            b
 775            c"})
 776            .await;
 777        cx.simulate_shared_keystrokes(": 3 enter").await;
 778        cx.shared_state().await.assert_eq(indoc! {"
 779            a
 780            b
 781            ˇc"});
 782    }
 783
 784    #[gpui::test]
 785    async fn test_command_replace(cx: &mut TestAppContext) {
 786        let mut cx = NeovimBackedTestContext::new(cx).await;
 787
 788        cx.set_shared_state(indoc! {"
 789            ˇa
 790            b
 791            b
 792            c"})
 793            .await;
 794        cx.simulate_shared_keystrokes(": % s / b / d enter").await;
 795        cx.shared_state().await.assert_eq(indoc! {"
 796            a
 797            d
 798            ˇd
 799            c"});
 800        cx.simulate_shared_keystrokes(": % s : . : \\ 0 \\ 0 enter")
 801            .await;
 802        cx.shared_state().await.assert_eq(indoc! {"
 803            aa
 804            dd
 805            dd
 806            ˇcc"});
 807        cx.simulate_shared_keystrokes("k : s / dd / ee enter").await;
 808        cx.shared_state().await.assert_eq(indoc! {"
 809            aa
 810            dd
 811            ˇee
 812            cc"});
 813    }
 814
 815    #[gpui::test]
 816    async fn test_command_search(cx: &mut TestAppContext) {
 817        let mut cx = NeovimBackedTestContext::new(cx).await;
 818
 819        cx.set_shared_state(indoc! {"
 820                ˇa
 821                b
 822                a
 823                c"})
 824            .await;
 825        cx.simulate_shared_keystrokes(": / b enter").await;
 826        cx.shared_state().await.assert_eq(indoc! {"
 827                a
 828                ˇb
 829                a
 830                c"});
 831        cx.simulate_shared_keystrokes(": ? a enter").await;
 832        cx.shared_state().await.assert_eq(indoc! {"
 833                ˇa
 834                b
 835                a
 836                c"});
 837    }
 838
 839    #[gpui::test]
 840    async fn test_command_write(cx: &mut TestAppContext) {
 841        let mut cx = VimTestContext::new(cx, true).await;
 842        let path = Path::new("/root/dir/file.rs");
 843        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
 844
 845        cx.simulate_keystrokes("i @ escape");
 846        cx.simulate_keystrokes(": w enter");
 847
 848        assert_eq!(fs.load(path).await.unwrap(), "@\n");
 849
 850        fs.as_fake().insert_file(path, b"oops\n".to_vec()).await;
 851
 852        // conflict!
 853        cx.simulate_keystrokes("i @ escape");
 854        cx.simulate_keystrokes(": w enter");
 855        assert!(cx.has_pending_prompt());
 856        // "Cancel"
 857        cx.simulate_prompt_answer(0);
 858        assert_eq!(fs.load(path).await.unwrap(), "oops\n");
 859        assert!(!cx.has_pending_prompt());
 860        // force overwrite
 861        cx.simulate_keystrokes(": w ! enter");
 862        assert!(!cx.has_pending_prompt());
 863        assert_eq!(fs.load(path).await.unwrap(), "@@\n");
 864    }
 865
 866    #[gpui::test]
 867    async fn test_command_quit(cx: &mut TestAppContext) {
 868        let mut cx = VimTestContext::new(cx, true).await;
 869
 870        cx.simulate_keystrokes(": n e w enter");
 871        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
 872        cx.simulate_keystrokes(": q enter");
 873        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 1));
 874        cx.simulate_keystrokes(": n e w enter");
 875        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
 876        cx.simulate_keystrokes(": q a enter");
 877        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 0));
 878    }
 879
 880    #[gpui::test]
 881    async fn test_offsets(cx: &mut TestAppContext) {
 882        let mut cx = NeovimBackedTestContext::new(cx).await;
 883
 884        cx.set_shared_state("ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n")
 885            .await;
 886
 887        cx.simulate_shared_keystrokes(": + enter").await;
 888        cx.shared_state()
 889            .await
 890            .assert_eq("1\nˇ2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n");
 891
 892        cx.simulate_shared_keystrokes(": 1 0 - enter").await;
 893        cx.shared_state()
 894            .await
 895            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\nˇ9\n10\n11\n");
 896
 897        cx.simulate_shared_keystrokes(": . - 2 enter").await;
 898        cx.shared_state()
 899            .await
 900            .assert_eq("1\n2\n3\n4\n5\n6\nˇ7\n8\n9\n10\n11\n");
 901
 902        cx.simulate_shared_keystrokes(": % enter").await;
 903        cx.shared_state()
 904            .await
 905            .assert_eq("1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\nˇ");
 906    }
 907
 908    #[gpui::test]
 909    async fn test_command_ranges(cx: &mut TestAppContext) {
 910        let mut cx = NeovimBackedTestContext::new(cx).await;
 911
 912        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
 913
 914        cx.simulate_shared_keystrokes(": 2 , 4 d enter").await;
 915        cx.shared_state().await.assert_eq("1\nˇ4\n3\n2\n1");
 916
 917        cx.simulate_shared_keystrokes(": 2 , 4 s o r t enter").await;
 918        cx.shared_state().await.assert_eq("1\nˇ2\n3\n4\n1");
 919
 920        cx.simulate_shared_keystrokes(": 2 , 4 j o i n enter").await;
 921        cx.shared_state().await.assert_eq("1\nˇ2 3 4\n1");
 922    }
 923
 924    #[gpui::test]
 925    async fn test_command_visual_replace(cx: &mut TestAppContext) {
 926        let mut cx = NeovimBackedTestContext::new(cx).await;
 927
 928        cx.set_shared_state("ˇ1\n2\n3\n4\n4\n3\n2\n1").await;
 929
 930        cx.simulate_shared_keystrokes("v 2 j : s / . / k enter")
 931            .await;
 932        cx.shared_state().await.assert_eq("k\nk\nˇk\n4\n4\n3\n2\n1");
 933    }
 934
 935    fn assert_active_item(
 936        workspace: &mut Workspace,
 937        expected_path: &str,
 938        expected_text: &str,
 939        cx: &mut ViewContext<Workspace>,
 940    ) {
 941        let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
 942
 943        let buffer = active_editor
 944            .read(cx)
 945            .buffer()
 946            .read(cx)
 947            .as_singleton()
 948            .unwrap();
 949
 950        let text = buffer.read(cx).text();
 951        let file = buffer.read(cx).file().unwrap();
 952        let file_path = file.as_local().unwrap().abs_path(cx);
 953
 954        assert_eq!(text, expected_text);
 955        assert_eq!(file_path.to_str().unwrap(), expected_path);
 956    }
 957
 958    #[gpui::test]
 959    async fn test_command_gf(cx: &mut TestAppContext) {
 960        let mut cx = VimTestContext::new(cx, true).await;
 961
 962        // Assert base state, that we're in /root/dir/file.rs
 963        cx.workspace(|workspace, cx| {
 964            assert_active_item(workspace, "/root/dir/file.rs", "", cx);
 965        });
 966
 967        // Insert a new file
 968        let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
 969        fs.as_fake()
 970            .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
 971            .await;
 972        fs.as_fake()
 973            .insert_file("/root/dir/file3.rs", "go to file3".as_bytes().to_vec())
 974            .await;
 975
 976        // Put the path to the second file into the currently open buffer
 977        cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
 978
 979        // Go to file2.rs
 980        cx.simulate_keystrokes("g f");
 981
 982        // We now have two items
 983        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
 984        cx.workspace(|workspace, cx| {
 985            assert_active_item(workspace, "/root/dir/file2.rs", "This is file2.rs", cx);
 986        });
 987
 988        // Update editor to point to `file2.rs`
 989        cx.editor = cx.workspace(|workspace, cx| workspace.active_item_as::<Editor>(cx).unwrap());
 990
 991        // Put the path to the third file into the currently open buffer,
 992        // but remove its suffix, because we want that lookup to happen automatically.
 993        cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
 994
 995        // Go to file3.rs
 996        cx.simulate_keystrokes("g f");
 997
 998        // We now have three items
 999        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 3));
1000        cx.workspace(|workspace, cx| {
1001            assert_active_item(workspace, "/root/dir/file3.rs", "go to file3", cx);
1002        });
1003    }
1004}