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