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