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**. Deletes do not touch it, so
7//!   `yw…dw…p` still pastes the original yank.
8//! - `"1`–`"9` — small-delete ring. Each delete shifts the ring
9//!   (newest at `"1`, oldest dropped off `"9`).
10//! - `"a`–`"z` — named slots. A capital letter (`"A`…) appends to
11//!   the matching lowercase slot, matching vim semantics.
12
13#[derive(Default, Clone, Debug)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub struct Slot {
16    pub text: String,
17    pub linewise: bool,
18}
19
20impl Slot {
21    fn new(text: String, linewise: bool) -> Self {
22        Self { text, linewise }
23    }
24}
25
26#[derive(Default, Debug, Clone)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub struct Registers {
29    /// `"` — written by every yank / delete / change.
30    pub unnamed: Slot,
31    /// `"0` — last yank only.
32    pub yank_zero: Slot,
33    /// `"1`–`"9` — last 9 deletes (`"1` newest).
34    pub delete_ring: [Slot; 9],
35    /// `"a`–`"z` — named user registers.
36    pub named: [Slot; 26],
37    /// `"+` / `"*` — system clipboard register. Both selectors alias
38    /// the same slot (matches the typical Linux/macOS/Windows setup
39    /// where there's no separate primary selection in our pipeline).
40    /// The host (the host) syncs this slot from the OS clipboard
41    /// before paste and from the slot back out on yank.
42    pub clip: Slot,
43}
44
45impl Registers {
46    /// Record a yank operation. Writes to `"`, `"0`, and (if
47    /// `target` is set) the named slot. When `target` is `'_'`
48    /// (black-hole register) all writes are suppressed — vim discards
49    /// the text without touching any register.
50    pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
51        // Black-hole register: discard the text entirely.
52        if target == Some('_') {
53            return;
54        }
55        let slot = Slot::new(text, linewise);
56        self.unnamed = slot.clone();
57        self.yank_zero = slot.clone();
58        if let Some(c) = target {
59            self.write_named(c, slot);
60        }
61    }
62
63    /// Record a delete / change. Writes to `"`, rotates the
64    /// `"1`–`"9` ring, and (if `target` is set) the named slot.
65    /// Empty deletes are dropped — vim doesn't pollute the ring
66    /// with no-ops. When `target` is `'_'` (black-hole register) all
67    /// writes are suppressed, preserving the previous register state.
68    pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
69        if text.is_empty() {
70            return;
71        }
72        // Black-hole register: discard the text entirely.
73        if target == Some('_') {
74            return;
75        }
76        let slot = Slot::new(text, linewise);
77        self.unnamed = slot.clone();
78        for i in (1..9).rev() {
79            self.delete_ring[i] = self.delete_ring[i - 1].clone();
80        }
81        self.delete_ring[0] = slot.clone();
82        if let Some(c) = target {
83            self.write_named(c, slot);
84        }
85    }
86
87    /// Read a register by its single-char selector. Returns `None`
88    /// for unrecognised selectors.
89    pub fn read(&self, reg: char) -> Option<&Slot> {
90        match reg {
91            '"' => Some(&self.unnamed),
92            '0' => Some(&self.yank_zero),
93            '1'..='9' => Some(&self.delete_ring[(reg as u8 - b'1') as usize]),
94            'a'..='z' => Some(&self.named[(reg as u8 - b'a') as usize]),
95            'A'..='Z' => Some(&self.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
96            '+' | '*' => Some(&self.clip),
97            _ => None,
98        }
99    }
100
101    /// Replace the clipboard slot's contents — host hook for syncing
102    /// from the OS clipboard before a paste from `"+` / `"*`.
103    pub fn set_clipboard(&mut self, text: String, linewise: bool) {
104        self.clip = Slot::new(text, linewise);
105    }
106
107    fn write_named(&mut self, c: char, slot: Slot) {
108        if c.is_ascii_lowercase() {
109            self.named[(c as u8 - b'a') as usize] = slot;
110        } else if c.is_ascii_uppercase() {
111            let idx = (c.to_ascii_lowercase() as u8 - b'a') as usize;
112            let cur = &mut self.named[idx];
113            cur.text.push_str(&slot.text);
114            cur.linewise = slot.linewise || cur.linewise;
115        } else if c == '+' || c == '*' {
116            self.clip = slot;
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn yank_writes_unnamed_and_zero() {
127        let mut r = Registers::default();
128        r.record_yank("foo".into(), false, None);
129        assert_eq!(r.read('"').unwrap().text, "foo");
130        assert_eq!(r.read('0').unwrap().text, "foo");
131    }
132
133    #[test]
134    fn delete_rotates_ring_and_skips_zero() {
135        let mut r = Registers::default();
136        r.record_yank("kept".into(), false, None);
137        r.record_delete("d1".into(), false, None);
138        r.record_delete("d2".into(), false, None);
139        // Newest delete is "1.
140        assert_eq!(r.read('1').unwrap().text, "d2");
141        assert_eq!(r.read('2').unwrap().text, "d1");
142        // "0 untouched by deletes.
143        assert_eq!(r.read('0').unwrap().text, "kept");
144        // Unnamed mirrors the latest write.
145        assert_eq!(r.read('"').unwrap().text, "d2");
146    }
147
148    #[test]
149    fn named_lowercase_overwrites_uppercase_appends() {
150        let mut r = Registers::default();
151        r.record_yank("hello ".into(), false, Some('a'));
152        r.record_yank("world".into(), false, Some('A'));
153        assert_eq!(r.read('a').unwrap().text, "hello world");
154        // "A is just a write target; reading 'A' returns the same slot.
155        assert_eq!(r.read('A').unwrap().text, "hello world");
156    }
157
158    #[test]
159    fn empty_delete_is_dropped() {
160        let mut r = Registers::default();
161        r.record_delete("first".into(), false, None);
162        r.record_delete(String::new(), false, None);
163        assert_eq!(r.read('1').unwrap().text, "first");
164        assert!(r.read('2').unwrap().text.is_empty());
165    }
166
167    #[test]
168    fn unknown_selector_returns_none() {
169        let r = Registers::default();
170        assert!(r.read('?').is_none());
171        assert!(r.read('!').is_none());
172    }
173
174    #[test]
175    fn plus_and_star_alias_clipboard_slot() {
176        let mut r = Registers::default();
177        r.set_clipboard("payload".into(), false);
178        assert_eq!(r.read('+').unwrap().text, "payload");
179        assert_eq!(r.read('*').unwrap().text, "payload");
180    }
181
182    #[test]
183    fn yank_to_plus_writes_clipboard_slot() {
184        let mut r = Registers::default();
185        r.record_yank("hi".into(), false, Some('+'));
186        assert_eq!(r.read('+').unwrap().text, "hi");
187        // Unnamed always mirrors the latest write.
188        assert_eq!(r.read('"').unwrap().text, "hi");
189    }
190}