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        // History at line boundaries: `k` on the first line and `j` on the
232        // last line become history navigation (matching readline vi-mode and
233        // most REPL muscle memory). Off-boundary, both stay as line motions
234        // via `dispatch_motion` below, so multi-line buffers keep working.
235        match key.code {
236            KeyCode::Char('k') if motions::is_on_first_line(self.cursor, text) => {
237                return EditResult::action(Action::HistoryPrev);
238            }
239            KeyCode::Char('j') if motions::is_on_last_line(self.cursor, text) => {
240                return EditResult::action(Action::HistoryNext);
241            }
242            _ => {}
243        }
244
245        // Shared motions (h/l/j/k/0/$/^/w/b/e/%/Left/Right/Home/End).
246        // Up/Down are NOT motions in Normal — they're history navigation below.
247        if self.dispatch_motion(key.code, text) {
248            return EditResult::cursor_only();
249        }
250
251        match key.code {
252            // Mode switching
253            KeyCode::Char('i') => {
254                self.mode = Mode::Insert;
255                EditResult::none()
256            }
257            KeyCode::Char('a') => {
258                self.mode = Mode::Insert;
259                self.move_right(text);
260                EditResult::none()
261            }
262            KeyCode::Char('A') => {
263                self.mode = Mode::Insert;
264                self.move_line_end_insert(text);
265                EditResult::none()
266            }
267            KeyCode::Char('I') => {
268                self.mode = Mode::Insert;
269                self.move_first_non_blank(text);
270                EditResult::none()
271            }
272            KeyCode::Char('o') => {
273                self.mode = Mode::Insert;
274                self.move_line_end(text);
275                let pos = self.cursor;
276                self.cursor = pos + 1;
277                EditResult::edit(TextEdit::Insert {
278                    at: pos,
279                    text: "\n".to_string(),
280                })
281            }
282            KeyCode::Char('O') => {
283                self.mode = Mode::Insert;
284                self.move_line_start(text);
285                let pos = self.cursor;
286                EditResult::edit(TextEdit::Insert {
287                    at: pos,
288                    text: "\n".to_string(),
289                })
290            }
291
292            // Visual mode
293            KeyCode::Char('v') => {
294                self.mode = Mode::Visual;
295                self.visual_anchor = Some(self.cursor);
296                EditResult::none()
297            }
298
299            // Cancel (Ctrl+C)
300            KeyCode::Char('c') if key.ctrl => EditResult::action(Action::Cancel),
301
302            // Operators (enter pending mode)
303            KeyCode::Char('d') => {
304                self.mode = Mode::OperatorPending(Operator::Delete);
305                EditResult::none()
306            }
307            KeyCode::Char('c') => {
308                self.mode = Mode::OperatorPending(Operator::Change);
309                EditResult::none()
310            }
311            KeyCode::Char('y') => {
312                self.mode = Mode::OperatorPending(Operator::Yank);
313                EditResult::none()
314            }
315
316            // Direct deletions
317            KeyCode::Char('x') => self.delete_char(text),
318            KeyCode::Char('D') => self.delete_to_end(text),
319            KeyCode::Char('C') => {
320                self.mode = Mode::Insert;
321                self.delete_to_end(text)
322            }
323
324            // Replace character (r)
325            KeyCode::Char('r') => {
326                self.mode = Mode::ReplaceChar;
327                EditResult::none()
328            }
329
330            // Ex-style command line (`:`) — opt-in. Without the flag, `:` is
331            // an unhandled key in Normal mode, matching classic vim's
332            // "press `i` first" behavior so non-REPL hosts are unaffected.
333            KeyCode::Char(':') if self.command_mode_enabled => {
334                self.mode = Mode::CommandLine;
335                self.command_buf.clear();
336                self.command_cursor = 0;
337                EditResult::none()
338            }
339
340            // Paste
341            KeyCode::Char('p') => self.paste_after(text),
342            KeyCode::Char('P') => self.paste_before(text),
343
344            // History (arrows only)
345            KeyCode::Up => EditResult::action(Action::HistoryPrev),
346            KeyCode::Down => EditResult::action(Action::HistoryNext),
347
348            // Submit
349            KeyCode::Enter if !key.shift => EditResult::action(Action::Submit),
350
351            // Newline (Shift+Enter)
352            KeyCode::Enter if key.shift => {
353                self.mode = Mode::Insert;
354                let pos = self.cursor;
355                self.cursor = pos + 1;
356                EditResult::edit(TextEdit::Insert {
357                    at: pos,
358                    text: "\n".to_string(),
359                })
360            }
361
362            // Escape in Normal mode is a no-op (safe to spam like in vim)
363            // Use Ctrl+C to cancel/quit
364            KeyCode::Escape => EditResult::none(),
365
366            _ => EditResult::none(),
367        }
368    }
369
370    /// Handle key in Insert mode.
371    fn handle_insert(&mut self, key: Key, text: &str) -> EditResult {
372        match key.code {
373            KeyCode::Escape => {
374                self.mode = Mode::Normal;
375                // Move cursor left like vim does when exiting insert
376                if self.cursor > 0 {
377                    self.move_left(text);
378                }
379                EditResult::none()
380            }
381
382            // Ctrl+C exits insert mode
383            KeyCode::Char('c') if key.ctrl => {
384                self.mode = Mode::Normal;
385                EditResult::none()
386            }
387
388            KeyCode::Char(c) if !key.ctrl && !key.alt => {
389                let pos = self.cursor;
390                self.cursor = pos + c.len_utf8();
391                EditResult::edit(TextEdit::Insert {
392                    at: pos,
393                    text: c.to_string(),
394                })
395            }
396
397            KeyCode::Backspace => {
398                if self.cursor == 0 {
399                    return EditResult::none();
400                }
401                let mut start = self.cursor - 1;
402                while start > 0 && !text.is_char_boundary(start) {
403                    start -= 1;
404                }
405                let end = self.cursor; // Save original cursor before updating
406                self.cursor = start;
407                EditResult::edit(TextEdit::Delete { start, end })
408            }
409
410            KeyCode::Delete => self.delete_char(text),
411
412            KeyCode::Left => {
413                self.move_left(text);
414                EditResult::cursor_only()
415            }
416            KeyCode::Right => {
417                self.move_right(text);
418                EditResult::cursor_only()
419            }
420            KeyCode::Up => {
421                self.move_up(text);
422                EditResult::cursor_only()
423            }
424            KeyCode::Down => {
425                self.move_down(text);
426                EditResult::cursor_only()
427            }
428            KeyCode::Home => {
429                self.move_line_start(text);
430                EditResult::cursor_only()
431            }
432            KeyCode::End => {
433                // In Insert mode, cursor can go past the last character
434                self.move_line_end_insert(text);
435                EditResult::cursor_only()
436            }
437
438            // Enter inserts newline in insert mode
439            KeyCode::Enter => {
440                let pos = self.cursor;
441                self.cursor = pos + 1;
442                EditResult::edit(TextEdit::Insert {
443                    at: pos,
444                    text: "\n".to_string(),
445                })
446            }
447
448            _ => EditResult::none(),
449        }
450    }
451
452    /// Handle key in OperatorPending mode.
453    fn handle_operator_pending(&mut self, op: Operator, key: Key, text: &str) -> EditResult {
454        // First, handle escape to cancel
455        if key.code == KeyCode::Escape {
456            self.mode = Mode::Normal;
457            return EditResult::none();
458        }
459
460        // Handle doubled operator (dd, cc, yy) - operates on whole line
461        let is_line_op = matches!(
462            (op, key.code),
463            (Operator::Delete, KeyCode::Char('d'))
464                | (Operator::Change, KeyCode::Char('c'))
465                | (Operator::Yank, KeyCode::Char('y'))
466        );
467
468        if is_line_op {
469            self.mode = Mode::Normal;
470            return self.apply_operator_line(op, text);
471        }
472
473        // Handle motion
474        let start = self.cursor;
475        match key.code {
476            KeyCode::Char('w') => {
477                // Special case: cw behaves like ce (change to end of word, not including space)
478                // This is a vim quirk for historical compatibility
479                if op == Operator::Change {
480                    self.move_word_end(text);
481                    // Include the character at cursor
482                    if self.cursor < text.len() {
483                        self.cursor += 1;
484                    }
485                } else {
486                    self.move_word_forward(text);
487                }
488            }
489            KeyCode::Char('b') => self.move_word_backward(text),
490            KeyCode::Char('e') => {
491                self.move_word_end(text);
492                // Include the character at cursor for delete/change
493                if self.cursor < text.len() {
494                    self.cursor += 1;
495                }
496            }
497            KeyCode::Char('0') | KeyCode::Home => self.move_line_start(text),
498            KeyCode::Char('$') | KeyCode::End => self.move_line_end(text),
499            KeyCode::Char('^') => self.move_first_non_blank(text),
500            KeyCode::Char('h') | KeyCode::Left => self.move_left(text),
501            KeyCode::Char('l') | KeyCode::Right => self.move_right(text),
502            KeyCode::Char('j') => self.move_down(text),
503            KeyCode::Char('k') => self.move_up(text),
504            _ => {
505                // Unknown motion, cancel
506                self.mode = Mode::Normal;
507                return EditResult::none();
508            }
509        }
510
511        let end = self.cursor;
512        self.mode = Mode::Normal;
513
514        if start == end {
515            return EditResult::none();
516        }
517
518        let (range_start, range_end) = if start < end {
519            (start, end)
520        } else {
521            (end, start)
522        };
523
524        self.apply_operator(op, range_start, range_end, text)
525    }
526
527    /// Handle key in Visual mode.
528    fn handle_visual(&mut self, key: Key, text: &str) -> EditResult {
529        match key.code {
530            KeyCode::Escape => {
531                self.mode = Mode::Normal;
532                self.visual_anchor = None;
533                EditResult::none()
534            }
535
536            // Motions extend selection (note: `^` and `%` are intentionally
537            // not wired here to preserve original behavior — tracked for a
538            // separate behavior-change PR).
539            KeyCode::Char('h') | KeyCode::Left => {
540                self.move_left(text);
541                EditResult::cursor_only()
542            }
543            KeyCode::Char('l') | KeyCode::Right => {
544                self.move_right(text);
545                EditResult::cursor_only()
546            }
547            KeyCode::Char('j') => {
548                self.move_down(text);
549                EditResult::cursor_only()
550            }
551            KeyCode::Char('k') => {
552                self.move_up(text);
553                EditResult::cursor_only()
554            }
555            KeyCode::Char('w') => {
556                self.move_word_forward(text);
557                EditResult::cursor_only()
558            }
559            KeyCode::Char('b') => {
560                self.move_word_backward(text);
561                EditResult::cursor_only()
562            }
563            KeyCode::Char('e') => {
564                self.move_word_end(text);
565                EditResult::cursor_only()
566            }
567            KeyCode::Char('0') | KeyCode::Home => {
568                self.move_line_start(text);
569                EditResult::cursor_only()
570            }
571            KeyCode::Char('$') | KeyCode::End => {
572                self.move_line_end(text);
573                EditResult::cursor_only()
574            }
575
576            // Operators on selection
577            KeyCode::Char('d') | KeyCode::Char('x') => {
578                let (start, end) = self.selection_range();
579                self.mode = Mode::Normal;
580                self.visual_anchor = None;
581                self.apply_operator(Operator::Delete, start, end, text)
582            }
583            KeyCode::Char('c') => {
584                let (start, end) = self.selection_range();
585                self.mode = Mode::Normal;
586                self.visual_anchor = None;
587                self.apply_operator(Operator::Change, start, end, text)
588            }
589            KeyCode::Char('y') => {
590                let (start, end) = self.selection_range();
591                self.mode = Mode::Normal;
592                self.visual_anchor = None;
593                self.apply_operator(Operator::Yank, start, end, text)
594            }
595
596            _ => EditResult::none(),
597        }
598    }
599
600    /// Handle key in ReplaceChar mode (waiting for character after 'r').
601    fn handle_replace_char(&mut self, key: Key, text: &str) -> EditResult {
602        self.mode = Mode::Normal;
603
604        match key.code {
605            KeyCode::Escape => EditResult::none(),
606            KeyCode::Char(c) if !key.ctrl && !key.alt => {
607                // Replace character at cursor
608                if self.cursor >= text.len() {
609                    return EditResult::none();
610                }
611
612                // Find the end of the current character
613                let mut end = self.cursor + 1;
614                while end < text.len() && !text.is_char_boundary(end) {
615                    end += 1;
616                }
617
618                // Delete current char and insert new one
619                // Note: edits are applied in reverse order, so Insert comes first in vec
620                EditResult {
621                    edits: vec![
622                        TextEdit::Insert {
623                            at: self.cursor,
624                            text: c.to_string(),
625                        },
626                        TextEdit::Delete {
627                            start: self.cursor,
628                            end,
629                        },
630                    ],
631                    ..Default::default()
632                }
633            }
634            _ => EditResult::none(),
635        }
636    }
637
638    /// Get the selection range (ordered).
639    fn selection_range(&self) -> (usize, usize) {
640        let anchor = self.visual_anchor.unwrap_or(self.cursor);
641        if self.cursor < anchor {
642            (self.cursor, anchor)
643        } else {
644            (anchor, self.cursor + 1) // Include cursor position
645        }
646    }
647}
648
649impl LineEditor for VimLineEditor {
650    fn handle_key(&mut self, key: Key, text: &str) -> EditResult {
651        self.clamp_cursor(text);
652
653        let result = match self.mode {
654            Mode::Normal => self.handle_normal(key, text),
655            Mode::Insert => self.handle_insert(key, text),
656            Mode::OperatorPending(op) => self.handle_operator_pending(op, key, text),
657            Mode::Visual => self.handle_visual(key, text),
658            Mode::ReplaceChar => self.handle_replace_char(key, text),
659            Mode::CommandLine => self.handle_command_line(key),
660        };
661
662        // Store yanked text
663        if let Some(ref yanked) = result.yanked {
664            self.yank_buffer = yanked.clone();
665        }
666
667        result
668    }
669
670    fn cursor(&self) -> usize {
671        self.cursor
672    }
673
674    fn status(&self) -> &str {
675        match self.mode {
676            Mode::Normal => "NORMAL",
677            Mode::Insert => "INSERT",
678            Mode::OperatorPending(Operator::Delete) => "d...",
679            Mode::OperatorPending(Operator::Change) => "c...",
680            Mode::OperatorPending(Operator::Yank) => "y...",
681            Mode::Visual => "VISUAL",
682            Mode::ReplaceChar => "r...",
683            Mode::CommandLine => "COMMAND",
684        }
685    }
686
687    fn selection(&self) -> Option<Range<usize>> {
688        if self.mode == Mode::Visual {
689            let (start, end) = self.selection_range();
690            Some(start..end)
691        } else {
692            None
693        }
694    }
695
696    fn reset(&mut self) {
697        self.cursor = 0;
698        self.mode = Mode::Normal;
699        self.visual_anchor = None;
700        self.command_buf.clear();
701        self.command_cursor = 0;
702        // Keep yank buffer and command_mode_enabled flag across resets.
703    }
704
705    fn set_cursor(&mut self, pos: usize, text: &str) {
706        // Clamp to text length and ensure we're at a char boundary
707        let pos = pos.min(text.len());
708        self.cursor = if text.is_char_boundary(pos) {
709            pos
710        } else {
711            // Walk backwards to find a valid boundary
712            let mut p = pos;
713            while p > 0 && !text.is_char_boundary(p) {
714                p -= 1;
715            }
716            p
717        };
718    }
719}
720
721#[cfg(test)]
722mod tests;