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