increment.rs

  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 { 1 } 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 } 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    }
594}