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