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}
24
25impl Slot {
26    fn new(text: String, linewise: bool) -> Self {
27        Self { text, linewise }
28    }
29}
30
31#[derive(Default, Debug, Clone)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Registers {
34    /// `"` — written by every yank / delete / change.
35    pub unnamed: Slot,
36    /// `"0` — last yank only.
37    pub yank_zero: Slot,
38    /// `"1`–`"9` — last 9 line-sized deletes (`"1` newest).
39    pub delete_ring: [Slot; 9],
40    /// `"-` — small-delete register: last delete/change of less than
41    /// one line, when no register was named.
42    pub small_delete: Slot,
43    /// `"a`–`"z` — named user registers.
44    pub named: [Slot; 26],
45    /// `"+` / `"*` — system clipboard register. Both selectors alias
46    /// the same slot (matches the typical Linux/macOS/Windows setup
47    /// where there's no separate primary selection in our pipeline).
48    /// The host (the host) syncs this slot from the OS clipboard
49    /// before paste and from the slot back out on yank.
50    pub clip: Slot,
51    /// `"%` — synthetic read-only register: current buffer filename.
52    /// Set by the host whenever the active slot changes.
53    pub filename: Option<String>,
54    /// Pre-built `Slot` for the `%` register. Kept in sync with `filename`
55    /// by [`Registers::set_filename`] so `read('%')` can return `&Slot`.
56    /// Derived from `filename` — not serialised independently.
57    #[cfg_attr(feature = "serde", serde(skip))]
58    filename_slot: Option<Slot>,
59}
60
61impl Registers {
62    /// Record a yank operation. Writes to `"`, `"0`, and (if
63    /// `target` is set) the named slot. When `target` is `'_'`
64    /// (black-hole register) all writes are suppressed — vim discards
65    /// the text without touching any register.
66    pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
67        // Black-hole register: discard the text entirely.
68        if target == Some('_') {
69            return;
70        }
71        let slot = Slot::new(text, linewise);
72        self.unnamed = slot.clone();
73        // vim: `"0` holds the last yank only when the command did not name
74        // another register — `"ayy` leaves `"0` untouched.
75        if target.is_none() {
76            self.yank_zero = slot.clone();
77        }
78        if let Some(c) = target {
79            self.write_named(c, slot);
80            // vim: the unnamed register points at the named register just
81            // written, so an uppercase-append (`"Ayy`) leaves `"` holding the
82            // full appended contents, not just the latest fragment.
83            if let Some(named) = self.read(c) {
84                self.unnamed = named.clone();
85            }
86        }
87    }
88
89    /// Record a delete / change. Writes to `"` and routes the text to a
90    /// numbered or small-delete register (and, if `target` is set, the
91    /// named slot). Empty deletes are dropped — vim doesn't pollute the
92    /// ring with no-ops. When `target` is `'_'` (black-hole register) all
93    /// writes are suppressed, preserving the previous register state.
94    ///
95    /// Register routing follows vim (`:help quote1`, `:help quote_-`):
96    /// - a named target suppresses both the numbered ring and `"-`;
97    /// - otherwise a delete of a whole line or spanning more than one line
98    ///   shifts the `"1`–`"9` ring;
99    /// - a smaller (sub-line) delete goes to `"-` and leaves the ring alone.
100    pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
101        if text.is_empty() {
102            return;
103        }
104        // Black-hole register: discard the text entirely.
105        if target == Some('_') {
106            return;
107        }
108        let slot = Slot::new(text, linewise);
109        self.unnamed = slot.clone();
110        if let Some(c) = target {
111            // A named register suppresses the numbered ring and `"-`.
112            self.write_named(c, slot);
113            // vim: unnamed points at the named register just written.
114            if let Some(named) = self.read(c) {
115                self.unnamed = named.clone();
116            }
117            return;
118        }
119        if slot.linewise || slot.text.contains('\n') {
120            // Line-sized delete: shift the numbered ring, newest into `"1`.
121            for i in (1..9).rev() {
122                self.delete_ring[i] = self.delete_ring[i - 1].clone();
123            }
124            self.delete_ring[0] = slot;
125        } else {
126            // Small delete: goes to `"-`, ring untouched.
127            self.small_delete = slot;
128        }
129    }
130
131    /// Read a register by its single-char selector. Returns `None`
132    /// for unrecognised selectors.
133    ///
134    /// `'%'` is a synthetic read-only register: returns the current buffer
135    /// filename when one has been set via [`Registers::set_filename`].
136    pub fn read(&self, reg: char) -> Option<&Slot> {
137        match reg {
138            '"' => Some(&self.unnamed),
139            '0' => Some(&self.yank_zero),
140            '1'..='9' => Some(&self.delete_ring[(reg as u8 - b'1') as usize]),
141            '-' => Some(&self.small_delete),
142            'a'..='z' => Some(&self.named[(reg as u8 - b'a') as usize]),
143            'A'..='Z' => Some(&self.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
144            '+' | '*' => Some(&self.clip),
145            // `%` is a synthetic read-only register: current buffer filename.
146            '%' => self.filename_slot.as_ref(),
147            _ => None,
148        }
149    }
150
151    /// Host hook: set the `"%` register to the given filename. Call this
152    /// whenever the active buffer changes.
153    pub fn set_filename(&mut self, name: Option<String>) {
154        self.filename = name.clone();
155        self.filename_slot = name.map(|n| Slot::new(n, false));
156    }
157
158    /// Replace the clipboard slot's contents — host hook for syncing
159    /// from the OS clipboard before a paste from `"+` / `"*`.
160    pub fn set_clipboard(&mut self, text: String, linewise: bool) {
161        self.clip = Slot::new(text, linewise);
162    }
163
164    fn write_named(&mut self, c: char, slot: Slot) {
165        if c.is_ascii_lowercase() {
166            self.named[(c as u8 - b'a') as usize] = slot;
167        } else if c.is_ascii_uppercase() {
168            let idx = (c.to_ascii_lowercase() as u8 - b'a') as usize;
169            let cur = &mut self.named[idx];
170            cur.text.push_str(&slot.text);
171            cur.linewise = slot.linewise || cur.linewise;
172        } else if c == '+' || c == '*' {
173            self.clip = slot;
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn yank_writes_unnamed_and_zero() {
184        let mut r = Registers::default();
185        r.record_yank("foo".into(), false, None);
186        assert_eq!(r.read('"').unwrap().text, "foo");
187        assert_eq!(r.read('0').unwrap().text, "foo");
188    }
189
190    #[test]
191    fn delete_rotates_ring_and_skips_zero() {
192        let mut r = Registers::default();
193        r.record_yank("kept".into(), false, None);
194        // Line-sized deletes fill the numbered ring.
195        r.record_delete("d1\n".into(), true, None);
196        r.record_delete("d2\n".into(), true, None);
197        // Newest delete is "1.
198        assert_eq!(r.read('1').unwrap().text, "d2\n");
199        assert_eq!(r.read('2').unwrap().text, "d1\n");
200        // "0 untouched by deletes.
201        assert_eq!(r.read('0').unwrap().text, "kept");
202        // Unnamed mirrors the latest write.
203        assert_eq!(r.read('"').unwrap().text, "d2\n");
204    }
205
206    #[test]
207    fn small_delete_goes_to_dash_not_ring() {
208        let mut r = Registers::default();
209        // Seed the ring with a line-sized delete.
210        r.record_delete("line\n".into(), true, None);
211        // Sub-line deletes (e.g. `x`, `dw`) land in "- and leave the ring.
212        r.record_delete("x".into(), false, None);
213        r.record_delete("y".into(), false, None);
214        assert_eq!(r.read('-').unwrap().text, "y");
215        // Ring still holds the earlier line delete, unshifted.
216        assert_eq!(r.read('1').unwrap().text, "line\n");
217        assert!(r.read('2').unwrap().text.is_empty());
218        // Unnamed still mirrors the latest write.
219        assert_eq!(r.read('"').unwrap().text, "y");
220    }
221
222    #[test]
223    fn multiline_charwise_delete_fills_ring_not_dash() {
224        let mut r = Registers::default();
225        // A charwise delete spanning a newline is "line-sized" for routing.
226        r.record_delete("a\nb".into(), false, None);
227        assert_eq!(r.read('1').unwrap().text, "a\nb");
228        assert!(r.read('-').unwrap().text.is_empty());
229    }
230
231    #[test]
232    fn named_delete_leaves_ring_and_dash_untouched() {
233        let mut r = Registers::default();
234        r.record_delete("ring\n".into(), true, None);
235        r.record_delete("dash".into(), false, None);
236        // `"add` — named target suppresses both ring and "-.
237        r.record_delete("named\n".into(), true, Some('a'));
238        assert_eq!(r.read('a').unwrap().text, "named\n");
239        assert_eq!(r.read('1').unwrap().text, "ring\n");
240        assert_eq!(r.read('-').unwrap().text, "dash");
241        // Unnamed mirrors the named write.
242        assert_eq!(r.read('"').unwrap().text, "named\n");
243    }
244
245    #[test]
246    fn yank_to_named_preserves_zero() {
247        let mut r = Registers::default();
248        r.record_yank("original".into(), false, None);
249        assert_eq!(r.read('0').unwrap().text, "original");
250        // `"ayy` must not clobber "0.
251        r.record_yank("into a".into(), false, Some('a'));
252        assert_eq!(r.read('0').unwrap().text, "original");
253        assert_eq!(r.read('a').unwrap().text, "into a");
254    }
255
256    #[test]
257    fn named_lowercase_overwrites_uppercase_appends() {
258        let mut r = Registers::default();
259        r.record_yank("hello ".into(), false, Some('a'));
260        r.record_yank("world".into(), false, Some('A'));
261        assert_eq!(r.read('a').unwrap().text, "hello world");
262        // "A is just a write target; reading 'A' returns the same slot.
263        assert_eq!(r.read('A').unwrap().text, "hello world");
264    }
265
266    #[test]
267    fn empty_delete_is_dropped() {
268        let mut r = Registers::default();
269        r.record_delete("first\n".into(), true, None);
270        r.record_delete(String::new(), true, None);
271        assert_eq!(r.read('1').unwrap().text, "first\n");
272        assert!(r.read('2').unwrap().text.is_empty());
273    }
274
275    #[test]
276    fn unknown_selector_returns_none() {
277        let r = Registers::default();
278        assert!(r.read('?').is_none());
279        assert!(r.read('!').is_none());
280    }
281
282    #[test]
283    fn plus_and_star_alias_clipboard_slot() {
284        let mut r = Registers::default();
285        r.set_clipboard("payload".into(), false);
286        assert_eq!(r.read('+').unwrap().text, "payload");
287        assert_eq!(r.read('*').unwrap().text, "payload");
288    }
289
290    #[test]
291    fn yank_to_plus_writes_clipboard_slot() {
292        let mut r = Registers::default();
293        r.record_yank("hi".into(), false, Some('+'));
294        assert_eq!(r.read('+').unwrap().text, "hi");
295        // Unnamed always mirrors the latest write.
296        assert_eq!(r.read('"').unwrap().text, "hi");
297    }
298
299    #[test]
300    fn percent_register_returns_none_when_no_filename() {
301        let r = Registers::default();
302        assert!(r.read('%').is_none());
303    }
304
305    #[test]
306    fn percent_register_returns_filename_after_set() {
307        let mut r = Registers::default();
308        r.set_filename(Some("src/main.rs".into()));
309        let slot = r
310            .read('%')
311            .expect("'%' should return Some after set_filename");
312        assert_eq!(slot.text, "src/main.rs");
313        assert!(!slot.linewise, "'%' slot should be charwise");
314    }
315
316    #[test]
317    fn percent_register_clears_when_set_to_none() {
318        let mut r = Registers::default();
319        r.set_filename(Some("foo.txt".into()));
320        r.set_filename(None);
321        assert!(r.read('%').is_none());
322    }
323}