Skip to main content

vtcode_vim/
lib.rs

1//! Vim-style prompt editing engine shared by VT Code terminal surfaces.
2
3mod engine;
4mod text;
5mod types;
6
7pub use engine::{Editor, HandleKeyOutcome, handle_key};
8pub use text::{next_char_boundary, prev_char_boundary};
9pub use types::{VimMode, VimState};
10
11#[cfg(test)]
12mod tests {
13    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14
15    use super::{Editor, VimState, handle_key, next_char_boundary, prev_char_boundary};
16
17    #[derive(Debug)]
18    struct TestEditor {
19        content: String,
20        cursor: usize,
21    }
22
23    impl TestEditor {
24        fn new(content: &str, cursor: usize) -> Self {
25            Self {
26                content: content.to_string(),
27                cursor,
28            }
29        }
30    }
31
32    impl Editor for TestEditor {
33        fn content(&self) -> &str {
34            &self.content
35        }
36
37        fn cursor(&self) -> usize {
38            self.cursor
39        }
40
41        fn set_cursor(&mut self, pos: usize) {
42            self.cursor = pos.min(self.content.len());
43        }
44
45        fn move_left(&mut self) {
46            self.cursor = prev_char_boundary(&self.content, self.cursor);
47        }
48
49        fn move_right(&mut self) {
50            self.cursor = next_char_boundary(&self.content, self.cursor);
51        }
52
53        fn delete_char_forward(&mut self) {
54            if self.cursor >= self.content.len() {
55                return;
56            }
57            let end = next_char_boundary(&self.content, self.cursor);
58            self.content.drain(self.cursor..end);
59        }
60
61        fn insert_text(&mut self, text: &str) {
62            self.content.insert_str(self.cursor, text);
63            self.cursor += text.len();
64        }
65
66        fn replace(&mut self, content: String, cursor: usize) {
67            self.content = content;
68            self.cursor = cursor.min(self.content.len());
69        }
70
71        fn replace_range(&mut self, start: usize, end: usize, text: &str) {
72            self.content.replace_range(start..end, text);
73            self.cursor = (start + text.len()).min(self.content.len());
74        }
75    }
76
77    fn enable_normal_mode(state: &mut VimState, editor: &mut TestEditor, clipboard: &mut String) {
78        state.set_enabled(true);
79        let outcome = handle_key(
80            state,
81            editor,
82            clipboard,
83            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
84        );
85        assert!(outcome.handled);
86    }
87
88    #[test]
89    fn control_shortcuts_remain_unhandled() {
90        let mut state = VimState::new(true);
91        let mut editor = TestEditor::new("hello", 5);
92        let mut clipboard = String::new();
93
94        let outcome = handle_key(
95            &mut state,
96            &mut editor,
97            &mut clipboard,
98            &KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
99        );
100
101        assert!(!outcome.handled);
102        assert_eq!(editor.content, "hello");
103    }
104
105    #[test]
106    fn dd_deletes_current_line() {
107        let mut state = VimState::new(false);
108        let mut editor = TestEditor::new("one\ntwo\nthree", 4);
109        let mut clipboard = String::new();
110        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
111
112        assert!(
113            handle_key(
114                &mut state,
115                &mut editor,
116                &mut clipboard,
117                &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
118            )
119            .handled
120        );
121        assert!(
122            handle_key(
123                &mut state,
124                &mut editor,
125                &mut clipboard,
126                &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
127            )
128            .handled
129        );
130
131        assert_eq!(editor.content, "one\nthree");
132        assert_eq!(editor.cursor, 4);
133    }
134
135    #[test]
136    fn dot_repeats_change_word_edit() {
137        let mut state = VimState::new(false);
138        let mut editor = TestEditor::new("alpha beta", 0);
139        let mut clipboard = String::new();
140        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
141
142        let _ = handle_key(
143            &mut state,
144            &mut editor,
145            &mut clipboard,
146            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
147        );
148        let _ = handle_key(
149            &mut state,
150            &mut editor,
151            &mut clipboard,
152            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
153        );
154        editor.insert_text("A");
155        let _ = handle_key(
156            &mut state,
157            &mut editor,
158            &mut clipboard,
159            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
160        );
161
162        editor.set_cursor(1);
163        let _ = handle_key(
164            &mut state,
165            &mut editor,
166            &mut clipboard,
167            &KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE),
168        );
169
170        assert_eq!(editor.content, "AA");
171    }
172
173    #[test]
174    fn dot_repeats_change_line_edit() {
175        let mut state = VimState::new(false);
176        let mut editor = TestEditor::new("one\ntwo", 0);
177        let mut clipboard = String::new();
178        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
179
180        let _ = handle_key(
181            &mut state,
182            &mut editor,
183            &mut clipboard,
184            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
185        );
186        let _ = handle_key(
187            &mut state,
188            &mut editor,
189            &mut clipboard,
190            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
191        );
192        editor.insert_text("ONE");
193        let _ = handle_key(
194            &mut state,
195            &mut editor,
196            &mut clipboard,
197            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
198        );
199
200        editor.set_cursor(4);
201        let _ = handle_key(
202            &mut state,
203            &mut editor,
204            &mut clipboard,
205            &KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE),
206        );
207
208        assert_eq!(editor.content, "ONE\nONE");
209    }
210
211    #[test]
212    fn vertical_motion_preserves_preferred_column() {
213        let mut state = VimState::new(false);
214        let mut editor = TestEditor::new("abcd\nxy\nabcd", 3);
215        let mut clipboard = String::new();
216        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
217
218        let _ = handle_key(
219            &mut state,
220            &mut editor,
221            &mut clipboard,
222            &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
223        );
224        assert_eq!(editor.cursor, 7);
225
226        let _ = handle_key(
227            &mut state,
228            &mut editor,
229            &mut clipboard,
230            &KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
231        );
232        assert_eq!(editor.cursor, 11);
233
234        let _ = handle_key(
235            &mut state,
236            &mut editor,
237            &mut clipboard,
238            &KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
239        );
240        assert_eq!(editor.cursor, 7);
241    }
242
243    #[test]
244    fn find_repeat_reuses_last_character_search() {
245        let mut state = VimState::new(false);
246        let mut editor = TestEditor::new("a b c b", 0);
247        let mut clipboard = String::new();
248        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
249
250        let _ = handle_key(
251            &mut state,
252            &mut editor,
253            &mut clipboard,
254            &KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
255        );
256        let _ = handle_key(
257            &mut state,
258            &mut editor,
259            &mut clipboard,
260            &KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
261        );
262        assert_eq!(editor.cursor, 2);
263
264        let _ = handle_key(
265            &mut state,
266            &mut editor,
267            &mut clipboard,
268            &KeyEvent::new(KeyCode::Char(';'), KeyModifiers::NONE),
269        );
270        assert_eq!(editor.cursor, 6);
271
272        let _ = handle_key(
273            &mut state,
274            &mut editor,
275            &mut clipboard,
276            &KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE),
277        );
278        assert_eq!(editor.cursor, 2);
279    }
280
281    #[test]
282    fn x_deletes_character_at_cursor() {
283        let mut state = VimState::new(false);
284        let mut editor = TestEditor::new("hello", 1);
285        let mut clipboard = String::new();
286        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
287
288        let _ = handle_key(
289            &mut state,
290            &mut editor,
291            &mut clipboard,
292            &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
293        );
294        assert_eq!(editor.content, "hllo");
295        assert_eq!(editor.cursor, 1);
296    }
297
298    #[test]
299    fn dw_deletes_word_forward() {
300        let mut state = VimState::new(false);
301        let mut editor = TestEditor::new("hello world", 0);
302        let mut clipboard = String::new();
303        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
304
305        let _ = handle_key(
306            &mut state,
307            &mut editor,
308            &mut clipboard,
309            &KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
310        );
311        let _ = handle_key(
312            &mut state,
313            &mut editor,
314            &mut clipboard,
315            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
316        );
317        assert_eq!(editor.content, "world");
318        assert_eq!(editor.cursor, 0);
319    }
320
321    #[test]
322    fn j_joins_lines() {
323        let mut state = VimState::new(false);
324        let mut editor = TestEditor::new("hello\nworld", 0);
325        let mut clipboard = String::new();
326        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
327
328        let _ = handle_key(
329            &mut state,
330            &mut editor,
331            &mut clipboard,
332            &KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE),
333        );
334        assert_eq!(editor.content, "hello world");
335        // Cursor lands after the join space (on 'w'), not on the space itself
336        assert_eq!(editor.cursor, 6);
337    }
338
339    #[test]
340    fn p_pastes_charwise_after_cursor() {
341        let mut state = VimState::new(false);
342        let mut editor = TestEditor::new("abc", 1);
343        let mut clipboard = "XY".to_string();
344        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
345
346        state.clipboard_kind = crate::types::ClipboardKind::CharWise;
347
348        let _ = handle_key(
349            &mut state,
350            &mut editor,
351            &mut clipboard,
352            &KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
353        );
354        // Paste after cursor char 'b' (pos 1) → inserts at pos 2
355        assert_eq!(editor.content, "abXYc");
356    }
357
358    #[test]
359    fn p_pastes_linewise_after_current_line() {
360        let mut state = VimState::new(false);
361        let mut editor = TestEditor::new("one\nthree", 0);
362        let mut clipboard = "two\n".to_string();
363        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
364
365        state.clipboard_kind = crate::types::ClipboardKind::LineWise;
366
367        let _ = handle_key(
368            &mut state,
369            &mut editor,
370            &mut clipboard,
371            &KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
372        );
373        assert_eq!(editor.content, "one\ntwo\nthree");
374    }
375
376    #[test]
377    fn y_then_p_yanks_and_pastes_line() {
378        let mut state = VimState::new(false);
379        let mut editor = TestEditor::new("one\ntwo\nthree", 0);
380        let mut clipboard = String::new();
381        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
382
383        // Yank current line
384        let _ = handle_key(
385            &mut state,
386            &mut editor,
387            &mut clipboard,
388            &KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE),
389        );
390        assert_eq!(clipboard, "one\n");
391
392        // Paste after
393        let _ = handle_key(
394            &mut state,
395            &mut editor,
396            &mut clipboard,
397            &KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
398        );
399        assert_eq!(editor.content, "one\none\ntwo\nthree");
400    }
401
402    #[test]
403    fn d_deletes_to_line_end() {
404        let mut state = VimState::new(false);
405        let mut editor = TestEditor::new("hello world", 5);
406        let mut clipboard = String::new();
407        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
408
409        let _ = handle_key(
410            &mut state,
411            &mut editor,
412            &mut clipboard,
413            &KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE),
414        );
415        assert_eq!(editor.content, "hello");
416        assert_eq!(editor.cursor, 5);
417    }
418
419    #[test]
420    fn w_moves_to_next_word_start() {
421        let mut state = VimState::new(false);
422        let mut editor = TestEditor::new("hello world foo", 0);
423        let mut clipboard = String::new();
424        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
425
426        let _ = handle_key(
427            &mut state,
428            &mut editor,
429            &mut clipboard,
430            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
431        );
432        assert_eq!(editor.cursor, 6);
433
434        let _ = handle_key(
435            &mut state,
436            &mut editor,
437            &mut clipboard,
438            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
439        );
440        assert_eq!(editor.cursor, 12);
441    }
442
443    #[test]
444    fn b_moves_to_prev_word_start() {
445        let mut state = VimState::new(false);
446        let mut editor = TestEditor::new("hello world foo", 12);
447        let mut clipboard = String::new();
448        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
449
450        let _ = handle_key(
451            &mut state,
452            &mut editor,
453            &mut clipboard,
454            &KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
455        );
456        assert_eq!(editor.cursor, 6);
457
458        let _ = handle_key(
459            &mut state,
460            &mut editor,
461            &mut clipboard,
462            &KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
463        );
464        assert_eq!(editor.cursor, 0);
465    }
466
467    #[test]
468    fn indent_adds_whitespace() {
469        let mut state = VimState::new(false);
470        let mut editor = TestEditor::new("hello\nworld", 0);
471        let mut clipboard = String::new();
472        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
473
474        let _ = handle_key(
475            &mut state,
476            &mut editor,
477            &mut clipboard,
478            &KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE),
479        );
480        let _ = handle_key(
481            &mut state,
482            &mut editor,
483            &mut clipboard,
484            &KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE),
485        );
486        assert_eq!(editor.content, "    hello\nworld");
487    }
488
489    #[test]
490    fn ciw_changes_inner_word() {
491        let mut state = VimState::new(false);
492        let mut editor = TestEditor::new("hello world", 0);
493        let mut clipboard = String::new();
494        enable_normal_mode(&mut state, &mut editor, &mut clipboard);
495
496        // ciw
497        let _ = handle_key(
498            &mut state,
499            &mut editor,
500            &mut clipboard,
501            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
502        );
503        let _ = handle_key(
504            &mut state,
505            &mut editor,
506            &mut clipboard,
507            &KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
508        );
509        let _ = handle_key(
510            &mut state,
511            &mut editor,
512            &mut clipboard,
513            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
514        );
515        // Now in insert mode, type replacement
516        editor.insert_text("hi");
517        let _ = handle_key(
518            &mut state,
519            &mut editor,
520            &mut clipboard,
521            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
522        );
523        assert_eq!(editor.content, "hi world");
524    }
525}