Skip to main content

vim_line/
vim.rs

1//! Vim-style line editor implementation.
2//!
3//! Owns the `VimLineEditor` struct, its `Mode` / `Operator` state, cursor-
4//! motion and edit helpers, the five mode-specific key handlers, and the
5//! `LineEditor` trait implementation.
6
7use crate::{Action, EditResult, Key, KeyCode, LineEditor, TextEdit};
8use std::ops::Range;
9
10mod command_line;
11mod edits;
12mod motions;
13
14/// Vim editing mode.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub(crate) enum Mode {
17    #[default]
18    Normal,
19    Insert,
20    OperatorPending(Operator),
21    Visual,
22    /// Waiting for a character to replace the one under cursor (r command)
23    ReplaceChar,
24    /// Ex-style command line entered with `:` from Normal (opt-in via the
25    /// editor's command-mode flag). Operates on a separate command buffer
26    /// owned by the editor; the host's main text is untouched.
27    CommandLine,
28}
29
30/// Operators that wait for a motion.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub(crate) enum Operator {
33    Delete,
34    Change,
35    Yank,
36}
37
38/// A vim-style line editor.
39///
40/// Implements modal editing with Normal, Insert, Visual, and OperatorPending modes.
41/// Designed for single "one-shot" inputs that may span multiple lines.
42#[derive(Debug, Clone)]
43pub struct VimLineEditor {
44    pub(in crate::vim) cursor: usize,
45    pub(in crate::vim) mode: Mode,
46    /// Anchor point for visual selection (cursor is the other end).
47    pub(in crate::vim) visual_anchor: Option<usize>,
48    /// Last yanked text (for paste).
49    pub(in crate::vim) yank_buffer: String,
50    /// Whether Normal-mode `:` enters `Mode::CommandLine`. Off by default so
51    /// non-REPL hosts keep classic vim behavior (where `:` is a no-op until
52    /// the host opts in to Ex-style command entry).
53    pub(in crate::vim) command_mode_enabled: bool,
54    /// Buffer for the Ex-style command line (only populated in
55    /// `Mode::CommandLine`). Decoupled from the host's main text.
56    pub(in crate::vim) command_buf: String,
57    /// Cursor within `command_buf` (byte offset).
58    pub(in crate::vim) command_cursor: usize,
59}
60
61impl Default for VimLineEditor {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl VimLineEditor {
68    /// Create a new editor in Normal mode.
69    pub fn new() -> Self {
70        Self {
71            cursor: 0,
72            mode: Mode::Normal,
73            visual_anchor: None,
74            yank_buffer: String::new(),
75            command_mode_enabled: false,
76            command_buf: String::new(),
77            command_cursor: 0,
78        }
79    }
80
81    /// Enable (or disable) the opt-in `:` -> `Mode::CommandLine` behavior.
82    /// Builder-style; call this on a freshly constructed editor.
83    pub fn with_command_mode(mut self, enabled: bool) -> Self {
84        self.command_mode_enabled = enabled;
85        self
86    }
87
88    /// Current command-line buffer, if the editor is in `Mode::CommandLine`.
89    /// Returns `None` in every other mode so the host can render it
90    /// unconditionally.
91    pub fn command_line_buffer(&self) -> Option<&str> {
92        if self.mode == Mode::CommandLine {
93            Some(&self.command_buf)
94        } else {
95            None
96        }
97    }
98
99    /// Cursor offset within the command-line buffer (only meaningful while
100    /// in `Mode::CommandLine`).
101    pub fn command_line_cursor(&self) -> usize {
102        self.command_cursor
103    }
104
105    /// Replace the command-line buffer and cursor. No-op outside
106    /// `Mode::CommandLine`. Used by hosts that compute completions
107    /// (`Tab`-cycling, etc.) and want to install the result.
108    pub fn set_command_line(&mut self, buf: String, cursor: usize) {
109        if self.mode != Mode::CommandLine {
110            return;
111        }
112        let cursor = cursor.min(buf.len());
113        // Snap to a char boundary if the caller handed us a mid-codepoint
114        // offset.
115        let cursor = if buf.is_char_boundary(cursor) {
116            cursor
117        } else {
118            let mut p = cursor;
119            while p > 0 && !buf.is_char_boundary(p) {
120                p -= 1;
121            }
122            p
123        };
124        self.command_buf = buf;
125        self.command_cursor = cursor;
126    }
127
128    /// Current mode — test-only accessor.
129    #[cfg(test)]
130    fn mode(&self) -> Mode {
131        self.mode
132    }
133
134    /// Clamp cursor to valid range for the given text.
135    fn clamp_cursor(&mut self, text: &str) {
136        self.cursor = self.cursor.min(text.len());
137    }
138
139    /// Move cursor left by one character.
140    fn move_left(&mut self, text: &str) {
141        self.cursor = motions::move_left(self.cursor, text);
142    }
143
144    /// Move cursor right by one character.
145    fn move_right(&mut self, text: &str) {
146        self.cursor = motions::move_right(self.cursor, text);
147    }
148
149    /// Move cursor to start of line (0).
150    fn move_line_start(&mut self, text: &str) {
151        self.cursor = motions::move_line_start(self.cursor, text);
152    }
153
154    /// Move cursor to first non-whitespace of line (^).
155    fn move_first_non_blank(&mut self, text: &str) {
156        self.cursor = motions::move_first_non_blank(self.cursor, text);
157    }
158
159    /// Move cursor to end of line (Normal mode — stays on last char).
160    fn move_line_end(&mut self, text: &str) {
161        self.cursor = motions::move_line_end(self.cursor, text);
162    }
163
164    /// Move cursor past end of line (Insert mode).
165    fn move_line_end_insert(&mut self, text: &str) {
166        self.cursor = motions::move_line_end_insert(self.cursor, text);
167    }
168
169    /// Move cursor forward by word (w).
170    fn move_word_forward(&mut self, text: &str) {
171        self.cursor = motions::move_word_forward(self.cursor, text);
172    }
173
174    /// Move cursor backward by word (b).
175    fn move_word_backward(&mut self, text: &str) {
176        self.cursor = motions::move_word_backward(self.cursor, text);
177    }
178
179    /// Move cursor to end of word (e).
180    fn move_word_end(&mut self, text: &str) {
181        self.cursor = motions::move_word_end(self.cursor, text);
182    }
183
184    /// Move cursor up one line (k).
185    fn move_up(&mut self, text: &str) {
186        self.cursor = motions::move_up(self.cursor, text);
187    }
188
189    /// Move cursor down one line (j).
190    fn move_down(&mut self, text: &str) {
191        self.cursor = motions::move_down(self.cursor, text);
192    }
193
194    /// Move cursor to matching bracket (%).
195    /// Supports (), [], {}, and <>.
196    fn move_to_matching_bracket(&mut self, text: &str) {
197        self.cursor = motions::move_to_matching_bracket(self.cursor, text);
198    }
199
200    /// Dispatch a shared motion key (h/l/j/k/0/$/^/w/b/e/%/Left/Right/Home/End)
201    /// to the appropriate cursor helper. Returns `true` when the key was
202    /// recognized as a motion, `false` otherwise.
203    ///
204    /// Up/Down arrow keys are intentionally NOT handled here — Normal mode
205    /// treats them as history navigation, not motion.
206    ///
207    /// Called by Normal and Visual handlers so each can delegate motion
208    /// interpretation to one place and then wrap the result in its own way.
209    /// OperatorPending has extra `c`/`cw`/`ce` quirks and handles motion
210    /// itself.
211    fn dispatch_motion(&mut self, code: KeyCode, text: &str) -> bool {
212        match code {
213            KeyCode::Char('h') | KeyCode::Left => self.move_left(text),
214            KeyCode::Char('l') | KeyCode::Right => self.move_right(text),
215            KeyCode::Char('j') => self.move_down(text),
216            KeyCode::Char('k') => self.move_up(text),
217            KeyCode::Char('0') | KeyCode::Home => self.move_line_start(text),
218            KeyCode::Char('^') => self.move_first_non_blank(text),
219            KeyCode::Char('$') | KeyCode::End => self.move_line_end(text),
220            KeyCode::Char('w') => self.move_word_forward(text),
221            KeyCode::Char('b') => self.move_word_backward(text),
222            KeyCode::Char('e') => self.move_word_end(text),
223            KeyCode::Char('%') => self.move_to_matching_bracket(text),
224            _ => return false,
225        }
226        true
227    }
228
229    /// Handle key in Normal mode.
230    fn handle_normal(&mut self, key: Key, text: &str) -> EditResult {
231        // Shared motions (h/l/j/k/0/$/^/w/b/e/%/Left/Right/Home/End).
232        // Up/Down are NOT motions in Normal — they're history navigation below.
233        if self.dispatch_motion(key.code, text) {
234            return EditResult::cursor_only();
235        }
236
237        match key.code {
238            // Mode switching
239            KeyCode::Char('i') => {
240                self.mode = Mode::Insert;
241                EditResult::none()
242            }
243            KeyCode::Char('a') => {
244                self.mode = Mode::Insert;
245                self.move_right(text);
246                EditResult::none()
247            }
248            KeyCode::Char('A') => {
249                self.mode = Mode::Insert;
250                self.move_line_end_insert(text);
251                EditResult::none()
252            }
253            KeyCode::Char('I') => {
254                self.mode = Mode::Insert;
255                self.move_first_non_blank(text);
256                EditResult::none()
257            }
258            KeyCode::Char('o') => {
259                self.mode = Mode::Insert;
260                self.move_line_end(text);
261                let pos = self.cursor;
262                self.cursor = pos + 1;
263                EditResult::edit(TextEdit::Insert {
264                    at: pos,
265                    text: "\n".to_string(),
266                })
267            }
268            KeyCode::Char('O') => {
269                self.mode = Mode::Insert;
270                self.move_line_start(text);
271                let pos = self.cursor;
272                EditResult::edit(TextEdit::Insert {
273                    at: pos,
274                    text: "\n".to_string(),
275                })
276            }
277
278            // Visual mode
279            KeyCode::Char('v') => {
280                self.mode = Mode::Visual;
281                self.visual_anchor = Some(self.cursor);
282                EditResult::none()
283            }
284
285            // Cancel (Ctrl+C)
286            KeyCode::Char('c') if key.ctrl => EditResult::action(Action::Cancel),
287
288            // Operators (enter pending mode)
289            KeyCode::Char('d') => {
290                self.mode = Mode::OperatorPending(Operator::Delete);
291                EditResult::none()
292            }
293            KeyCode::Char('c') => {
294                self.mode = Mode::OperatorPending(Operator::Change);
295                EditResult::none()
296            }
297            KeyCode::Char('y') => {
298                self.mode = Mode::OperatorPending(Operator::Yank);
299                EditResult::none()
300            }
301
302            // Direct deletions
303            KeyCode::Char('x') => self.delete_char(text),
304            KeyCode::Char('D') => self.delete_to_end(text),
305            KeyCode::Char('C') => {
306                self.mode = Mode::Insert;
307                self.delete_to_end(text)
308            }
309
310            // Replace character (r)
311            KeyCode::Char('r') => {
312                self.mode = Mode::ReplaceChar;
313                EditResult::none()
314            }
315
316            // Ex-style command line (`:`) — opt-in. Without the flag, `:` is
317            // an unhandled key in Normal mode, matching classic vim's
318            // "press `i` first" behavior so non-REPL hosts are unaffected.
319            KeyCode::Char(':') if self.command_mode_enabled => {
320                self.mode = Mode::CommandLine;
321                self.command_buf.clear();
322                self.command_cursor = 0;
323                EditResult::none()
324            }
325
326            // Paste
327            KeyCode::Char('p') => self.paste_after(text),
328            KeyCode::Char('P') => self.paste_before(text),
329
330            // History (arrows only)
331            KeyCode::Up => EditResult::action(Action::HistoryPrev),
332            KeyCode::Down => EditResult::action(Action::HistoryNext),
333
334            // Submit
335            KeyCode::Enter if !key.shift => EditResult::action(Action::Submit),
336
337            // Newline (Shift+Enter)
338            KeyCode::Enter if key.shift => {
339                self.mode = Mode::Insert;
340                let pos = self.cursor;
341                self.cursor = pos + 1;
342                EditResult::edit(TextEdit::Insert {
343                    at: pos,
344                    text: "\n".to_string(),
345                })
346            }
347
348            // Escape in Normal mode is a no-op (safe to spam like in vim)
349            // Use Ctrl+C to cancel/quit
350            KeyCode::Escape => EditResult::none(),
351
352            _ => EditResult::none(),
353        }
354    }
355
356    /// Handle key in Insert mode.
357    fn handle_insert(&mut self, key: Key, text: &str) -> EditResult {
358        match key.code {
359            KeyCode::Escape => {
360                self.mode = Mode::Normal;
361                // Move cursor left like vim does when exiting insert
362                if self.cursor > 0 {
363                    self.move_left(text);
364                }
365                EditResult::none()
366            }
367
368            // Ctrl+C exits insert mode
369            KeyCode::Char('c') if key.ctrl => {
370                self.mode = Mode::Normal;
371                EditResult::none()
372            }
373
374            KeyCode::Char(c) if !key.ctrl && !key.alt => {
375                let pos = self.cursor;
376                self.cursor = pos + c.len_utf8();
377                EditResult::edit(TextEdit::Insert {
378                    at: pos,
379                    text: c.to_string(),
380                })
381            }
382
383            KeyCode::Backspace => {
384                if self.cursor == 0 {
385                    return EditResult::none();
386                }
387                let mut start = self.cursor - 1;
388                while start > 0 && !text.is_char_boundary(start) {
389                    start -= 1;
390                }
391                let end = self.cursor; // Save original cursor before updating
392                self.cursor = start;
393                EditResult::edit(TextEdit::Delete { start, end })
394            }
395
396            KeyCode::Delete => self.delete_char(text),
397
398            KeyCode::Left => {
399                self.move_left(text);
400                EditResult::cursor_only()
401            }
402            KeyCode::Right => {
403                self.move_right(text);
404                EditResult::cursor_only()
405            }
406            KeyCode::Up => {
407                self.move_up(text);
408                EditResult::cursor_only()
409            }
410            KeyCode::Down => {
411                self.move_down(text);
412                EditResult::cursor_only()
413            }
414            KeyCode::Home => {
415                self.move_line_start(text);
416                EditResult::cursor_only()
417            }
418            KeyCode::End => {
419                // In Insert mode, cursor can go past the last character
420                self.move_line_end_insert(text);
421                EditResult::cursor_only()
422            }
423
424            // Enter inserts newline in insert mode
425            KeyCode::Enter => {
426                let pos = self.cursor;
427                self.cursor = pos + 1;
428                EditResult::edit(TextEdit::Insert {
429                    at: pos,
430                    text: "\n".to_string(),
431                })
432            }
433
434            _ => EditResult::none(),
435        }
436    }
437
438    /// Handle key in OperatorPending mode.
439    fn handle_operator_pending(&mut self, op: Operator, key: Key, text: &str) -> EditResult {
440        // First, handle escape to cancel
441        if key.code == KeyCode::Escape {
442            self.mode = Mode::Normal;
443            return EditResult::none();
444        }
445
446        // Handle doubled operator (dd, cc, yy) - operates on whole line
447        let is_line_op = matches!(
448            (op, key.code),
449            (Operator::Delete, KeyCode::Char('d'))
450                | (Operator::Change, KeyCode::Char('c'))
451                | (Operator::Yank, KeyCode::Char('y'))
452        );
453
454        if is_line_op {
455            self.mode = Mode::Normal;
456            return self.apply_operator_line(op, text);
457        }
458
459        // Handle motion
460        let start = self.cursor;
461        match key.code {
462            KeyCode::Char('w') => {
463                // Special case: cw behaves like ce (change to end of word, not including space)
464                // This is a vim quirk for historical compatibility
465                if op == Operator::Change {
466                    self.move_word_end(text);
467                    // Include the character at cursor
468                    if self.cursor < text.len() {
469                        self.cursor += 1;
470                    }
471                } else {
472                    self.move_word_forward(text);
473                }
474            }
475            KeyCode::Char('b') => self.move_word_backward(text),
476            KeyCode::Char('e') => {
477                self.move_word_end(text);
478                // Include the character at cursor for delete/change
479                if self.cursor < text.len() {
480                    self.cursor += 1;
481                }
482            }
483            KeyCode::Char('0') | KeyCode::Home => self.move_line_start(text),
484            KeyCode::Char('$') | KeyCode::End => self.move_line_end(text),
485            KeyCode::Char('^') => self.move_first_non_blank(text),
486            KeyCode::Char('h') | KeyCode::Left => self.move_left(text),
487            KeyCode::Char('l') | KeyCode::Right => self.move_right(text),
488            KeyCode::Char('j') => self.move_down(text),
489            KeyCode::Char('k') => self.move_up(text),
490            _ => {
491                // Unknown motion, cancel
492                self.mode = Mode::Normal;
493                return EditResult::none();
494            }
495        }
496
497        let end = self.cursor;
498        self.mode = Mode::Normal;
499
500        if start == end {
501            return EditResult::none();
502        }
503
504        let (range_start, range_end) = if start < end {
505            (start, end)
506        } else {
507            (end, start)
508        };
509
510        self.apply_operator(op, range_start, range_end, text)
511    }
512
513    /// Handle key in Visual mode.
514    fn handle_visual(&mut self, key: Key, text: &str) -> EditResult {
515        match key.code {
516            KeyCode::Escape => {
517                self.mode = Mode::Normal;
518                self.visual_anchor = None;
519                EditResult::none()
520            }
521
522            // Motions extend selection (note: `^` and `%` are intentionally
523            // not wired here to preserve original behavior — tracked for a
524            // separate behavior-change PR).
525            KeyCode::Char('h') | KeyCode::Left => {
526                self.move_left(text);
527                EditResult::cursor_only()
528            }
529            KeyCode::Char('l') | KeyCode::Right => {
530                self.move_right(text);
531                EditResult::cursor_only()
532            }
533            KeyCode::Char('j') => {
534                self.move_down(text);
535                EditResult::cursor_only()
536            }
537            KeyCode::Char('k') => {
538                self.move_up(text);
539                EditResult::cursor_only()
540            }
541            KeyCode::Char('w') => {
542                self.move_word_forward(text);
543                EditResult::cursor_only()
544            }
545            KeyCode::Char('b') => {
546                self.move_word_backward(text);
547                EditResult::cursor_only()
548            }
549            KeyCode::Char('e') => {
550                self.move_word_end(text);
551                EditResult::cursor_only()
552            }
553            KeyCode::Char('0') | KeyCode::Home => {
554                self.move_line_start(text);
555                EditResult::cursor_only()
556            }
557            KeyCode::Char('$') | KeyCode::End => {
558                self.move_line_end(text);
559                EditResult::cursor_only()
560            }
561
562            // Operators on selection
563            KeyCode::Char('d') | KeyCode::Char('x') => {
564                let (start, end) = self.selection_range();
565                self.mode = Mode::Normal;
566                self.visual_anchor = None;
567                self.apply_operator(Operator::Delete, start, end, text)
568            }
569            KeyCode::Char('c') => {
570                let (start, end) = self.selection_range();
571                self.mode = Mode::Normal;
572                self.visual_anchor = None;
573                self.apply_operator(Operator::Change, start, end, text)
574            }
575            KeyCode::Char('y') => {
576                let (start, end) = self.selection_range();
577                self.mode = Mode::Normal;
578                self.visual_anchor = None;
579                self.apply_operator(Operator::Yank, start, end, text)
580            }
581
582            _ => EditResult::none(),
583        }
584    }
585
586    /// Handle key in ReplaceChar mode (waiting for character after 'r').
587    fn handle_replace_char(&mut self, key: Key, text: &str) -> EditResult {
588        self.mode = Mode::Normal;
589
590        match key.code {
591            KeyCode::Escape => EditResult::none(),
592            KeyCode::Char(c) if !key.ctrl && !key.alt => {
593                // Replace character at cursor
594                if self.cursor >= text.len() {
595                    return EditResult::none();
596                }
597
598                // Find the end of the current character
599                let mut end = self.cursor + 1;
600                while end < text.len() && !text.is_char_boundary(end) {
601                    end += 1;
602                }
603
604                // Delete current char and insert new one
605                // Note: edits are applied in reverse order, so Insert comes first in vec
606                EditResult {
607                    edits: vec![
608                        TextEdit::Insert {
609                            at: self.cursor,
610                            text: c.to_string(),
611                        },
612                        TextEdit::Delete {
613                            start: self.cursor,
614                            end,
615                        },
616                    ],
617                    ..Default::default()
618                }
619            }
620            _ => EditResult::none(),
621        }
622    }
623
624    /// Get the selection range (ordered).
625    fn selection_range(&self) -> (usize, usize) {
626        let anchor = self.visual_anchor.unwrap_or(self.cursor);
627        if self.cursor < anchor {
628            (self.cursor, anchor)
629        } else {
630            (anchor, self.cursor + 1) // Include cursor position
631        }
632    }
633}
634
635impl LineEditor for VimLineEditor {
636    fn handle_key(&mut self, key: Key, text: &str) -> EditResult {
637        self.clamp_cursor(text);
638
639        let result = match self.mode {
640            Mode::Normal => self.handle_normal(key, text),
641            Mode::Insert => self.handle_insert(key, text),
642            Mode::OperatorPending(op) => self.handle_operator_pending(op, key, text),
643            Mode::Visual => self.handle_visual(key, text),
644            Mode::ReplaceChar => self.handle_replace_char(key, text),
645            Mode::CommandLine => self.handle_command_line(key),
646        };
647
648        // Store yanked text
649        if let Some(ref yanked) = result.yanked {
650            self.yank_buffer = yanked.clone();
651        }
652
653        result
654    }
655
656    fn cursor(&self) -> usize {
657        self.cursor
658    }
659
660    fn status(&self) -> &str {
661        match self.mode {
662            Mode::Normal => "NORMAL",
663            Mode::Insert => "INSERT",
664            Mode::OperatorPending(Operator::Delete) => "d...",
665            Mode::OperatorPending(Operator::Change) => "c...",
666            Mode::OperatorPending(Operator::Yank) => "y...",
667            Mode::Visual => "VISUAL",
668            Mode::ReplaceChar => "r...",
669            Mode::CommandLine => "COMMAND",
670        }
671    }
672
673    fn selection(&self) -> Option<Range<usize>> {
674        if self.mode == Mode::Visual {
675            let (start, end) = self.selection_range();
676            Some(start..end)
677        } else {
678            None
679        }
680    }
681
682    fn reset(&mut self) {
683        self.cursor = 0;
684        self.mode = Mode::Normal;
685        self.visual_anchor = None;
686        self.command_buf.clear();
687        self.command_cursor = 0;
688        // Keep yank buffer and command_mode_enabled flag across resets.
689    }
690
691    fn set_cursor(&mut self, pos: usize, text: &str) {
692        // Clamp to text length and ensure we're at a char boundary
693        let pos = pos.min(text.len());
694        self.cursor = if text.is_char_boundary(pos) {
695            pos
696        } else {
697            // Walk backwards to find a valid boundary
698            let mut p = pos;
699            while p > 0 && !text.is_char_boundary(p) {
700                p -= 1;
701            }
702            p
703        };
704    }
705}
706
707#[cfg(test)]
708mod tests;