Skip to main content

hjkl_engine/
registers.rs

1//! Vim-style register bank.
2//!
3//! Slots:
4//! - `"` (unnamed) — written by every `y` / `d` / `c` / `x`; the
5//!   default source for `p` / `P`.
6//! - `"0` — the most recent **yank** (only when no register was
7//!   named). Deletes do not touch it, so `yw…dw…p` still pastes the
8//!   original yank.
9//! - `"1`–`"9` — numbered delete/change ring. A delete of a whole
10//!   line or spanning more than one line shifts the ring (newest at
11//!   `"1`, oldest dropped off `"9`). Deletes of less than one line
12//!   do **not** touch the ring — they go to `"-` instead.
13//! - `"-` — small-delete register: text from a delete/change of less
14//!   than one line (unless the command named another register).
15//! - `"a`–`"z` — named slots. A capital letter (`"A`…) appends to
16//!   the matching lowercase slot, matching vim semantics.
17
18#[derive(Default, Clone, Debug)]
19
20pub struct Slot {
21    pub text: String,
22    pub linewise: bool,
23    /// Blockwise (visual-block `<C-v>`) register. When set, `text` still
24    /// holds the row segments joined by `\n` (so charwise-fallback paste
25    /// and the `hjkl_get_register` RPC keep the same string), but `p`/`P`
26    /// re-insert it as COLUMNS at the cursor rather than spilling onto new
27    /// lines. Mutually exclusive with `linewise` in practice.
28    ///
29    pub blockwise: bool,
30    /// Column width of a blockwise register — the number of cells each row
31    /// segment is padded to (with trailing spaces) when pasted. Zero for
32    /// charwise/linewise slots. See [`Slot::blockwise`].
33    pub block_width: usize,
34}
35
36impl Slot {
37    fn new(text: String, linewise: bool) -> Self {
38        Self {
39            text,
40            linewise,
41            blockwise: false,
42            block_width: 0,
43        }
44    }
45
46    /// Build a blockwise slot (see [`Slot::blockwise`]). `width` is the
47    /// column width every row segment pads to on paste.
48    fn new_block(text: String, width: usize) -> Self {
49        Self {
50            text,
51            linewise: false,
52            blockwise: true,
53            block_width: width,
54        }
55    }
56}
57
58#[derive(Default, Debug, Clone)]
59
60pub struct Registers {
61    /// `"` — written by every yank / delete / change.
62    pub unnamed: Slot,
63    /// `"0` — last yank only.
64    pub yank_zero: Slot,
65    /// `"1`–`"9` — last 9 line-sized deletes (`"1` newest).
66    pub delete_ring: [Slot; 9],
67    /// `"-` — small-delete register: last delete/change of less than
68    /// one line, when no register was named.
69    pub small_delete: Slot,
70    /// `"a`–`"z` — named user registers.
71    pub named: [Slot; 26],
72    /// `"+` / `"*` — system clipboard register. Both selectors alias
73    /// the same slot (matches the typical Linux/macOS/Windows setup
74    /// where there's no separate primary selection in our pipeline).
75    /// The host (the host) syncs this slot from the OS clipboard
76    /// before paste and from the slot back out on yank.
77    pub clip: Slot,
78    /// `"%` — synthetic read-only register: current buffer filename.
79    /// Set by the host whenever the active slot changes.
80    pub filename: Option<String>,
81    /// Pre-built `Slot` for the `%` register. Kept in sync with `filename`
82    /// by [`Registers::set_filename`] so `read('%')` can return `&Slot`.
83    filename_slot: Option<Slot>,
84}
85
86impl Registers {
87    /// Record a yank operation. Writes to `"`, `"0`, and (if
88    /// `target` is set) the named slot. When `target` is `'_'`
89    /// (black-hole register) all writes are suppressed — vim discards
90    /// the text without touching any register.
91    pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
92        // Black-hole register: discard the text entirely.
93        if target == Some('_') {
94            return;
95        }
96        self.store_yank(Slot::new(text, linewise), target);
97    }
98
99    /// Record a blockwise (visual-block) yank. Same routing as
100    /// [`Registers::record_yank`] but the resulting slot is flagged
101    /// blockwise with column `width` so `p`/`P` re-insert the segments as
102    /// columns (see [`Slot::blockwise`]).
103    pub fn record_yank_block(&mut self, text: String, width: usize, target: Option<char>) {
104        if target == Some('_') {
105            return;
106        }
107        self.store_yank(Slot::new_block(text, width), target);
108    }
109
110    /// Shared routing for charwise/linewise/blockwise yanks. Writes `"`,
111    /// `"0` (only when unnamed), and the named target if any.
112    fn store_yank(&mut self, slot: Slot, target: Option<char>) {
113        self.unnamed = slot.clone();
114        // vim: `"0` holds the last yank only when the command did not name
115        // another register — `"ayy` leaves `"0` untouched.
116        if target.is_none() {
117            self.yank_zero = slot.clone();
118        }
119        if let Some(c) = target {
120            self.write_named(c, slot);
121            // vim: the unnamed register points at the named register just
122            // written, so an uppercase-append (`"Ayy`) leaves `"` holding the
123            // full appended contents, not just the latest fragment.
124            if let Some(named) = self.read(c) {
125                self.unnamed = named.clone();
126            }
127        }
128    }
129
130    /// Record a delete / change. Writes to `"` and routes the text to a
131    /// numbered or small-delete register (and, if `target` is set, the
132    /// named slot). Empty deletes are dropped — vim doesn't pollute the
133    /// ring with no-ops. When `target` is `'_'` (black-hole register) all
134    /// writes are suppressed, preserving the previous register state.
135    ///
136    /// Register routing follows vim (`:help quote1`, `:help quote_-`):
137    /// - a named target suppresses both the numbered ring and `"-`;
138    /// - otherwise a delete of a whole line or spanning more than one line
139    ///   shifts the `"1`–`"9` ring;
140    /// - a smaller (sub-line) delete goes to `"-` and leaves the ring alone.
141    pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
142        if text.is_empty() {
143            return;
144        }
145        // Black-hole register: discard the text entirely.
146        if target == Some('_') {
147            return;
148        }
149        self.store_delete(Slot::new(text, linewise), target);
150    }
151
152    /// Record a blockwise (visual-block) delete / change. Same routing as
153    /// [`Registers::record_delete`] but flags the slot blockwise with
154    /// column `width` (see [`Slot::blockwise`]).
155    pub fn record_delete_block(&mut self, text: String, width: usize, target: Option<char>) {
156        if text.is_empty() {
157            return;
158        }
159        if target == Some('_') {
160            return;
161        }
162        self.store_delete(Slot::new_block(text, width), target);
163    }
164
165    /// Shared routing for charwise/linewise/blockwise deletes/changes.
166    fn store_delete(&mut self, slot: Slot, target: Option<char>) {
167        self.unnamed = slot.clone();
168        if let Some(c) = target {
169            // A named register suppresses the numbered ring and `"-`.
170            self.write_named(c, slot);
171            // vim: unnamed points at the named register just written.
172            if let Some(named) = self.read(c) {
173                self.unnamed = named.clone();
174            }
175            return;
176        }
177        if slot.linewise || slot.text.contains('\n') {
178            // Line-sized delete: shift the numbered ring, newest into `"1`.
179            for i in (1..9).rev() {
180                self.delete_ring[i] = self.delete_ring[i - 1].clone();
181            }
182            self.delete_ring[0] = slot;
183        } else {
184            // Small delete: goes to `"-`, ring untouched.
185            self.small_delete = slot;
186        }
187    }
188
189    /// Read a register by its single-char selector. Returns `None`
190    /// for unrecognised selectors.
191    ///
192    /// `'%'` is a synthetic read-only register: returns the current buffer
193    /// filename when one has been set via [`Registers::set_filename`].
194    pub fn read(&self, reg: char) -> Option<&Slot> {
195        match reg {
196            '"' => Some(&self.unnamed),
197            '0' => Some(&self.yank_zero),
198            '1'..='9' => Some(&self.delete_ring[(reg as u8 - b'1') as usize]),
199            '-' => Some(&self.small_delete),
200            'a'..='z' => Some(&self.named[(reg as u8 - b'a') as usize]),
201            'A'..='Z' => Some(&self.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
202            '+' | '*' => Some(&self.clip),
203            // `%` is a synthetic read-only register: current buffer filename.
204            '%' => self.filename_slot.as_ref(),
205            _ => None,
206        }
207    }
208
209    /// Host hook: set the `"%` register to the given filename. Call this
210    /// whenever the active buffer changes.
211    pub fn set_filename(&mut self, name: Option<String>) {
212        self.filename = name.clone();
213        self.filename_slot = name.map(|n| Slot::new(n, false));
214    }
215
216    /// Replace the clipboard slot's contents — host hook for syncing
217    /// from the OS clipboard before a paste from `"+` / `"*`.
218    pub fn set_clipboard(&mut self, text: String, linewise: bool) {
219        self.clip = Slot::new(text, linewise);
220    }
221
222    fn write_named(&mut self, c: char, slot: Slot) {
223        if c.is_ascii_lowercase() {
224            self.named[(c as u8 - b'a') as usize] = slot;
225        } else if c.is_ascii_uppercase() {
226            let idx = (c.to_ascii_lowercase() as u8 - b'a') as usize;
227            let cur = &mut self.named[idx];
228            cur.text.push_str(&slot.text);
229            cur.linewise = slot.linewise || cur.linewise;
230            cur.blockwise = slot.blockwise || cur.blockwise;
231            cur.block_width = cur.block_width.max(slot.block_width);
232        } else if c == '+' || c == '*' {
233            self.clip = slot;
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn yank_writes_unnamed_and_zero() {
244        let mut r = Registers::default();
245        r.record_yank("foo".into(), false, None);
246        assert_eq!(r.read('"').unwrap().text, "foo");
247        assert_eq!(r.read('0').unwrap().text, "foo");
248    }
249
250    #[test]
251    fn delete_rotates_ring_and_skips_zero() {
252        let mut r = Registers::default();
253        r.record_yank("kept".into(), false, None);
254        // Line-sized deletes fill the numbered ring.
255        r.record_delete("d1\n".into(), true, None);
256        r.record_delete("d2\n".into(), true, None);
257        // Newest delete is "1.
258        assert_eq!(r.read('1').unwrap().text, "d2\n");
259        assert_eq!(r.read('2').unwrap().text, "d1\n");
260        // "0 untouched by deletes.
261        assert_eq!(r.read('0').unwrap().text, "kept");
262        // Unnamed mirrors the latest write.
263        assert_eq!(r.read('"').unwrap().text, "d2\n");
264    }
265
266    #[test]
267    fn small_delete_goes_to_dash_not_ring() {
268        let mut r = Registers::default();
269        // Seed the ring with a line-sized delete.
270        r.record_delete("line\n".into(), true, None);
271        // Sub-line deletes (e.g. `x`, `dw`) land in "- and leave the ring.
272        r.record_delete("x".into(), false, None);
273        r.record_delete("y".into(), false, None);
274        assert_eq!(r.read('-').unwrap().text, "y");
275        // Ring still holds the earlier line delete, unshifted.
276        assert_eq!(r.read('1').unwrap().text, "line\n");
277        assert!(r.read('2').unwrap().text.is_empty());
278        // Unnamed still mirrors the latest write.
279        assert_eq!(r.read('"').unwrap().text, "y");
280    }
281
282    #[test]
283    fn multiline_charwise_delete_fills_ring_not_dash() {
284        let mut r = Registers::default();
285        // A charwise delete spanning a newline is "line-sized" for routing.
286        r.record_delete("a\nb".into(), false, None);
287        assert_eq!(r.read('1').unwrap().text, "a\nb");
288        assert!(r.read('-').unwrap().text.is_empty());
289    }
290
291    #[test]
292    fn named_delete_leaves_ring_and_dash_untouched() {
293        let mut r = Registers::default();
294        r.record_delete("ring\n".into(), true, None);
295        r.record_delete("dash".into(), false, None);
296        // `"add` — named target suppresses both ring and "-.
297        r.record_delete("named\n".into(), true, Some('a'));
298        assert_eq!(r.read('a').unwrap().text, "named\n");
299        assert_eq!(r.read('1').unwrap().text, "ring\n");
300        assert_eq!(r.read('-').unwrap().text, "dash");
301        // Unnamed mirrors the named write.
302        assert_eq!(r.read('"').unwrap().text, "named\n");
303    }
304
305    #[test]
306    fn yank_to_named_preserves_zero() {
307        let mut r = Registers::default();
308        r.record_yank("original".into(), false, None);
309        assert_eq!(r.read('0').unwrap().text, "original");
310        // `"ayy` must not clobber "0.
311        r.record_yank("into a".into(), false, Some('a'));
312        assert_eq!(r.read('0').unwrap().text, "original");
313        assert_eq!(r.read('a').unwrap().text, "into a");
314    }
315
316    #[test]
317    fn named_lowercase_overwrites_uppercase_appends() {
318        let mut r = Registers::default();
319        r.record_yank("hello ".into(), false, Some('a'));
320        r.record_yank("world".into(), false, Some('A'));
321        assert_eq!(r.read('a').unwrap().text, "hello world");
322        // "A is just a write target; reading 'A' returns the same slot.
323        assert_eq!(r.read('A').unwrap().text, "hello world");
324    }
325
326    #[test]
327    fn empty_delete_is_dropped() {
328        let mut r = Registers::default();
329        r.record_delete("first\n".into(), true, None);
330        r.record_delete(String::new(), true, None);
331        assert_eq!(r.read('1').unwrap().text, "first\n");
332        assert!(r.read('2').unwrap().text.is_empty());
333    }
334
335    #[test]
336    fn unknown_selector_returns_none() {
337        let r = Registers::default();
338        assert!(r.read('?').is_none());
339        assert!(r.read('!').is_none());
340    }
341
342    #[test]
343    fn plus_and_star_alias_clipboard_slot() {
344        let mut r = Registers::default();
345        r.set_clipboard("payload".into(), false);
346        assert_eq!(r.read('+').unwrap().text, "payload");
347        assert_eq!(r.read('*').unwrap().text, "payload");
348    }
349
350    #[test]
351    fn yank_to_plus_writes_clipboard_slot() {
352        let mut r = Registers::default();
353        r.record_yank("hi".into(), false, Some('+'));
354        assert_eq!(r.read('+').unwrap().text, "hi");
355        // Unnamed always mirrors the latest write.
356        assert_eq!(r.read('"').unwrap().text, "hi");
357    }
358
359    #[test]
360    fn percent_register_returns_none_when_no_filename() {
361        let r = Registers::default();
362        assert!(r.read('%').is_none());
363    }
364
365    #[test]
366    fn percent_register_returns_filename_after_set() {
367        let mut r = Registers::default();
368        r.set_filename(Some("src/main.rs".into()));
369        let slot = r
370            .read('%')
371            .expect("'%' should return Some after set_filename");
372        assert_eq!(slot.text, "src/main.rs");
373        assert!(!slot.linewise, "'%' slot should be charwise");
374    }
375
376    #[test]
377    fn block_yank_flags_slot_blockwise_with_width() {
378        let mut r = Registers::default();
379        r.record_yank_block("ab\nef".into(), 2, None);
380        let s = r.read('"').unwrap();
381        assert_eq!(s.text, "ab\nef");
382        assert!(s.blockwise);
383        assert!(!s.linewise);
384        assert_eq!(s.block_width, 2);
385        // `"0` mirrors the yank and is blockwise too.
386        let z = r.read('0').unwrap();
387        assert!(z.blockwise);
388        assert_eq!(z.block_width, 2);
389    }
390
391    #[test]
392    fn block_yank_to_named_carries_kind_and_width() {
393        let mut r = Registers::default();
394        r.record_yank_block("cd\nkl".into(), 3, Some('a'));
395        let a = r.read('a').unwrap();
396        assert!(a.blockwise);
397        assert_eq!(a.block_width, 3);
398        // Unnamed mirrors the named write, kind intact.
399        assert!(r.read('"').unwrap().blockwise);
400        assert_eq!(r.read('"').unwrap().block_width, 3);
401    }
402
403    #[test]
404    fn charwise_yank_leaves_slot_non_block() {
405        let mut r = Registers::default();
406        r.record_yank("plain".into(), false, None);
407        let s = r.read('"').unwrap();
408        assert!(!s.blockwise);
409        assert_eq!(s.block_width, 0);
410    }
411
412    #[test]
413    fn block_delete_flags_slot_blockwise() {
414        let mut r = Registers::default();
415        // A blockwise delete spans a newline → routes to the numbered ring.
416        r.record_delete_block("a\nb".into(), 1, None);
417        let s = r.read('1').unwrap();
418        assert!(s.blockwise);
419        assert_eq!(s.block_width, 1);
420        assert!(r.read('"').unwrap().blockwise);
421    }
422
423    #[test]
424    fn percent_register_clears_when_set_to_none() {
425        let mut r = Registers::default();
426        r.set_filename(Some("foo.txt".into()));
427        r.set_filename(None);
428        assert!(r.read('%').is_none());
429    }
430}