increment.rs

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