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