increment.rs

  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}