1#[derive(Default, Clone, Debug)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct Slot {
21 pub text: String,
22 pub linewise: bool,
23}
24
25impl Slot {
26 fn new(text: String, linewise: bool) -> Self {
27 Self { text, linewise }
28 }
29}
30
31#[derive(Default, Debug, Clone)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Registers {
34 pub unnamed: Slot,
36 pub yank_zero: Slot,
38 pub delete_ring: [Slot; 9],
40 pub small_delete: Slot,
43 pub named: [Slot; 26],
45 pub clip: Slot,
51 pub filename: Option<String>,
54 #[cfg_attr(feature = "serde", serde(skip))]
58 filename_slot: Option<Slot>,
59}
60
61impl Registers {
62 pub fn record_yank(&mut self, text: String, linewise: bool, target: Option<char>) {
67 if target == Some('_') {
69 return;
70 }
71 let slot = Slot::new(text, linewise);
72 self.unnamed = slot.clone();
73 if target.is_none() {
76 self.yank_zero = slot.clone();
77 }
78 if let Some(c) = target {
79 self.write_named(c, slot);
80 if let Some(named) = self.read(c) {
84 self.unnamed = named.clone();
85 }
86 }
87 }
88
89 pub fn record_delete(&mut self, text: String, linewise: bool, target: Option<char>) {
101 if text.is_empty() {
102 return;
103 }
104 if target == Some('_') {
106 return;
107 }
108 let slot = Slot::new(text, linewise);
109 self.unnamed = slot.clone();
110 if let Some(c) = target {
111 self.write_named(c, slot);
113 if let Some(named) = self.read(c) {
115 self.unnamed = named.clone();
116 }
117 return;
118 }
119 if slot.linewise || slot.text.contains('\n') {
120 for i in (1..9).rev() {
122 self.delete_ring[i] = self.delete_ring[i - 1].clone();
123 }
124 self.delete_ring[0] = slot;
125 } else {
126 self.small_delete = slot;
128 }
129 }
130
131 pub fn read(&self, reg: char) -> Option<&Slot> {
137 match reg {
138 '"' => Some(&self.unnamed),
139 '0' => Some(&self.yank_zero),
140 '1'..='9' => Some(&self.delete_ring[(reg as u8 - b'1') as usize]),
141 '-' => Some(&self.small_delete),
142 'a'..='z' => Some(&self.named[(reg as u8 - b'a') as usize]),
143 'A'..='Z' => Some(&self.named[(reg.to_ascii_lowercase() as u8 - b'a') as usize]),
144 '+' | '*' => Some(&self.clip),
145 '%' => self.filename_slot.as_ref(),
147 _ => None,
148 }
149 }
150
151 pub fn set_filename(&mut self, name: Option<String>) {
154 self.filename = name.clone();
155 self.filename_slot = name.map(|n| Slot::new(n, false));
156 }
157
158 pub fn set_clipboard(&mut self, text: String, linewise: bool) {
161 self.clip = Slot::new(text, linewise);
162 }
163
164 fn write_named(&mut self, c: char, slot: Slot) {
165 if c.is_ascii_lowercase() {
166 self.named[(c as u8 - b'a') as usize] = slot;
167 } else if c.is_ascii_uppercase() {
168 let idx = (c.to_ascii_lowercase() as u8 - b'a') as usize;
169 let cur = &mut self.named[idx];
170 cur.text.push_str(&slot.text);
171 cur.linewise = slot.linewise || cur.linewise;
172 } else if c == '+' || c == '*' {
173 self.clip = slot;
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn yank_writes_unnamed_and_zero() {
184 let mut r = Registers::default();
185 r.record_yank("foo".into(), false, None);
186 assert_eq!(r.read('"').unwrap().text, "foo");
187 assert_eq!(r.read('0').unwrap().text, "foo");
188 }
189
190 #[test]
191 fn delete_rotates_ring_and_skips_zero() {
192 let mut r = Registers::default();
193 r.record_yank("kept".into(), false, None);
194 r.record_delete("d1\n".into(), true, None);
196 r.record_delete("d2\n".into(), true, None);
197 assert_eq!(r.read('1').unwrap().text, "d2\n");
199 assert_eq!(r.read('2').unwrap().text, "d1\n");
200 assert_eq!(r.read('0').unwrap().text, "kept");
202 assert_eq!(r.read('"').unwrap().text, "d2\n");
204 }
205
206 #[test]
207 fn small_delete_goes_to_dash_not_ring() {
208 let mut r = Registers::default();
209 r.record_delete("line\n".into(), true, None);
211 r.record_delete("x".into(), false, None);
213 r.record_delete("y".into(), false, None);
214 assert_eq!(r.read('-').unwrap().text, "y");
215 assert_eq!(r.read('1').unwrap().text, "line\n");
217 assert!(r.read('2').unwrap().text.is_empty());
218 assert_eq!(r.read('"').unwrap().text, "y");
220 }
221
222 #[test]
223 fn multiline_charwise_delete_fills_ring_not_dash() {
224 let mut r = Registers::default();
225 r.record_delete("a\nb".into(), false, None);
227 assert_eq!(r.read('1').unwrap().text, "a\nb");
228 assert!(r.read('-').unwrap().text.is_empty());
229 }
230
231 #[test]
232 fn named_delete_leaves_ring_and_dash_untouched() {
233 let mut r = Registers::default();
234 r.record_delete("ring\n".into(), true, None);
235 r.record_delete("dash".into(), false, None);
236 r.record_delete("named\n".into(), true, Some('a'));
238 assert_eq!(r.read('a').unwrap().text, "named\n");
239 assert_eq!(r.read('1').unwrap().text, "ring\n");
240 assert_eq!(r.read('-').unwrap().text, "dash");
241 assert_eq!(r.read('"').unwrap().text, "named\n");
243 }
244
245 #[test]
246 fn yank_to_named_preserves_zero() {
247 let mut r = Registers::default();
248 r.record_yank("original".into(), false, None);
249 assert_eq!(r.read('0').unwrap().text, "original");
250 r.record_yank("into a".into(), false, Some('a'));
252 assert_eq!(r.read('0').unwrap().text, "original");
253 assert_eq!(r.read('a').unwrap().text, "into a");
254 }
255
256 #[test]
257 fn named_lowercase_overwrites_uppercase_appends() {
258 let mut r = Registers::default();
259 r.record_yank("hello ".into(), false, Some('a'));
260 r.record_yank("world".into(), false, Some('A'));
261 assert_eq!(r.read('a').unwrap().text, "hello world");
262 assert_eq!(r.read('A').unwrap().text, "hello world");
264 }
265
266 #[test]
267 fn empty_delete_is_dropped() {
268 let mut r = Registers::default();
269 r.record_delete("first\n".into(), true, None);
270 r.record_delete(String::new(), true, None);
271 assert_eq!(r.read('1').unwrap().text, "first\n");
272 assert!(r.read('2').unwrap().text.is_empty());
273 }
274
275 #[test]
276 fn unknown_selector_returns_none() {
277 let r = Registers::default();
278 assert!(r.read('?').is_none());
279 assert!(r.read('!').is_none());
280 }
281
282 #[test]
283 fn plus_and_star_alias_clipboard_slot() {
284 let mut r = Registers::default();
285 r.set_clipboard("payload".into(), false);
286 assert_eq!(r.read('+').unwrap().text, "payload");
287 assert_eq!(r.read('*').unwrap().text, "payload");
288 }
289
290 #[test]
291 fn yank_to_plus_writes_clipboard_slot() {
292 let mut r = Registers::default();
293 r.record_yank("hi".into(), false, Some('+'));
294 assert_eq!(r.read('+').unwrap().text, "hi");
295 assert_eq!(r.read('"').unwrap().text, "hi");
297 }
298
299 #[test]
300 fn percent_register_returns_none_when_no_filename() {
301 let r = Registers::default();
302 assert!(r.read('%').is_none());
303 }
304
305 #[test]
306 fn percent_register_returns_filename_after_set() {
307 let mut r = Registers::default();
308 r.set_filename(Some("src/main.rs".into()));
309 let slot = r
310 .read('%')
311 .expect("'%' should return Some after set_filename");
312 assert_eq!(slot.text, "src/main.rs");
313 assert!(!slot.linewise, "'%' slot should be charwise");
314 }
315
316 #[test]
317 fn percent_register_clears_when_set_to_none() {
318 let mut r = Registers::default();
319 r.set_filename(Some("foo.txt".into()));
320 r.set_filename(None);
321 assert!(r.read('%').is_none());
322 }
323}