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    /// `"%` — synthetic read-only register: current buffer filename.
44    /// Set by the host whenever the active slot changes.
45    pub filename: Option<String>,
46    /// Pre-built `Slot` for the `%` register. Kept in sync with `filename`
47    /// by [`Registers::set_filename`] so `read('%')` can return `&Slot`.
48    /// Derived from `filename` — not serialised independently.
49    #[cfg_attr(feature = "serde", serde(skip))]
50    filename_slot: Option<Slot>,
51}
52
53impl Registers {
54    /// Record a yank operation. Writes to `"`, `"0`, and (if
55    /// `target` is set) the named slot. When `target` is `'_'`
56    /// (black-hole register) all writes are suppressed — vim discards
57    /// the text without touching any register.
58    pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
59        // Black-hole register: discard the text entirely.
60        if target == Some('_') {
61            return;
62        }
63        let slot = Slot::new(text, linewise);
64        self.unnamed = slot.clone();
65        self.yank_zero = slot.clone();
66        if let Some(c) = target {
67            self.write_named(c, slot);
68        }
69    }
70
71    /// Record a delete / change. Writes to `"`, rotates the
72    /// `"1`–`"9` ring, and (if `target` is set) the named slot.
73    /// Empty deletes are dropped — vim doesn't pollute the ring
74    /// with no-ops. When `target` is `'_'` (black-hole register) all
75    /// writes are suppressed, preserving the previous register state.
76    pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
77        if text.is_empty() {
78            return;
79        }
80        // Black-hole register: discard the text entirely.
81        if target == Some('_') {
82            return;
83        }
84        let slot = Slot::new(text, linewise);
85        self.unnamed = slot.clone();
86        for i in (1..9).rev() {
87            self.delete_ring[i] = self.delete_ring[i - 1].clone();
88        }
89        self.delete_ring[0] = slot.clone();
90        if let Some(c) = target {
91            self.write_named(c, slot);
92        }
93    }
94
95    /// Read a register by its single-char selector. Returns `None`
96    /// for unrecognised selectors.
97    ///
98    /// `'%'` is a synthetic read-only register: returns the current buffer
99    /// filename when one has been set via [`Registers::set_filename`].
100    pub fn read(&self, reg: char) -> Option<&Slot> {
101        match reg {
102            '"' => Some(&self.unnamed),
103            '0' => Some(&self.yank_zero),
104            '1'..='9' => Some(&self.delete_ring[(reg as u8 - b'1') as usize]),
105            'a'..='z' => Some(&self.named[(reg as u8 - b'a') as usize]),
106            'A'..='Z' => Some(&self.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
107            '+' | '*' => Some(&self.clip),
108            // `%` is a synthetic read-only register: current buffer filename.
109            '%' => self.filename_slot.as_ref(),
110            _ => None,
111        }
112    }
113
114    /// Host hook: set the `"%` register to the given filename. Call this
115    /// whenever the active buffer changes.
116    pub fn set_filename(&mut self, name: Option<String>) {
117        self.filename = name.clone();
118        self.filename_slot = name.map(|n| Slot::new(n, false));
119    }
120
121    /// Replace the clipboard slot's contents — host hook for syncing
122    /// from the OS clipboard before a paste from `"+` / `"*`.
123    pub fn set_clipboard(&mut self, text: String, linewise: bool) {
124        self.clip = Slot::new(text, linewise);
125    }
126
127    fn write_named(&mut self, c: char, slot: Slot) {
128        if c.is_ascii_lowercase() {
129            self.named[(c as u8 - b'a') as usize] = slot;
130        } else if c.is_ascii_uppercase() {
131            let idx = (c.to_ascii_lowercase() as u8 - b'a') as usize;
132            let cur = &mut self.named[idx];
133            cur.text.push_str(&slot.text);
134            cur.linewise = slot.linewise || cur.linewise;
135        } else if c == '+' || c == '*' {
136            self.clip = slot;
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn yank_writes_unnamed_and_zero() {
147        let mut r = Registers::default();
148        r.record_yank("foo".into(), false, None);
149        assert_eq!(r.read('"').unwrap().text, "foo");
150        assert_eq!(r.read('0').unwrap().text, "foo");
151    }
152
153    #[test]
154    fn delete_rotates_ring_and_skips_zero() {
155        let mut r = Registers::default();
156        r.record_yank("kept".into(), false, None);
157        r.record_delete("d1".into(), false, None);
158        r.record_delete("d2".into(), false, None);
159        // Newest delete is "1.
160        assert_eq!(r.read('1').unwrap().text, "d2");
161        assert_eq!(r.read('2').unwrap().text, "d1");
162        // "0 untouched by deletes.
163        assert_eq!(r.read('0').unwrap().text, "kept");
164        // Unnamed mirrors the latest write.
165        assert_eq!(r.read('"').unwrap().text, "d2");
166    }
167
168    #[test]
169    fn named_lowercase_overwrites_uppercase_appends() {
170        let mut r = Registers::default();
171        r.record_yank("hello ".into(), false, Some('a'));
172        r.record_yank("world".into(), false, Some('A'));
173        assert_eq!(r.read('a').unwrap().text, "hello world");
174        // "A is just a write target; reading 'A' returns the same slot.
175        assert_eq!(r.read('A').unwrap().text, "hello world");
176    }
177
178    #[test]
179    fn empty_delete_is_dropped() {
180        let mut r = Registers::default();
181        r.record_delete("first".into(), false, None);
182        r.record_delete(String::new(), false, None);
183        assert_eq!(r.read('1').unwrap().text, "first");
184        assert!(r.read('2').unwrap().text.is_empty());
185    }
186
187    #[test]
188    fn unknown_selector_returns_none() {
189        let r = Registers::default();
190        assert!(r.read('?').is_none());
191        assert!(r.read('!').is_none());
192    }
193
194    #[test]
195    fn plus_and_star_alias_clipboard_slot() {
196        let mut r = Registers::default();
197        r.set_clipboard("payload".into(), false);
198        assert_eq!(r.read('+').unwrap().text, "payload");
199        assert_eq!(r.read('*').unwrap().text, "payload");
200    }
201
202    #[test]
203    fn yank_to_plus_writes_clipboard_slot() {
204        let mut r = Registers::default();
205        r.record_yank("hi".into(), false, Some('+'));
206        assert_eq!(r.read('+').unwrap().text, "hi");
207        // Unnamed always mirrors the latest write.
208        assert_eq!(r.read('"').unwrap().text, "hi");
209    }
210
211    #[test]
212    fn percent_register_returns_none_when_no_filename() {
213        let r = Registers::default();
214        assert!(r.read('%').is_none());
215    }
216
217    #[test]
218    fn percent_register_returns_filename_after_set() {
219        let mut r = Registers::default();
220        r.set_filename(Some("src/main.rs".into()));
221        let slot = r
222            .read('%')
223            .expect("'%' should return Some after set_filename");
224        assert_eq!(slot.text, "src/main.rs");
225        assert!(!slot.linewise, "'%' slot should be charwise");
226    }
227
228    #[test]
229    fn percent_register_clears_when_set_to_none() {
230        let mut r = Registers::default();
231        r.set_filename(Some("foo.txt".into()));
232        r.set_filename(None);
233        assert!(r.read('%').is_none());
234    }
235}