1use std::ops::Range;
2
3use editor::{scroll::Autoscroll, Editor, MultiBufferSnapshot, ToOffset, ToPoint};
4use gpui::{impl_actions, ViewContext};
5use language::{Bias, Point};
6use serde::Deserialize;
7
8use crate::{state::Mode, Vim};
9
10#[derive(Clone, Deserialize, PartialEq)]
11#[serde(rename_all = "camelCase")]
12struct Increment {
13 #[serde(default)]
14 step: bool,
15}
16
17#[derive(Clone, Deserialize, PartialEq)]
18#[serde(rename_all = "camelCase")]
19struct Decrement {
20 #[serde(default)]
21 step: bool,
22}
23
24impl_actions!(vim, [Increment, Decrement]);
25
26pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
27 Vim::action(editor, cx, |vim, action: &Increment, cx| {
28 vim.record_current_action(cx);
29 let count = vim.take_count(cx).unwrap_or(1);
30 let step = if action.step { 1 } else { 0 };
31 vim.increment(count as i64, step, cx)
32 });
33 Vim::action(editor, cx, |vim, action: &Decrement, cx| {
34 vim.record_current_action(cx);
35 let count = vim.take_count(cx).unwrap_or(1);
36 let step = if action.step { -1 } else { 0 };
37 vim.increment(-(count as i64), step, cx)
38 });
39}
40
41impl Vim {
42 fn increment(&mut self, mut delta: i64, step: i32, cx: &mut ViewContext<Self>) {
43 self.store_visual_marks(cx);
44 self.update_editor(cx, |vim, editor, cx| {
45 let mut edits = Vec::new();
46 let mut new_anchors = Vec::new();
47
48 let snapshot = editor.buffer().read(cx).snapshot(cx);
49 for selection in editor.selections.all_adjusted(cx) {
50 if !selection.is_empty()
51 && (vim.mode != Mode::VisualBlock || new_anchors.is_empty())
52 {
53 new_anchors.push((true, snapshot.anchor_before(selection.start)))
54 }
55 for row in selection.start.row..=selection.end.row {
56 let start = if row == selection.start.row {
57 selection.start
58 } else {
59 Point::new(row, 0)
60 };
61
62 if let Some((range, num, radix)) = find_number(&snapshot, start) {
63 let replace = match radix {
64 10 => increment_decimal_string(&num, delta),
65 16 => increment_hex_string(&num, delta),
66 2 => increment_binary_string(&num, delta),
67 _ => unreachable!(),
68 };
69 delta += step as i64;
70 edits.push((range.clone(), replace));
71 if selection.is_empty() {
72 new_anchors.push((false, snapshot.anchor_after(range.end)))
73 }
74 } else if selection.is_empty() {
75 new_anchors.push((true, snapshot.anchor_after(start)))
76 }
77 }
78 }
79 editor.transact(cx, |editor, cx| {
80 editor.edit(edits, cx);
81
82 let snapshot = editor.buffer().read(cx).snapshot(cx);
83 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
84 let mut new_ranges = Vec::new();
85 for (visual, anchor) in new_anchors.iter() {
86 let mut point = anchor.to_point(&snapshot);
87 if !*visual && point.column > 0 {
88 point.column -= 1;
89 point = snapshot.clip_point(point, Bias::Left)
90 }
91 new_ranges.push(point..point);
92 }
93 s.select_ranges(new_ranges)
94 })
95 });
96 });
97 self.switch_mode(Mode::Normal, true, cx)
98 }
99}
100
101fn increment_decimal_string(mut num: &str, mut delta: i64) -> String {
102 let mut negative = false;
103 if num.chars().next() == Some('-') {
104 negative = true;
105 delta = 0 - delta;
106 num = &num[1..];
107 }
108 let result = if let Ok(value) = u64::from_str_radix(num, 10) {
109 let wrapped = value.wrapping_add_signed(delta);
110 if delta < 0 && wrapped > value {
111 negative = !negative;
112 (u64::MAX - wrapped).wrapping_add(1)
113 } else if delta > 0 && wrapped < value {
114 negative = !negative;
115 u64::MAX - wrapped
116 } else {
117 wrapped
118 }
119 } else {
120 u64::MAX
121 };
122
123 if result == 0 || !negative {
124 format!("{}", result)
125 } else {
126 format!("-{}", result)
127 }
128}
129
130fn increment_hex_string(num: &str, delta: i64) -> String {
131 let result = if let Ok(val) = u64::from_str_radix(&num, 16) {
132 val.wrapping_add_signed(delta)
133 } else {
134 u64::MAX
135 };
136 if should_use_lowercase(num) {
137 format!("{:0width$x}", result, width = num.len())
138 } else {
139 format!("{:0width$X}", result, width = num.len())
140 }
141}
142
143fn should_use_lowercase(num: &str) -> bool {
144 let mut use_uppercase = false;
145 for ch in num.chars() {
146 if ch.is_ascii_lowercase() {
147 return true;
148 }
149 if ch.is_ascii_uppercase() {
150 use_uppercase = true;
151 }
152 }
153 !use_uppercase
154}
155
156fn increment_binary_string(num: &str, delta: i64) -> String {
157 let result = if let Ok(val) = u64::from_str_radix(&num, 2) {
158 val.wrapping_add_signed(delta)
159 } else {
160 u64::MAX
161 };
162 format!("{:0width$b}", result, width = num.len())
163}
164
165fn find_number(
166 snapshot: &MultiBufferSnapshot,
167 start: Point,
168) -> Option<(Range<Point>, String, u32)> {
169 let mut offset = start.to_offset(snapshot);
170
171 let ch0 = snapshot.chars_at(offset).next();
172 if ch0.as_ref().is_some_and(char::is_ascii_hexdigit) || matches!(ch0, Some('-' | 'b' | 'x')) {
173 // go backwards to the start of any number the selection is within
174 for ch in snapshot.reversed_chars_at(offset) {
175 if ch.is_ascii_hexdigit() || ch == '-' || ch == 'b' || ch == 'x' {
176 offset -= ch.len_utf8();
177 continue;
178 }
179 break;
180 }
181 }
182
183 let mut begin = None;
184 let mut end = None;
185 let mut num = String::new();
186 let mut radix = 10;
187
188 let mut chars = snapshot.chars_at(offset).peekable();
189 // find the next number on the line (may start after the original cursor position)
190 while let Some(ch) = chars.next() {
191 if num == "0" && ch == 'b' && chars.peek().is_some() && chars.peek().unwrap().is_digit(2) {
192 radix = 2;
193 begin = None;
194 num = String::new();
195 }
196 if num == "0"
197 && ch == 'x'
198 && chars.peek().is_some()
199 && chars.peek().unwrap().is_ascii_hexdigit()
200 {
201 radix = 16;
202 begin = None;
203 num = String::new();
204 }
205
206 if ch.is_digit(radix)
207 || (begin.is_none()
208 && ch == '-'
209 && chars.peek().is_some()
210 && chars.peek().unwrap().is_digit(radix))
211 {
212 if begin.is_none() {
213 begin = Some(offset);
214 }
215 num.push(ch);
216 println!("pushing {}", ch);
217 println!();
218 } else if begin.is_some() {
219 end = Some(offset);
220 break;
221 } else if ch == '\n' {
222 break;
223 }
224 offset += ch.len_utf8();
225 }
226 if let Some(begin) = begin {
227 let end = end.unwrap_or(offset);
228 Some((begin.to_point(snapshot)..end.to_point(snapshot), num, radix))
229 } else {
230 None
231 }
232}
233
234#[cfg(test)]
235mod test {
236 use indoc::indoc;
237
238 use crate::test::NeovimBackedTestContext;
239
240 #[gpui::test]
241 async fn test_increment(cx: &mut gpui::TestAppContext) {
242 let mut cx = NeovimBackedTestContext::new(cx).await;
243
244 cx.set_shared_state(indoc! {"
245 1ˇ2
246 "})
247 .await;
248
249 cx.simulate_shared_keystrokes("ctrl-a").await;
250 cx.shared_state().await.assert_eq(indoc! {"
251 1ˇ3
252 "});
253 cx.simulate_shared_keystrokes("ctrl-x").await;
254 cx.shared_state().await.assert_eq(indoc! {"
255 1ˇ2
256 "});
257
258 cx.simulate_shared_keystrokes("9 9 ctrl-a").await;
259 cx.shared_state().await.assert_eq(indoc! {"
260 11ˇ1
261 "});
262 cx.simulate_shared_keystrokes("1 1 1 ctrl-x").await;
263 cx.shared_state().await.assert_eq(indoc! {"
264 ˇ0
265 "});
266 cx.simulate_shared_keystrokes(".").await;
267 cx.shared_state().await.assert_eq(indoc! {"
268 -11ˇ1
269 "});
270 }
271
272 #[gpui::test]
273 async fn test_increment_with_dot(cx: &mut gpui::TestAppContext) {
274 let mut cx = NeovimBackedTestContext::new(cx).await;
275
276 cx.set_shared_state(indoc! {"
277 1ˇ.2
278 "})
279 .await;
280
281 cx.simulate_shared_keystrokes("ctrl-a").await;
282 cx.shared_state().await.assert_eq(indoc! {"
283 1.ˇ3
284 "});
285 cx.simulate_shared_keystrokes("ctrl-x").await;
286 cx.shared_state().await.assert_eq(indoc! {"
287 1.ˇ2
288 "});
289 }
290
291 #[gpui::test]
292 async fn test_increment_with_two_dots(cx: &mut gpui::TestAppContext) {
293 let mut cx = NeovimBackedTestContext::new(cx).await;
294
295 cx.set_shared_state(indoc! {"
296 111.ˇ.2
297 "})
298 .await;
299
300 cx.simulate_shared_keystrokes("ctrl-a").await;
301 cx.shared_state().await.assert_eq(indoc! {"
302 111..ˇ3
303 "});
304 cx.simulate_shared_keystrokes("ctrl-x").await;
305 cx.shared_state().await.assert_eq(indoc! {"
306 111..ˇ2
307 "});
308 }
309
310 #[gpui::test]
311 async fn test_increment_sign_change(cx: &mut gpui::TestAppContext) {
312 let mut cx = NeovimBackedTestContext::new(cx).await;
313 cx.set_shared_state(indoc! {"
314 ˇ0
315 "})
316 .await;
317 cx.simulate_shared_keystrokes("ctrl-x").await;
318 cx.shared_state().await.assert_eq(indoc! {"
319 -ˇ1
320 "});
321 cx.simulate_shared_keystrokes("2 ctrl-a").await;
322 cx.shared_state().await.assert_eq(indoc! {"
323 ˇ1
324 "});
325 }
326
327 #[gpui::test]
328 async fn test_increment_bin_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
329 let mut cx = NeovimBackedTestContext::new(cx).await;
330 cx.set_shared_state(indoc! {"
331 0b111111111111111111111111111111111111111111111111111111111111111111111ˇ1
332 "})
333 .await;
334
335 cx.simulate_shared_keystrokes("ctrl-a").await;
336 cx.shared_state().await.assert_eq(indoc! {"
337 0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
338 "});
339 cx.simulate_shared_keystrokes("ctrl-a").await;
340 cx.shared_state().await.assert_eq(indoc! {"
341 0b000000000000000000000000000000000000000000000000000000000000000000000ˇ0
342 "});
343
344 cx.simulate_shared_keystrokes("ctrl-a").await;
345 cx.shared_state().await.assert_eq(indoc! {"
346 0b000000000000000000000000000000000000000000000000000000000000000000000ˇ1
347 "});
348 cx.simulate_shared_keystrokes("2 ctrl-x").await;
349 cx.shared_state().await.assert_eq(indoc! {"
350 0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
351 "});
352 }
353
354 #[gpui::test]
355 async fn test_increment_hex_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
356 let mut cx = NeovimBackedTestContext::new(cx).await;
357 cx.set_shared_state(indoc! {"
358 0xfffffffffffffffffffˇf
359 "})
360 .await;
361
362 cx.simulate_shared_keystrokes("ctrl-a").await;
363 cx.shared_state().await.assert_eq(indoc! {"
364 0x0000fffffffffffffffˇf
365 "});
366 cx.simulate_shared_keystrokes("ctrl-a").await;
367 cx.shared_state().await.assert_eq(indoc! {"
368 0x0000000000000000000ˇ0
369 "});
370 cx.simulate_shared_keystrokes("ctrl-a").await;
371 cx.shared_state().await.assert_eq(indoc! {"
372 0x0000000000000000000ˇ1
373 "});
374 cx.simulate_shared_keystrokes("2 ctrl-x").await;
375 cx.shared_state().await.assert_eq(indoc! {"
376 0x0000fffffffffffffffˇf
377 "});
378 }
379
380 #[gpui::test]
381 async fn test_increment_wrapping(cx: &mut gpui::TestAppContext) {
382 let mut cx = NeovimBackedTestContext::new(cx).await;
383 cx.set_shared_state(indoc! {"
384 1844674407370955161ˇ9
385 "})
386 .await;
387
388 cx.simulate_shared_keystrokes("ctrl-a").await;
389 cx.shared_state().await.assert_eq(indoc! {"
390 1844674407370955161ˇ5
391 "});
392 cx.simulate_shared_keystrokes("ctrl-a").await;
393 cx.shared_state().await.assert_eq(indoc! {"
394 -1844674407370955161ˇ5
395 "});
396 cx.simulate_shared_keystrokes("ctrl-a").await;
397 cx.shared_state().await.assert_eq(indoc! {"
398 -1844674407370955161ˇ4
399 "});
400 cx.simulate_shared_keystrokes("3 ctrl-x").await;
401 cx.shared_state().await.assert_eq(indoc! {"
402 1844674407370955161ˇ4
403 "});
404 cx.simulate_shared_keystrokes("2 ctrl-a").await;
405 cx.shared_state().await.assert_eq(indoc! {"
406 -1844674407370955161ˇ5
407 "});
408 }
409
410 #[gpui::test]
411 async fn test_increment_inline(cx: &mut gpui::TestAppContext) {
412 let mut cx = NeovimBackedTestContext::new(cx).await;
413 cx.set_shared_state(indoc! {"
414 inline0x3ˇ9u32
415 "})
416 .await;
417
418 cx.simulate_shared_keystrokes("ctrl-a").await;
419 cx.shared_state().await.assert_eq(indoc! {"
420 inline0x3ˇau32
421 "});
422 cx.simulate_shared_keystrokes("ctrl-a").await;
423 cx.shared_state().await.assert_eq(indoc! {"
424 inline0x3ˇbu32
425 "});
426 cx.simulate_shared_keystrokes("l l l ctrl-a").await;
427 cx.shared_state().await.assert_eq(indoc! {"
428 inline0x3bu3ˇ3
429 "});
430 }
431
432 #[gpui::test]
433 async fn test_increment_hex_casing(cx: &mut gpui::TestAppContext) {
434 let mut cx = NeovimBackedTestContext::new(cx).await;
435 cx.set_shared_state(indoc! {"
436 0xFˇa
437 "})
438 .await;
439
440 cx.simulate_shared_keystrokes("ctrl-a").await;
441 cx.shared_state().await.assert_eq(indoc! {"
442 0xfˇb
443 "});
444 cx.simulate_shared_keystrokes("ctrl-a").await;
445 cx.shared_state().await.assert_eq(indoc! {"
446 0xfˇc
447 "});
448 }
449
450 #[gpui::test]
451 async fn test_increment_radix(cx: &mut gpui::TestAppContext) {
452 let mut cx = NeovimBackedTestContext::new(cx).await;
453
454 cx.simulate("ctrl-a", "ˇ total: 0xff")
455 .await
456 .assert_matches();
457 cx.simulate("ctrl-x", "ˇ total: 0xff")
458 .await
459 .assert_matches();
460 cx.simulate("ctrl-x", "ˇ total: 0xFF")
461 .await
462 .assert_matches();
463 cx.simulate("ctrl-a", "(ˇ0b10f)").await.assert_matches();
464 cx.simulate("ctrl-a", "ˇ-1").await.assert_matches();
465 cx.simulate("ctrl-a", "banˇana").await.assert_matches();
466 }
467
468 #[gpui::test]
469 async fn test_increment_steps(cx: &mut gpui::TestAppContext) {
470 let mut cx = NeovimBackedTestContext::new(cx).await;
471
472 cx.set_shared_state(indoc! {"
473 ˇ1
474 1
475 1 2
476 1
477 1"})
478 .await;
479
480 cx.simulate_shared_keystrokes("j v shift-g g ctrl-a").await;
481 cx.shared_state().await.assert_eq(indoc! {"
482 1
483 ˇ2
484 3 2
485 4
486 5"});
487
488 cx.simulate_shared_keystrokes("shift-g ctrl-v g g").await;
489 cx.shared_state().await.assert_eq(indoc! {"
490 «1ˇ»
491 «2ˇ»
492 «3ˇ» 2
493 «4ˇ»
494 «5ˇ»"});
495
496 cx.simulate_shared_keystrokes("g ctrl-x").await;
497 cx.shared_state().await.assert_eq(indoc! {"
498 ˇ0
499 0
500 0 2
501 0
502 0"});
503 }
504}