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}