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