1use editor::{Editor, MultiBufferSnapshot, ToOffset, ToPoint};
2use gpui::{Action, Context, Window};
3use language::{Bias, Point};
4use schemars::JsonSchema;
5use serde::Deserialize;
6use std::ops::Range;
7
8use crate::{Vim, state::Mode};
9
10const BOOLEAN_PAIRS: &[(&str, &str)] = &[("true", "false"), ("yes", "no"), ("on", "off")];
11
12/// Increments the number under the cursor or toggles boolean values.
13#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
14#[action(namespace = vim)]
15#[serde(deny_unknown_fields)]
16struct Increment {
17 #[serde(default)]
18 step: bool,
19}
20
21/// Decrements the number under the cursor or toggles boolean values.
22#[derive(Clone, Deserialize, JsonSchema, PartialEq, Action)]
23#[action(namespace = vim)]
24#[serde(deny_unknown_fields)]
25struct Decrement {
26 #[serde(default)]
27 step: bool,
28}
29
30pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
31 Vim::action(editor, cx, |vim, action: &Increment, window, cx| {
32 vim.record_current_action(cx);
33 let count = Vim::take_count(cx).unwrap_or(1);
34 Vim::take_forced_motion(cx);
35 let step = if action.step { count as i32 } else { 0 };
36 vim.increment(count as i64, step, window, cx)
37 });
38 Vim::action(editor, cx, |vim, action: &Decrement, window, cx| {
39 vim.record_current_action(cx);
40 let count = Vim::take_count(cx).unwrap_or(1);
41 Vim::take_forced_motion(cx);
42 let step = if action.step { -1 * (count as i32) } else { 0 };
43 vim.increment(-(count as i64), step, window, cx)
44 });
45}
46
47impl Vim {
48 fn increment(
49 &mut self,
50 mut delta: i64,
51 step: i32,
52 window: &mut Window,
53 cx: &mut Context<Self>,
54 ) {
55 self.store_visual_marks(window, cx);
56 self.update_editor(cx, |vim, editor, cx| {
57 let mut edits = Vec::new();
58 let mut new_anchors = Vec::new();
59
60 let snapshot = editor.buffer().read(cx).snapshot(cx);
61 for selection in editor.selections.all_adjusted(&editor.display_snapshot(cx)) {
62 if !selection.is_empty()
63 && (vim.mode != Mode::VisualBlock || new_anchors.is_empty())
64 {
65 new_anchors.push((true, snapshot.anchor_before(selection.start)))
66 }
67 for row in selection.start.row..=selection.end.row {
68 let start = if row == selection.start.row {
69 selection.start
70 } else {
71 Point::new(row, 0)
72 };
73 let end = if row == selection.end.row {
74 selection.end
75 } else {
76 Point::new(row, snapshot.line_len(multi_buffer::MultiBufferRow(row)))
77 };
78
79 let find_result = if !selection.is_empty() {
80 find_target(&snapshot, start, end, true)
81 } else {
82 find_target(&snapshot, start, end, false)
83 };
84
85 if let Some((range, target, radix)) = find_result {
86 let replace = match radix {
87 10 => increment_decimal_string(&target, delta),
88 16 => increment_hex_string(&target, delta),
89 2 => increment_binary_string(&target, delta),
90 0 => increment_toggle_string(&target),
91 _ => unreachable!(),
92 };
93 delta += step as i64;
94 edits.push((range.clone(), replace));
95 if selection.is_empty() {
96 new_anchors.push((false, snapshot.anchor_after(range.end)))
97 }
98 } else if selection.is_empty() {
99 new_anchors.push((true, snapshot.anchor_after(start)))
100 }
101 }
102 }
103 editor.transact(window, cx, |editor, window, cx| {
104 editor.edit(edits, cx);
105
106 let snapshot = editor.buffer().read(cx).snapshot(cx);
107 editor.change_selections(Default::default(), window, cx, |s| {
108 let mut new_ranges = Vec::new();
109 for (visual, anchor) in new_anchors.iter() {
110 let mut point = anchor.to_point(&snapshot);
111 if !*visual && point.column > 0 {
112 point.column -= 1;
113 point = snapshot.clip_point(point, Bias::Left)
114 }
115 new_ranges.push(point..point);
116 }
117 s.select_ranges(new_ranges)
118 })
119 });
120 });
121 self.switch_mode(Mode::Normal, true, window, cx)
122 }
123}
124
125fn increment_decimal_string(num: &str, delta: i64) -> String {
126 let (negative, delta, num_str) = match num.strip_prefix('-') {
127 Some(n) => (true, -delta, n),
128 None => (false, delta, num),
129 };
130 let num_length = num_str.len();
131 let leading_zero = num_str.starts_with('0');
132
133 let (result, new_negative) = match u64::from_str_radix(num_str, 10) {
134 Ok(value) => {
135 let wrapped = value.wrapping_add_signed(delta);
136 if delta < 0 && wrapped > value {
137 ((u64::MAX - wrapped).wrapping_add(1), !negative)
138 } else if delta > 0 && wrapped < value {
139 (u64::MAX - wrapped, !negative)
140 } else {
141 (wrapped, negative)
142 }
143 }
144 Err(_) => (u64::MAX, negative),
145 };
146
147 let formatted = format!("{}", result);
148 let new_significant_digits = formatted.len();
149 let padding = if leading_zero {
150 num_length.saturating_sub(new_significant_digits)
151 } else {
152 0
153 };
154
155 if new_negative && result != 0 {
156 format!("-{}{}", "0".repeat(padding), formatted)
157 } else {
158 format!("{}{}", "0".repeat(padding), formatted)
159 }
160}
161
162fn increment_hex_string(num: &str, delta: i64) -> String {
163 let result = if let Ok(val) = u64::from_str_radix(num, 16) {
164 val.wrapping_add_signed(delta)
165 } else {
166 u64::MAX
167 };
168 if should_use_lowercase(num) {
169 format!("{:0width$x}", result, width = num.len())
170 } else {
171 format!("{:0width$X}", result, width = num.len())
172 }
173}
174
175fn should_use_lowercase(num: &str) -> bool {
176 let mut use_uppercase = false;
177 for ch in num.chars() {
178 if ch.is_ascii_lowercase() {
179 return true;
180 }
181 if ch.is_ascii_uppercase() {
182 use_uppercase = true;
183 }
184 }
185 !use_uppercase
186}
187
188fn increment_binary_string(num: &str, delta: i64) -> String {
189 let result = if let Ok(val) = u64::from_str_radix(num, 2) {
190 val.wrapping_add_signed(delta)
191 } else {
192 u64::MAX
193 };
194 format!("{:0width$b}", result, width = num.len())
195}
196
197fn find_target(
198 snapshot: &MultiBufferSnapshot,
199 start: Point,
200 end: Point,
201 need_range: bool,
202) -> Option<(Range<Point>, String, u32)> {
203 let start_offset = start.to_offset(snapshot);
204 let end_offset = end.to_offset(snapshot);
205
206 let mut first_char_is_num = snapshot
207 .chars_at(start_offset)
208 .next()
209 .map_or(false, |ch| ch.is_ascii_hexdigit());
210 let mut pre_char = String::new();
211
212 let next_offset = start_offset
213 + snapshot
214 .chars_at(start_offset)
215 .next()
216 .map_or(0, |ch| ch.len_utf8());
217 // Backward scan to find the start of the number, but stop at start_offset.
218 // We track `offset` as the start position of the current character. Initialize
219 // to `next_offset` and decrement at the start of each iteration so that `offset`
220 // always lands on a valid character boundary (not in the middle of a multibyte char).
221 let mut offset = next_offset;
222 for ch in snapshot.reversed_chars_at(next_offset) {
223 offset -= ch.len_utf8();
224
225 // Search boundaries
226 if offset.0 == 0 || ch.is_whitespace() || (need_range && offset <= start_offset) {
227 break;
228 }
229
230 // Avoid the influence of hexadecimal letters
231 if first_char_is_num
232 && !ch.is_ascii_hexdigit()
233 && (ch != 'b' && ch != 'B')
234 && (ch != 'x' && ch != 'X')
235 && ch != '-'
236 {
237 // Used to determine if the initial character is a number.
238 if is_numeric_string(&pre_char) {
239 break;
240 } else {
241 first_char_is_num = false;
242 }
243 }
244
245 pre_char.insert(0, ch);
246 }
247
248 // The backward scan breaks on whitespace, including newlines. Without this
249 // skip, the forward scan would start on the newline and immediately break
250 // (since it also breaks on newlines), finding nothing on the current line.
251 if let Some(ch) = snapshot.chars_at(offset).next() {
252 if ch == '\n' {
253 offset += ch.len_utf8();
254 }
255 }
256
257 let mut begin = None;
258 let mut end = None;
259 let mut target = String::new();
260 let mut radix = 10;
261 let mut is_num = false;
262
263 let mut chars = snapshot.chars_at(offset).peekable();
264
265 while let Some(ch) = chars.next() {
266 if need_range && offset >= end_offset {
267 break; // stop at end of selection
268 }
269
270 if target == "0"
271 && (ch == 'b' || ch == 'B')
272 && chars.peek().is_some()
273 && chars.peek().unwrap().is_digit(2)
274 {
275 radix = 2;
276 begin = None;
277 target = String::new();
278 } else if target == "0"
279 && (ch == 'x' || ch == 'X')
280 && chars.peek().is_some()
281 && chars.peek().unwrap().is_ascii_hexdigit()
282 {
283 radix = 16;
284 begin = None;
285 target = String::new();
286 } else if ch == '.' {
287 // When the cursor is on a number followed by a dot and a non-digit
288 // (`ˇ1. item`), terminate the match so the number is incrementable.
289 // Without this, the dot unconditionally resets the scan and the
290 // number is skipped. We only do this when the cursor is on the
291 // number, when it's past (`111.ˇ.2`), we still reset so the forward
292 // scan can find the number after the dots.
293 let next_is_non_digit = chars.peek().map_or(true, |char| !char.is_digit(radix));
294 let on_number =
295 is_num && begin.is_some_and(|begin| begin >= start_offset || start_offset < offset);
296
297 if on_number && next_is_non_digit {
298 end = Some(offset);
299 break;
300 }
301
302 is_num = false;
303 begin = None;
304 target = String::new();
305 } else if ch.is_digit(radix)
306 || ((begin.is_none() || !is_num)
307 && ch == '-'
308 && chars.peek().is_some()
309 && chars.peek().unwrap().is_digit(radix))
310 {
311 if !is_num {
312 is_num = true;
313 begin = Some(offset);
314 target = String::new();
315 } else if begin.is_none() {
316 begin = Some(offset);
317 }
318 target.push(ch);
319 } else if ch.is_ascii_alphabetic() && !is_num {
320 if begin.is_none() {
321 begin = Some(offset);
322 }
323 target.push(ch);
324 } else if begin.is_some() && (is_num || !is_num && is_toggle_word(&target)) {
325 // End of matching
326 end = Some(offset);
327 break;
328 } else if ch == '\n' {
329 break;
330 } else {
331 // To match the next word
332 is_num = false;
333 begin = None;
334 target = String::new();
335 }
336
337 offset += ch.len_utf8();
338 }
339
340 if let Some(begin) = begin
341 && (is_num || !is_num && is_toggle_word(&target))
342 {
343 if !is_num {
344 radix = 0;
345 }
346
347 let end = end.unwrap_or(offset);
348 Some((
349 begin.to_point(snapshot)..end.to_point(snapshot),
350 target,
351 radix,
352 ))
353 } else {
354 None
355 }
356}
357
358fn is_numeric_string(s: &str) -> bool {
359 if s.is_empty() {
360 return false;
361 }
362
363 let (_, rest) = if let Some(r) = s.strip_prefix('-') {
364 (true, r)
365 } else {
366 (false, s)
367 };
368
369 if rest.is_empty() {
370 return false;
371 }
372
373 if let Some(digits) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
374 digits.is_empty() || digits.chars().all(|c| c == '0' || c == '1')
375 } else if let Some(digits) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
376 digits.is_empty() || digits.chars().all(|c| c.is_ascii_hexdigit())
377 } else {
378 !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
379 }
380}
381
382fn is_toggle_word(word: &str) -> bool {
383 let lower = word.to_lowercase();
384 BOOLEAN_PAIRS
385 .iter()
386 .any(|(a, b)| lower == *a || lower == *b)
387}
388
389fn increment_toggle_string(boolean: &str) -> String {
390 let lower = boolean.to_lowercase();
391
392 let target = BOOLEAN_PAIRS
393 .iter()
394 .find_map(|(a, b)| {
395 if lower == *a {
396 Some(b)
397 } else if lower == *b {
398 Some(a)
399 } else {
400 None
401 }
402 })
403 .unwrap_or(&boolean);
404
405 if boolean.chars().all(|c| c.is_uppercase()) {
406 // Upper case
407 target.to_uppercase()
408 } else if boolean.chars().next().unwrap_or(' ').is_uppercase() {
409 // Title case
410 let mut chars = target.chars();
411 match chars.next() {
412 None => String::new(),
413 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
414 }
415 } else {
416 target.to_string()
417 }
418}
419
420#[cfg(test)]
421mod test {
422 use indoc::indoc;
423
424 use crate::{
425 state::Mode,
426 test::{NeovimBackedTestContext, VimTestContext},
427 };
428
429 #[gpui::test]
430 async fn test_increment(cx: &mut gpui::TestAppContext) {
431 let mut cx = NeovimBackedTestContext::new(cx).await;
432
433 cx.set_shared_state(indoc! {"
434 1ˇ2
435 "})
436 .await;
437
438 cx.simulate_shared_keystrokes("ctrl-a").await;
439 cx.shared_state().await.assert_eq(indoc! {"
440 1ˇ3
441 "});
442 cx.simulate_shared_keystrokes("ctrl-x").await;
443 cx.shared_state().await.assert_eq(indoc! {"
444 1ˇ2
445 "});
446
447 cx.simulate_shared_keystrokes("9 9 ctrl-a").await;
448 cx.shared_state().await.assert_eq(indoc! {"
449 11ˇ1
450 "});
451 cx.simulate_shared_keystrokes("1 1 1 ctrl-x").await;
452 cx.shared_state().await.assert_eq(indoc! {"
453 ˇ0
454 "});
455 cx.simulate_shared_keystrokes(".").await;
456 cx.shared_state().await.assert_eq(indoc! {"
457 -11ˇ1
458 "});
459 }
460
461 #[gpui::test]
462 async fn test_increment_with_dot(cx: &mut gpui::TestAppContext) {
463 let mut cx = NeovimBackedTestContext::new(cx).await;
464
465 cx.set_shared_state(indoc! {"
466 1ˇ.2
467 "})
468 .await;
469
470 cx.simulate_shared_keystrokes("ctrl-a").await;
471 cx.shared_state().await.assert_eq(indoc! {"
472 1.ˇ3
473 "});
474 cx.simulate_shared_keystrokes("ctrl-x").await;
475 cx.shared_state().await.assert_eq(indoc! {"
476 1.ˇ2
477 "});
478 }
479
480 #[gpui::test]
481 async fn test_increment_with_leading_zeros(cx: &mut gpui::TestAppContext) {
482 let mut cx = NeovimBackedTestContext::new(cx).await;
483
484 cx.set_shared_state(indoc! {"
485 000ˇ9
486 "})
487 .await;
488
489 cx.simulate_shared_keystrokes("ctrl-a").await;
490 cx.shared_state().await.assert_eq(indoc! {"
491 001ˇ0
492 "});
493 cx.simulate_shared_keystrokes("2 ctrl-x").await;
494 cx.shared_state().await.assert_eq(indoc! {"
495 000ˇ8
496 "});
497 }
498
499 #[gpui::test]
500 async fn test_increment_with_leading_zeros_and_zero(cx: &mut gpui::TestAppContext) {
501 let mut cx = NeovimBackedTestContext::new(cx).await;
502
503 cx.set_shared_state(indoc! {"
504 01ˇ1
505 "})
506 .await;
507
508 cx.simulate_shared_keystrokes("ctrl-a").await;
509 cx.shared_state().await.assert_eq(indoc! {"
510 01ˇ2
511 "});
512 cx.simulate_shared_keystrokes("1 2 ctrl-x").await;
513 cx.shared_state().await.assert_eq(indoc! {"
514 00ˇ0
515 "});
516 }
517
518 #[gpui::test]
519 async fn test_increment_with_changing_leading_zeros(cx: &mut gpui::TestAppContext) {
520 let mut cx = NeovimBackedTestContext::new(cx).await;
521
522 cx.set_shared_state(indoc! {"
523 099ˇ9
524 "})
525 .await;
526
527 cx.simulate_shared_keystrokes("ctrl-a").await;
528 cx.shared_state().await.assert_eq(indoc! {"
529 100ˇ0
530 "});
531 cx.simulate_shared_keystrokes("2 ctrl-x").await;
532 cx.shared_state().await.assert_eq(indoc! {"
533 99ˇ8
534 "});
535 }
536
537 #[gpui::test]
538 async fn test_increment_with_two_dots(cx: &mut gpui::TestAppContext) {
539 let mut cx = NeovimBackedTestContext::new(cx).await;
540
541 cx.set_shared_state(indoc! {"
542 111.ˇ.2
543 "})
544 .await;
545
546 cx.simulate_shared_keystrokes("ctrl-a").await;
547 cx.shared_state().await.assert_eq(indoc! {"
548 111..ˇ3
549 "});
550 cx.simulate_shared_keystrokes("ctrl-x").await;
551 cx.shared_state().await.assert_eq(indoc! {"
552 111..ˇ2
553 "});
554 }
555
556 #[gpui::test]
557 async fn test_increment_sign_change(cx: &mut gpui::TestAppContext) {
558 let mut cx = NeovimBackedTestContext::new(cx).await;
559 cx.set_shared_state(indoc! {"
560 ˇ0
561 "})
562 .await;
563 cx.simulate_shared_keystrokes("ctrl-x").await;
564 cx.shared_state().await.assert_eq(indoc! {"
565 -ˇ1
566 "});
567 cx.simulate_shared_keystrokes("2 ctrl-a").await;
568 cx.shared_state().await.assert_eq(indoc! {"
569 ˇ1
570 "});
571 }
572
573 #[gpui::test]
574 async fn test_increment_sign_change_with_leading_zeros(cx: &mut gpui::TestAppContext) {
575 let mut cx = NeovimBackedTestContext::new(cx).await;
576 cx.set_shared_state(indoc! {"
577 00ˇ1
578 "})
579 .await;
580 cx.simulate_shared_keystrokes("ctrl-x").await;
581 cx.shared_state().await.assert_eq(indoc! {"
582 00ˇ0
583 "});
584 cx.simulate_shared_keystrokes("ctrl-x").await;
585 cx.shared_state().await.assert_eq(indoc! {"
586 -00ˇ1
587 "});
588 cx.simulate_shared_keystrokes("2 ctrl-a").await;
589 cx.shared_state().await.assert_eq(indoc! {"
590 00ˇ1
591 "});
592 }
593
594 #[gpui::test]
595 async fn test_increment_bin_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
596 let mut cx = NeovimBackedTestContext::new(cx).await;
597 cx.set_shared_state(indoc! {"
598 0b111111111111111111111111111111111111111111111111111111111111111111111ˇ1
599 "})
600 .await;
601
602 cx.simulate_shared_keystrokes("ctrl-a").await;
603 cx.shared_state().await.assert_eq(indoc! {"
604 0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
605 "});
606 cx.simulate_shared_keystrokes("ctrl-a").await;
607 cx.shared_state().await.assert_eq(indoc! {"
608 0b000000000000000000000000000000000000000000000000000000000000000000000ˇ0
609 "});
610
611 cx.simulate_shared_keystrokes("ctrl-a").await;
612 cx.shared_state().await.assert_eq(indoc! {"
613 0b000000000000000000000000000000000000000000000000000000000000000000000ˇ1
614 "});
615 cx.simulate_shared_keystrokes("2 ctrl-x").await;
616 cx.shared_state().await.assert_eq(indoc! {"
617 0b000000111111111111111111111111111111111111111111111111111111111111111ˇ1
618 "});
619 }
620
621 #[gpui::test]
622 async fn test_increment_hex_wrapping_and_padding(cx: &mut gpui::TestAppContext) {
623 let mut cx = NeovimBackedTestContext::new(cx).await;
624 cx.set_shared_state(indoc! {"
625 0xfffffffffffffffffffˇf
626 "})
627 .await;
628
629 cx.simulate_shared_keystrokes("ctrl-a").await;
630 cx.shared_state().await.assert_eq(indoc! {"
631 0x0000fffffffffffffffˇf
632 "});
633 cx.simulate_shared_keystrokes("ctrl-a").await;
634 cx.shared_state().await.assert_eq(indoc! {"
635 0x0000000000000000000ˇ0
636 "});
637 cx.simulate_shared_keystrokes("ctrl-a").await;
638 cx.shared_state().await.assert_eq(indoc! {"
639 0x0000000000000000000ˇ1
640 "});
641 cx.simulate_shared_keystrokes("2 ctrl-x").await;
642 cx.shared_state().await.assert_eq(indoc! {"
643 0x0000fffffffffffffffˇf
644 "});
645 }
646
647 #[gpui::test]
648 async fn test_increment_wrapping(cx: &mut gpui::TestAppContext) {
649 let mut cx = NeovimBackedTestContext::new(cx).await;
650 cx.set_shared_state(indoc! {"
651 1844674407370955161ˇ9
652 "})
653 .await;
654
655 cx.simulate_shared_keystrokes("ctrl-a").await;
656 cx.shared_state().await.assert_eq(indoc! {"
657 1844674407370955161ˇ5
658 "});
659 cx.simulate_shared_keystrokes("ctrl-a").await;
660 cx.shared_state().await.assert_eq(indoc! {"
661 -1844674407370955161ˇ5
662 "});
663 cx.simulate_shared_keystrokes("ctrl-a").await;
664 cx.shared_state().await.assert_eq(indoc! {"
665 -1844674407370955161ˇ4
666 "});
667 cx.simulate_shared_keystrokes("3 ctrl-x").await;
668 cx.shared_state().await.assert_eq(indoc! {"
669 1844674407370955161ˇ4
670 "});
671 cx.simulate_shared_keystrokes("2 ctrl-a").await;
672 cx.shared_state().await.assert_eq(indoc! {"
673 -1844674407370955161ˇ5
674 "});
675 }
676
677 #[gpui::test]
678 async fn test_increment_inline(cx: &mut gpui::TestAppContext) {
679 let mut cx = NeovimBackedTestContext::new(cx).await;
680 cx.set_shared_state(indoc! {"
681 inline0x3ˇ9u32
682 "})
683 .await;
684
685 cx.simulate_shared_keystrokes("ctrl-a").await;
686 cx.shared_state().await.assert_eq(indoc! {"
687 inline0x3ˇau32
688 "});
689 cx.simulate_shared_keystrokes("ctrl-a").await;
690 cx.shared_state().await.assert_eq(indoc! {"
691 inline0x3ˇbu32
692 "});
693 cx.simulate_shared_keystrokes("l l l ctrl-a").await;
694 cx.shared_state().await.assert_eq(indoc! {"
695 inline0x3bu3ˇ3
696 "});
697 }
698
699 #[gpui::test]
700 async fn test_increment_hex_casing(cx: &mut gpui::TestAppContext) {
701 let mut cx = NeovimBackedTestContext::new(cx).await;
702 cx.set_shared_state(indoc! {"
703 0xFˇa
704 "})
705 .await;
706
707 cx.simulate_shared_keystrokes("ctrl-a").await;
708 cx.shared_state().await.assert_eq(indoc! {"
709 0xfˇb
710 "});
711 cx.simulate_shared_keystrokes("ctrl-a").await;
712 cx.shared_state().await.assert_eq(indoc! {"
713 0xfˇc
714 "});
715 }
716
717 #[gpui::test]
718 async fn test_increment_radix(cx: &mut gpui::TestAppContext) {
719 let mut cx = NeovimBackedTestContext::new(cx).await;
720
721 cx.simulate("ctrl-a", "ˇ total: 0xff")
722 .await
723 .assert_matches();
724 cx.simulate("ctrl-x", "ˇ total: 0xff")
725 .await
726 .assert_matches();
727 cx.simulate("ctrl-x", "ˇ total: 0xFF")
728 .await
729 .assert_matches();
730 cx.simulate("ctrl-a", "(ˇ0b10f)").await.assert_matches();
731 cx.simulate("ctrl-a", "ˇ-1").await.assert_matches();
732 cx.simulate("ctrl-a", "-ˇ1").await.assert_matches();
733 cx.simulate("ctrl-a", "banˇana").await.assert_matches();
734 }
735
736 #[gpui::test]
737 async fn test_increment_steps(cx: &mut gpui::TestAppContext) {
738 let mut cx = NeovimBackedTestContext::new(cx).await;
739
740 cx.set_shared_state(indoc! {"
741 ˇ1
742 1
743 1 2
744 1
745 1"})
746 .await;
747
748 cx.simulate_shared_keystrokes("j v shift-g g ctrl-a").await;
749 cx.shared_state().await.assert_eq(indoc! {"
750 1
751 ˇ2
752 3 2
753 4
754 5"});
755
756 cx.simulate_shared_keystrokes("shift-g ctrl-v g g").await;
757 cx.shared_state().await.assert_eq(indoc! {"
758 «1ˇ»
759 «2ˇ»
760 «3ˇ» 2
761 «4ˇ»
762 «5ˇ»"});
763
764 cx.simulate_shared_keystrokes("g ctrl-x").await;
765 cx.shared_state().await.assert_eq(indoc! {"
766 ˇ0
767 0
768 0 2
769 0
770 0"});
771 cx.simulate_shared_keystrokes("v shift-g g ctrl-a").await;
772 cx.simulate_shared_keystrokes("v shift-g 5 g ctrl-a").await;
773 cx.shared_state().await.assert_eq(indoc! {"
774 ˇ6
775 12
776 18 2
777 24
778 30"});
779 }
780
781 #[gpui::test]
782 async fn test_increment_toggle(cx: &mut gpui::TestAppContext) {
783 let mut cx = VimTestContext::new(cx, true).await;
784
785 cx.set_state("let enabled = trˇue;", Mode::Normal);
786 cx.simulate_keystrokes("ctrl-a");
787 cx.assert_state("let enabled = falsˇe;", Mode::Normal);
788
789 cx.simulate_keystrokes("0 ctrl-a");
790 cx.assert_state("let enabled = truˇe;", Mode::Normal);
791
792 cx.set_state(
793 indoc! {"
794 ˇlet enabled = TRUE;
795 let enabled = TRUE;
796 let enabled = TRUE;
797 "},
798 Mode::Normal,
799 );
800 cx.simulate_keystrokes("shift-v j j ctrl-x");
801 cx.assert_state(
802 indoc! {"
803 ˇlet enabled = FALSE;
804 let enabled = FALSE;
805 let enabled = FALSE;
806 "},
807 Mode::Normal,
808 );
809
810 cx.set_state(
811 indoc! {"
812 let enabled = ˇYes;
813 let enabled = Yes;
814 let enabled = Yes;
815 "},
816 Mode::Normal,
817 );
818 cx.simulate_keystrokes("ctrl-v j j e ctrl-x");
819 cx.assert_state(
820 indoc! {"
821 let enabled = ˇNo;
822 let enabled = No;
823 let enabled = No;
824 "},
825 Mode::Normal,
826 );
827
828 cx.set_state("ˇlet enabled = True;", Mode::Normal);
829 cx.simulate_keystrokes("ctrl-a");
830 cx.assert_state("let enabled = Falsˇe;", Mode::Normal);
831
832 cx.simulate_keystrokes("ctrl-a");
833 cx.assert_state("let enabled = Truˇe;", Mode::Normal);
834
835 cx.set_state("let enabled = Onˇ;", Mode::Normal);
836 cx.simulate_keystrokes("v b ctrl-a");
837 cx.assert_state("let enabled = ˇOff;", Mode::Normal);
838 }
839
840 #[gpui::test]
841 async fn test_increment_order(cx: &mut gpui::TestAppContext) {
842 let mut cx = VimTestContext::new(cx, true).await;
843
844 cx.set_state("aaˇa false 1 2 3", Mode::Normal);
845 cx.simulate_keystrokes("ctrl-a");
846 cx.assert_state("aaa truˇe 1 2 3", Mode::Normal);
847
848 cx.set_state("aaˇa 1 false 2 3", Mode::Normal);
849 cx.simulate_keystrokes("ctrl-a");
850 cx.assert_state("aaa ˇ2 false 2 3", Mode::Normal);
851
852 cx.set_state("trueˇ 1 2 3", Mode::Normal);
853 cx.simulate_keystrokes("ctrl-a");
854 cx.assert_state("true ˇ2 2 3", Mode::Normal);
855
856 cx.set_state("falseˇ", Mode::Normal);
857 cx.simulate_keystrokes("ctrl-a");
858 cx.assert_state("truˇe", Mode::Normal);
859
860 cx.set_state("⚡️ˇ⚡️", Mode::Normal);
861 cx.simulate_keystrokes("ctrl-a");
862 cx.assert_state("⚡️ˇ⚡️", Mode::Normal);
863 }
864
865 #[gpui::test]
866 async fn test_increment_visual_partial_number(cx: &mut gpui::TestAppContext) {
867 let mut cx = NeovimBackedTestContext::new(cx).await;
868
869 cx.set_shared_state("ˇ123").await;
870 cx.simulate_shared_keystrokes("v l ctrl-a").await;
871 cx.shared_state().await.assert_eq(indoc! {"ˇ133"});
872 cx.simulate_shared_keystrokes("l v l ctrl-a").await;
873 cx.shared_state().await.assert_eq(indoc! {"1ˇ34"});
874 cx.simulate_shared_keystrokes("shift-v y p p ctrl-v k k l ctrl-a")
875 .await;
876 cx.shared_state().await.assert_eq(indoc! {"ˇ144\n144\n144"});
877 }
878
879 #[gpui::test]
880 async fn test_increment_markdown_list_markers_multiline(cx: &mut gpui::TestAppContext) {
881 let mut cx = NeovimBackedTestContext::new(cx).await;
882
883 cx.set_shared_state("# Title\nˇ1. item\n2. item\n3. item")
884 .await;
885 cx.simulate_shared_keystrokes("ctrl-a").await;
886 cx.shared_state()
887 .await
888 .assert_eq("# Title\nˇ2. item\n2. item\n3. item");
889 cx.simulate_shared_keystrokes("j").await;
890 cx.shared_state()
891 .await
892 .assert_eq("# Title\n2. item\nˇ2. item\n3. item");
893 cx.simulate_shared_keystrokes("ctrl-a").await;
894 cx.shared_state()
895 .await
896 .assert_eq("# Title\n2. item\nˇ3. item\n3. item");
897 cx.simulate_shared_keystrokes("ctrl-x").await;
898 cx.shared_state()
899 .await
900 .assert_eq("# Title\n2. item\nˇ2. item\n3. item");
901 }
902
903 #[gpui::test]
904 async fn test_increment_with_multibyte_characters(cx: &mut gpui::TestAppContext) {
905 let mut cx = VimTestContext::new(cx, true).await;
906
907 // Test cursor after a multibyte character - this would panic before the fix
908 // because the backward scan would land in the middle of the Korean character
909 cx.set_state("지ˇ1", Mode::Normal);
910 cx.simulate_keystrokes("ctrl-a");
911 cx.assert_state("지ˇ2", Mode::Normal);
912 }
913}