hjkl-vim 0.40.0

Vim modal state types and grammar primitives for the hjkl editor stack. Pre-1.0 churn.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
//! Vim FSM: command.
//!
//! Split out of the monolithic `vim.rs` (#267 follow-up).

use hjkl_vim_types::{LastChange, Mode, RangeKind};

use hjkl_engine::rope_util::{rope_line_to_str, rope_row_range_str};

use super::*;
use crate::vim_state::{vim, vim_mut};
use hjkl_engine::Editor;
use hjkl_engine::buf_helpers::{
    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_pos, buf_set_cursor_rc,
};

/// Read the text in a vim-shaped range without mutating. Used by
/// `Operator::Yank` so we can pipe the same range translation as
/// [`cut_vim_range`] but skip the delete + inverse extraction.
pub fn read_vim_range<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    start: (usize, usize),
    end: (usize, usize),
    kind: RangeKind,
) -> String {
    let (top, bot) = order(start, end);
    ed.sync_buffer_content_from_textarea();
    let rope = hjkl_engine::types::Query::rope(ed.buffer());
    let n_lines = rope.len_lines();
    match kind {
        RangeKind::Linewise => {
            let lo = top.0;
            let hi = bot.0.min(n_lines.saturating_sub(1));
            let mut text = rope_row_range_str(&rope, lo, hi);
            text.push('\n');
            text
        }
        RangeKind::Inclusive | RangeKind::Exclusive => {
            let inclusive = matches!(kind, RangeKind::Inclusive);
            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
            let mut out = String::new();
            for row in top.0..=bot.0 {
                if row >= n_lines {
                    break;
                }
                let line = rope_line_to_str(&rope, row);
                let lo = if row == top.0 { top.1 } else { 0 };
                let hi_unclamped = if row == bot.0 {
                    if inclusive { bot.1 + 1 } else { bot.1 }
                } else {
                    line.chars().count()
                };
                let row_chars: Vec<char> = line.chars().collect();
                let hi = hi_unclamped.min(row_chars.len());
                if lo < hi {
                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
                }
                if row < bot.0 {
                    out.push('\n');
                }
            }
            out
        }
    }
}
/// Cut a vim-shaped range through the View edit funnel and return
/// the deleted text. Translates vim's `RangeKind`
/// (Linewise/Inclusive/Exclusive) into the buffer's
/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
/// position adjustment so inclusive motions actually include the bot
/// cell. Pushes the cut text into the clipboard via `record_yank_to_host`
/// and the textarea yank buffer (still observed by `p`/`P` until the paste
/// path is ported), and updates `yank_linewise` for linewise cuts.
pub fn cut_vim_range<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    start: (usize, usize),
    end: (usize, usize),
    kind: RangeKind,
) -> String {
    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
    let (top, bot) = order(start, end);
    ed.sync_buffer_content_from_textarea();
    let (buf_start, buf_end, buf_kind) = match kind {
        RangeKind::Linewise => (
            Position::new(top.0, 0),
            Position::new(bot.0, 0),
            BufKind::Line,
        ),
        RangeKind::Inclusive => {
            let line_chars = buf_line_chars(ed.buffer(), bot.0);
            // Advance one cell past `bot` so the buffer's exclusive
            // `cut_chars` actually drops the inclusive endpoint. Wrap
            // to the next row when bot already sits on the last char.
            let next = if bot.1 < line_chars {
                Position::new(bot.0, bot.1 + 1)
            } else if bot.0 + 1 < buf_row_count(ed.buffer()) {
                Position::new(bot.0 + 1, 0)
            } else {
                Position::new(bot.0, line_chars)
            };
            (Position::new(top.0, top.1), next, BufKind::Char)
        }
        RangeKind::Exclusive => (
            Position::new(top.0, top.1),
            Position::new(bot.0, bot.1),
            BufKind::Char,
        ),
    };
    let inverse = ed.mutate_edit(Edit::DeleteRange {
        start: buf_start,
        end: buf_end,
        kind: buf_kind,
    });
    let raw_text = match inverse {
        Edit::InsertStr { text, .. } => text,
        _ => String::new(),
    };
    // Normalize linewise register text: vim always appends '\n' to
    // linewise register content. The inverse from do_delete_range may
    // produce text with a leading '\n' (last-line delete with rows
    // above) or no newline at all (whole-buffer delete / empty buffer).
    let text = if matches!(kind, RangeKind::Linewise) {
        if raw_text.ends_with('\n') {
            // Normal mid-buffer delete: trailing '\n' already correct.
            raw_text
        } else if raw_text.starts_with('\n') {
            // Last-line delete with rows above: leading '\n' belongs
            // at the end (vim register convention).
            format!("{}\n", raw_text.strip_prefix('\n').unwrap())
        } else if raw_text.is_empty() {
            // The buffer reports an empty inverse only when this linewise
            // operation removed nothing. Keep registers untouched.
            return String::new();
        } else {
            // Whole-buffer delete: no newline in inverse text, append one.
            format!("{raw_text}\n")
        }
    } else {
        raw_text
    };
    if text.is_empty() {
        // Non-linewise delete yielded no text — nothing to record.
        return text;
    }
    ed.record_yank_to_host(text.clone());
    let target = vim_mut(ed).pending_register.take();
    ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise), target);
    text
}
/// `D` / `C` — delete from cursor to end of line through the edit
/// funnel. Pushes the deleted text to the clipboard via `record_yank_to_host`
/// and the textarea's yank buffer (still observed by `p`/`P` until the paste
/// path is ported). Cursor lands at the deletion start so the caller
/// can decide whether to step it left (`D`) or open insert mode (`C`).
pub fn delete_to_eol<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    let cursor = buf_cursor_pos(ed.buffer());
    let line_chars = buf_line_chars(ed.buffer(), cursor.row);
    if cursor.col >= line_chars {
        return;
    }
    let inverse = ed.mutate_edit(Edit::DeleteRange {
        start: cursor,
        end: Position::new(cursor.row, line_chars),
        kind: MotionKind::Char,
    });
    if let Edit::InsertStr { text, .. } = inverse
        && !text.is_empty()
    {
        ed.record_yank_to_host(text.clone());
        ed.set_yank_linewise(false);
        ed.set_yank(text);
    }
    buf_set_cursor_pos(ed.buffer_mut(), cursor);
}
pub fn do_char_delete<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    forward: bool,
    count: usize,
) {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.push_undo();
    ed.sync_buffer_content_from_textarea();
    // Collect deleted chars so we can write them to the unnamed register
    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
    let mut deleted = String::new();
    for _ in 0..count {
        let cursor = buf_cursor_pos(ed.buffer());
        let line_chars = buf_line_chars(ed.buffer(), cursor.row);
        if forward {
            // `x` — delete the char under the cursor. Vim no-ops on
            // an empty line; the buffer would drop a row otherwise.
            // `break`, not `continue`: the cursor can't regress below
            // EOL, so remaining iterations would spin uselessly (a
            // saturated count prefix would hang the editor).
            if cursor.col >= line_chars {
                break;
            }
            let inverse = ed.mutate_edit(Edit::DeleteRange {
                start: cursor,
                end: Position::new(cursor.row, cursor.col + 1),
                kind: MotionKind::Char,
            });
            if let Edit::InsertStr { text, .. } = inverse {
                deleted.push_str(&text);
            }
        } else {
            // `X` — delete the char before the cursor. `break` for the
            // same no-further-progress reason as the `x` arm above.
            if cursor.col == 0 {
                break;
            }
            let inverse = ed.mutate_edit(Edit::DeleteRange {
                start: Position::new(cursor.row, cursor.col - 1),
                end: cursor,
                kind: MotionKind::Char,
            });
            if let Edit::InsertStr { text, .. } = inverse {
                // X deletes backwards; prepend so the register text
                // matches reading order (first deleted char first).
                deleted = text + &deleted;
            }
        }
    }
    if !deleted.is_empty() {
        ed.record_yank_to_host(deleted.clone());
        let target = vim_mut(ed).pending_register.take();
        ed.record_delete(deleted, false, target);
    }
    // B11: `x` deleting the last char(s) of a line can leave the cursor one
    // past the new end — vim clamps to the new last column in Normal mode.
    let cursor = buf_cursor_pos(ed.buffer());
    let line_chars = buf_line_chars(ed.buffer(), cursor.row);
    if line_chars > 0 && cursor.col >= line_chars {
        buf_set_cursor_pos(ed.buffer_mut(), Position::new(cursor.row, line_chars - 1));
    }
}
/// A number located on one line, with its post-`delta` text already formatted.
///
/// `start..end` are char indices into the line the span was found on; replacing
/// that range with `text` performs the adjustment.
struct AdjustedNumber {
    start: usize,
    end: usize,
    text: String,
}

/// Find the leftmost number at or after char index `from` on `chars`, add
/// `delta`, and format the result the way vim does.
///
/// A `0x`/`0X` hex literal wins over a bare decimal starting at the same index.
/// Returns `None` when the rest of the line holds no number, or when the digits
/// found do not fit the parse type — both cases mean "leave the line alone".
///
/// This is the single implementation shared by normal-mode
/// [`adjust_number`] and visual-mode [`adjust_number_visual`]; keeping one
/// copy is what stops the two modes from drifting on padding and digit case.
fn adjusted_number_at(chars: &[char], from: usize, delta: i64) -> Option<AdjustedNumber> {
    let len = chars.len();
    let is_hex_prefix = |i: usize| {
        chars[i] == '0'
            && matches!(chars.get(i + 1), Some('x' | 'X'))
            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
    };
    let start = (from.min(len)..len).find(|&i| is_hex_prefix(i) || chars[i].is_ascii_digit())?;

    if is_hex_prefix(start) {
        // `0x` + hex digits. Increment in hex, preserve the digit width.
        let digits_start = start + 2;
        let mut digits_end = digits_start;
        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
            digits_end += 1;
        }
        let digits: String = chars[digits_start..digits_end].iter().collect();
        let n = u64::from_str_radix(&digits, 16).ok()?;
        let new_val = (n as i128 + delta as i128).max(0) as u64;
        let width = digits_end - digits_start;
        let prefix: String = chars[start..digits_start].iter().collect();
        // Vim picks the output's letter case from the *last* letter digit of
        // the original ("0xaB" <C-a> -> "0xAC", "0xAb" -> "0xac"). With no
        // letter digit to go by it falls back to the `x`/`X` prefix's own case
        // ("0X19" <C-a> -> "0X1A", "0x19" -> "0x1a").
        let upper = digits
            .chars()
            .rev()
            .find(|c| c.is_ascii_alphabetic())
            .map_or(chars[start + 1] == 'X', |c| c.is_ascii_uppercase());
        let text = if upper {
            format!("{prefix}{new_val:0width$X}")
        } else {
            format!("{prefix}{new_val:0width$x}")
        };
        return Some(AdjustedNumber {
            start,
            end: digits_end,
            text,
        });
    }

    // Signed decimal. A leading `-` immediately before the digits is part of
    // the number.
    let span_start = if start > 0 && chars[start - 1] == '-' {
        start - 1
    } else {
        start
    };
    let mut span_end = start;
    while span_end < len && chars[span_end].is_ascii_digit() {
        span_end += 1;
    }
    let s: String = chars[span_start..span_end].iter().collect();
    let n = s.parse::<i64>().ok()?;
    let new_val = n as i128 + delta as i128;
    // Vim zero-pads the result back to the original digit width, but only when
    // the original number actually had a leading zero (`:h CTRL-A`): "10"
    // <C-x> -> "9", not "09"; "007" <C-x> -> "006". The `-` sign is never part
    // of the padded width — "-007" <C-a> -> "-006", and crossing zero into
    // negative still pads the digits ("009" 20<C-x> -> "-011").
    let digits: String = chars[start..span_end].iter().collect();
    let width = digits.len();
    let text = if width > 1 && digits.starts_with('0') {
        if new_val < 0 {
            let mag = new_val.unsigned_abs();
            format!("-{mag:0width$}")
        } else {
            format!("{new_val:0width$}")
        }
    } else {
        new_val.to_string()
    };
    Some(AdjustedNumber {
        start: span_start,
        end: span_end,
        text,
    })
}

/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
/// current line, add `delta`, leave the cursor on the last digit of the result.
/// Recognises `0x`/`0X` hex literals (incremented in hex, width and digit case
/// preserved) as well as signed decimals. No-op if the line has no number to
/// the right.
pub fn adjust_number<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    delta: i64,
) -> bool {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    let cursor = buf_cursor_pos(ed.buffer());
    let row = cursor.row;
    let chars: Vec<char> = match buf_line(ed.buffer(), row) {
        Some(l) => l.chars().collect(),
        None => return false,
    };
    let Some(AdjustedNumber {
        start: span_start,
        end: span_end,
        text: new_s,
    }) = adjusted_number_at(&chars, cursor.col, delta)
    else {
        return false;
    };

    ed.push_undo();
    let span_start_pos = Position::new(row, span_start);
    let span_end_pos = Position::new(row, span_end);
    ed.mutate_edit(Edit::DeleteRange {
        start: span_start_pos,
        end: span_end_pos,
        kind: MotionKind::Char,
    });
    ed.mutate_edit(Edit::InsertStr {
        at: span_start_pos,
        text: new_s.clone(),
    });
    let new_len = new_s.chars().count();
    buf_set_cursor_rc(ed.buffer_mut(), row, span_start + new_len.saturating_sub(1));
    true
}
pub fn replace_char<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    ch: char,
    count: usize,
) {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    // Vim aborts `r{count}{char}` entirely — replacing nothing — when fewer
    // than `count` characters remain from the cursor to end-of-line, rather
    // than replacing a partial run. Check before touching undo/the buffer.
    let start = buf_cursor_pos(ed.buffer());
    let start_line_chars = buf_line_chars(ed.buffer(), start.row);
    if count == 0 || start.col + count > start_line_chars {
        return;
    }
    ed.push_undo();
    for _ in 0..count {
        let cursor = buf_cursor_pos(ed.buffer());
        let line_chars = buf_line_chars(ed.buffer(), cursor.row);
        if cursor.col >= line_chars {
            break;
        }
        ed.mutate_edit(Edit::DeleteRange {
            start: cursor,
            end: Position::new(cursor.row, cursor.col + 1),
            kind: MotionKind::Char,
        });
        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
    }
    // Vim leaves the cursor on the last replaced char.
    hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
}
/// Returns `false` when there is no char under the cursor to toggle
/// (end of line / empty line) so counted loops can stop instead of
/// spinning through a saturated count prefix.
pub fn toggle_case_at_cursor<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
) -> bool {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    let cursor = buf_cursor_pos(ed.buffer());
    let Some(c) = buf_line(ed.buffer(), cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
        return false;
    };
    let toggled = if c.is_uppercase() {
        c.to_lowercase().collect::<String>()
    } else {
        c.to_uppercase().collect::<String>()
    };
    ed.mutate_edit(Edit::DeleteRange {
        start: cursor,
        end: Position::new(cursor.row, cursor.col + 1),
        kind: MotionKind::Char,
    });
    ed.mutate_edit(Edit::InsertStr {
        at: cursor,
        text: toggled,
    });
    true
}
/// Returns `false` when the cursor is on the last line (nothing to
/// join) so counted loops can stop instead of spinning.
pub fn join_line<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) -> bool {
    use hjkl_buffer::{Edit, Position};
    ed.sync_buffer_content_from_textarea();
    let row = buf_cursor_pos(ed.buffer()).row;
    let n_rows = buf_row_count(ed.buffer());
    if row + 1 >= n_rows {
        return false;
    }
    // Vim does not join with ropey's phantom trailing empty row (the
    // ropey representation of a trailing newline).  If the only row
    // below is the phantom, treat it as "no line below" and abort.
    if row + 2 >= n_rows && buf_line(ed.buffer(), row + 1).is_some_and(|s| s.is_empty()) {
        return false;
    }
    let cur_line = buf_line(ed.buffer(), row).unwrap_or_default();
    let next_raw = buf_line(ed.buffer(), row + 1).unwrap_or_default();
    let next_trimmed = next_raw.trim_start();
    let cur_chars = cur_line.chars().count();
    let next_chars = next_raw.chars().count();
    // `J` inserts a single space iff both sides are non-empty after
    // stripping the next line's leading whitespace.
    let separator = if !cur_line.is_empty()
        && !next_trimmed.is_empty()
        && !cur_line.ends_with([' ', '\t'])
        && !next_trimmed.starts_with(')')
    {
        " "
    } else {
        ""
    };
    let joined = format!("{cur_line}{separator}{next_trimmed}");
    ed.mutate_edit(Edit::Replace {
        start: Position::new(row, 0),
        end: Position::new(row + 1, next_chars),
        with: joined,
    });
    // Vim parks the cursor on the inserted space — or at the join
    // point when no space went in (which is the same column either
    // way, since the space sits exactly at `cur_chars`).
    buf_set_cursor_rc(ed.buffer_mut(), row, cur_chars);
    true
}
/// `gJ` — join the next line onto the current one without inserting a
/// separating space or stripping leading whitespace.
/// Returns `false` when the cursor is on the last line. See [`join_line`].
pub fn join_line_raw<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) -> bool {
    use hjkl_buffer::Edit;
    ed.sync_buffer_content_from_textarea();
    let row = buf_cursor_pos(ed.buffer()).row;
    let n_rows = buf_row_count(ed.buffer());
    if row + 1 >= n_rows {
        return false;
    }
    // Same phantom-row guard as `join_line` — see there for rationale.
    if row + 2 >= n_rows && buf_line(ed.buffer(), row + 1).is_some_and(|s| s.is_empty()) {
        return false;
    }
    let join_col = buf_line_chars(ed.buffer(), row);
    ed.mutate_edit(Edit::JoinLines {
        row,
        count: 1,
        with_space: false,
    });
    // Vim leaves the cursor at the join point (end of original line).
    buf_set_cursor_rc(ed.buffer_mut(), row, join_col);
    true
}
/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
/// every line spanned by the selection into one. A single-line selection joins
/// the current line with the one below (matching normal-mode `J`).
pub fn visual_join<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    with_space: bool,
) {
    let cursor_row = buf_cursor_pos(ed.buffer()).row;
    let (top, bot) = match vim(ed).mode {
        Mode::VisualLine => (
            cursor_row.min(vim(ed).visual_line_anchor),
            cursor_row.max(vim(ed).visual_line_anchor),
        ),
        Mode::VisualBlock => {
            let a = vim(ed).block_anchor.0;
            (a.min(cursor_row), a.max(cursor_row))
        }
        Mode::Visual => {
            let a = vim(ed).visual_anchor.0;
            (a.min(cursor_row), a.max(cursor_row))
        }
        _ => return,
    };
    // N selected lines → N-1 joins; a single line still does one join (with the
    // line below) like normal-mode `J`.
    let joins = (bot - top).max(1);
    ed.push_undo();
    buf_set_cursor_rc(ed.buffer_mut(), top, 0);
    for _ in 0..joins {
        let joined = if with_space {
            join_line(ed)
        } else {
            join_line_raw(ed)
        };
        if !joined {
            break;
        }
    }
    // B1: visual `J`/`gJ` is exactly `[joins + 1]J`/`gJ` from the top row —
    // reuse the existing `JoinLine` dot-repeat entry (`:h v_J`) rather than
    // adding a bespoke visual variant; replay already starts at whatever
    // row the cursor is on, same as this function does via the
    // `buf_set_cursor_rc` above.
    if !vim(ed).replaying {
        vim_mut(ed).last_change = Some(LastChange::JoinLine { count: joins });
    }
    vim_mut(ed).mode = Mode::Normal;
    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
}
/// `[count]%` — go to the line at `count` percent of the file (vim: line
/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
pub fn goto_percent<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    count: usize,
) {
    let rows = buf_row_count(ed.buffer());
    if rows == 0 {
        return;
    }
    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
    // in vim, not N+1) so the percentage matches nvim.
    let total = if rows >= 2 && buf_line(ed.buffer(), rows - 1).is_some_and(|s| s.is_empty()) {
        rows - 1
    } else {
        rows
    };
    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
    // Saturating: a pathological count prefix (e.g. 20 typed digits) must not
    // overflow the multiply; the clamp below caps the result at `total` anyway.
    let line = count.saturating_mul(total).div_ceil(100).clamp(1, total);
    let pre = ed.cursor();
    ed.jump_cursor(line - 1, 0);
    move_first_non_whitespace(ed);
    ed.set_sticky_col(Some(ed.cursor().1));
    if ed.cursor() != pre {
        ed.push_jump(pre);
    }
}
/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
/// to the next `tabstop` boundary and a space as one column.
pub fn indent_width(s: &str, tabstop: usize) -> usize {
    let ts = tabstop.max(1);
    let mut w = 0usize;
    for c in s.chars() {
        match c {
            ' ' => w += 1,
            '\t' => w += ts - (w % ts),
            _ => break,
        }
    }
    w
}
/// Build a leading-whitespace string of `width` columns honoring `expandtab`
/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
pub fn build_indent(width: usize, settings: &hjkl_engine::Settings) -> String {
    if settings.expandtab {
        return " ".repeat(width);
    }
    let ts = settings.tabstop.max(1);
    let tabs = width / ts;
    let spaces = width % ts;
    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
}
/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
/// matches `target_width` columns; later lines keep their relative offset.
pub fn reindent_block(text: &str, target_width: usize, settings: &hjkl_engine::Settings) -> String {
    let ts = settings.tabstop.max(1);
    let lines: Vec<&str> = text.split('\n').collect();
    let first_width = lines.first().map_or(0, |l| indent_width(l, ts));
    let delta = target_width as isize - first_width as isize;
    lines
        .iter()
        .map(|line| {
            let trimmed = line.trim_start_matches([' ', '\t']);
            if trimmed.is_empty() {
                // Preserve blank lines as truly empty (vim does not indent them).
                return String::new();
            }
            let old_w = indent_width(line, ts) as isize;
            let new_w = (old_w + delta).max(0) as usize;
            format!("{}{}", build_indent(new_w, settings), trimmed)
        })
        .collect::<Vec<_>>()
        .join("\n")
}
pub fn do_paste<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    before: bool,
    count: usize,
    cursor_after: bool,
    reindent: bool,
) -> bool {
    use hjkl_buffer::{Edit, Position};
    // Resolve the source register: `"reg` prefix (consumed) or the
    // unnamed register otherwise. Read text + linewise from the
    // selected slot rather than the global `vim.yank_linewise` so
    // pasting from `"0` after a delete still uses the yank's layout.
    let selector = vim_mut(ed).pending_register.take();
    // `"+p`/`"*p`: refresh the register slot from the live OS clipboard
    // before reading it below (audit-r2 fix 4) — otherwise this reads
    // whatever the internal slot last had from an in-editor `"+y`.
    sync_clipboard_register_for(ed, selector);
    let (yank, linewise, blockwise, block_width) =
        ed.with_registers(|regs| match selector.and_then(|c| regs.read(c)) {
            Some(slot) => (
                slot.text.clone(),
                slot.linewise,
                slot.blockwise,
                slot.block_width,
            ),
            // Read both fields from the unnamed slot rather than mixing the
            // slot's text with `vim.yank_linewise`. The cached vim flag is
            // per-editor, so a register imported from another editor (e.g.
            // cross-buffer yank/paste) carried the wrong linewise without
            // this — pasting a linewise yank inserted at the char cursor.
            None => (
                regs.unnamed.text.clone(),
                regs.unnamed.linewise,
                regs.unnamed.blockwise,
                regs.unnamed.block_width,
            ),
        });
    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
    // the final paste, `]` = last inserted char of the final paste.
    // We track (lo, hi) across iterations; the last value wins.
    // Capture the cursor row before any paste iterations. Vim's
    // linewise `[count]p` lands the cursor on the FIRST pasted line
    // (original_row + 1), not on the last iteration's paste row.
    // Without this snapshot the per-iteration cursor advancement leaves
    // the cursor at `original_row + count` instead.
    let original_row_for_linewise_after = if linewise && !before {
        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
        let r = buf_cursor_pos(ed.buffer()).row;
        let (_, fold_end) = expand_linewise_over_closed_folds(ed.buffer(), r, r);
        Some(fold_end)
    } else {
        None
    };
    // Empty register: nothing to paste on any iteration — bail before the
    // loop instead of `continue`-spinning through a huge count prefix.
    if yank.is_empty() {
        return false;
    }
    // Bound requested source bytes before allocating or opening an undo entry.
    // The paste paths construct temporary strings, so this conservative limit
    // leaves headroom beneath the fuzz worker's 2 GiB RSS ceiling.
    const MAX_PASTE_BYTES: usize = 1024 * 1024;
    let Some(requested_bytes) = yank.len().checked_mul(count) else {
        return false;
    };
    if requested_bytes > MAX_PASTE_BYTES {
        return false;
    }
    if blockwise {
        let Some(block_bytes) = block_width
            .checked_mul(yank.split('\n').count())
            .and_then(|bytes| bytes.checked_mul(count))
        else {
            return false;
        };
        let Some(total_bytes) = requested_bytes.checked_add(block_bytes) else {
            return false;
        };
        if total_bytes > MAX_PASTE_BYTES {
            return false;
        }
    }
    ed.push_undo();
    // Blockwise register (`<C-v>` yank/delete): re-insert the row segments
    // as columns at the cursor. Handles its own cursor / marks / sticky
    // column, so return before the charwise/linewise loop below.
    if blockwise {
        do_block_paste(ed, before, count, block_width, &yank);
        return true;
    }
    // Charwise pastes insert the register text repeated `count` times as a
    // single block. Linewise pastes likewise construct one multi-line edit:
    // applying a separate edit per copy magnifies undo and allocation costs.
    let paste_mark = if linewise {
        ed.sync_buffer_content_from_textarea();
        let row = buf_cursor_pos(ed.buffer()).row;
        let mut text = yank.trim_matches('\n').to_string();
        if reindent {
            let cur_line = buf_line(ed.buffer(), row).unwrap_or_default();
            let target_w = indent_width(&cur_line, ed.settings().tabstop.max(1));
            text = reindent_block(&text, target_w, ed.settings());
        }
        let (fold_start, fold_end) = expand_linewise_over_closed_folds(ed.buffer(), row, row);
        let payload = std::iter::repeat_n(text.as_str(), count)
            .collect::<Vec<_>>()
            .join("\n");
        let target_row = if before {
            ed.mutate_edit(Edit::InsertStr {
                at: Position::new(fold_start, 0),
                text: format!("{payload}\n"),
            });
            fold_start
        } else {
            let line_chars = buf_line_chars(ed.buffer(), fold_end);
            ed.mutate_edit(Edit::InsertStr {
                at: Position::new(fold_end, line_chars),
                text: format!("\n{payload}"),
            });
            fold_end + 1
        };
        buf_set_cursor_rc(ed.buffer_mut(), target_row, 0);
        hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
        let payload_lines = payload.lines().count().max(1);
        let bot_row = target_row + payload_lines - 1;
        let bot_last_col = buf_line_chars(ed.buffer(), bot_row).saturating_sub(1);
        ((target_row, 0), (bot_row, bot_last_col))
    } else {
        ed.sync_buffer_content_from_textarea();
        // Charwise paste. `P` inserts at cursor (shifting cell
        // right); `p` inserts after cursor (advance one cell first,
        // clamped to the end of the line).
        let cursor = buf_cursor_pos(ed.buffer());
        let at = if before {
            cursor
        } else {
            let line_chars = buf_line_chars(ed.buffer(), cursor.row);
            Position::new(cursor.row, (cursor.col + 1).min(line_chars))
        };
        let repeated = yank.repeat(count);
        ed.mutate_edit(Edit::InsertStr { at, text: repeated });
        // Vim parks the cursor on the last char of the pasted text
        // (do_insert_str leaves it one past the end). `gp` instead
        // leaves the cursor just AFTER the pasted text, so skip the
        // step-back there.
        if !cursor_after && ed.cursor().1 > 0 {
            hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
        }
        // Charwise: `[` = insert start, `]` = last pasted char.
        let lo = (at.row, at.col);
        let hi = if cursor_after {
            let c = ed.cursor();
            (c.0, c.1.saturating_sub(1))
        } else {
            ed.cursor()
        };
        (lo, hi)
    };
    ed.set_mark('[', paste_mark.0);
    ed.set_mark(']', paste_mark.1);
    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
    if cursor_after && linewise {
        let bot_row = paste_mark.1.0;
        let last_row = buf_row_count(ed.buffer()).saturating_sub(1);
        let target = (bot_row + 1).min(last_row);
        buf_set_cursor_rc(ed.buffer_mut(), target, 0);
    } else if let Some(orig_row) = original_row_for_linewise_after {
        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
        // line (original_row + 1) — vim parity. The per-iteration loop
        // moves cursor to each paste's target_row, so without this reset
        // `5p` would land at original_row + 5 instead of original_row + 1.
        let first_target = orig_row.saturating_add(1);
        buf_set_cursor_rc(ed.buffer_mut(), first_target, 0);
        hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
    }
    // Any paste re-anchors the sticky column to the new cursor position.
    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
    true
}
/// Blockwise paste (`p`/`P` with a visual-block register). Re-inserts the
/// register's row segments as COLUMNS at the cursor — vim's true
/// block-paste geometry — rather than spilling each segment onto its own
/// new line. `count` repeats each segment horizontally.
///
/// Geometry (verified against nvim v0.12.4):
/// - `p` inserts starting at the column AFTER the cursor, `P` AT the
///   cursor column; segment row `i` lands on buffer row `cursor.row + i`.
/// - Rows past the buffer end are created; a target row shorter than the
///   insert column is padded with spaces up to it.
/// - Each segment is padded with trailing spaces to the register's block
///   `width` and then repeated `count` times — but ONLY when there is text
///   after the insert column on that row; at end-of-line no trailing
///   padding is added (matches nvim).
/// - The cursor lands on the top-left cell of the pasted block.
///
/// The caller (`do_paste`) has already pushed the undo checkpoint.
fn do_block_paste<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    before: bool,
    count: usize,
    width: usize,
    yank: &str,
) {
    ed.sync_buffer_content_from_textarea();
    let mut lines: Vec<String> =
        hjkl_engine::rope_util::rope_to_lines_vec(&hjkl_engine::types::Query::rope(ed.buffer()));
    // Detach ropey's phantom trailing empty line (present iff the buffer
    // ends with `\n`) so appending block rows past EOF doesn't consume the
    // trailing newline. Re-added after the join below.
    let trailing_nl = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
    if trailing_nl {
        lines.pop();
    }
    let cursor = buf_cursor_pos(ed.buffer());
    let start_row = cursor.row;
    // Insert column (char index). `P` inserts at the cursor; `p` after it.
    // On an empty line `p` has no char to sit after, so it inserts at col 0.
    let cur_len = lines.get(start_row).map_or(0, |l| l.chars().count());
    let insert_col = if before {
        cursor.col
    } else if cur_len == 0 {
        0
    } else {
        cursor.col + 1
    };
    let segments: Vec<&str> = yank.split('\n').collect();
    for (i, seg) in segments.iter().enumerate() {
        let row = start_row + i;
        // Rows past the buffer end are created as empty lines.
        while row >= lines.len() {
            lines.push(String::new());
        }
        let mut chars: Vec<char> = lines[row].chars().collect();
        // Pad the target row up to the insert column with spaces.
        if chars.len() < insert_col {
            chars.resize(insert_col, ' ');
        }
        let head: String = chars[..insert_col].iter().collect();
        let tail: String = chars[insert_col..].iter().collect();
        // Pad the segment to the block width only when it is followed by
        // text on this row — at EOL vim adds no trailing spaces.
        let piece = if tail.is_empty() {
            seg.repeat(count)
        } else {
            let seg_len = seg.chars().count();
            let mut padded = seg.to_string();
            if seg_len < width {
                padded.extend(std::iter::repeat_n(' ', width - seg_len));
            }
            padded.repeat(count)
        };
        lines[row] = format!("{head}{piece}{tail}");
    }
    let mut joined = lines.join("\n");
    if trailing_nl {
        joined.push('\n');
    }
    hjkl_engine::types::BufferEdit::replace_all(ed.buffer_mut(), &joined);
    ed.mark_content_dirty();
    // Cursor lands on the top-left cell of the pasted block.
    ed.jump_cursor(start_row, insert_col);
    // `[` / `]` span the pasted block's top-left .. bottom-left column.
    let bot_row = start_row + segments.len().saturating_sub(1);
    ed.set_mark('[', (start_row, insert_col));
    ed.set_mark(']', (bot_row, insert_col));
    ed.set_sticky_col(Some(insert_col));
}
/// Visual-mode `p` / `P` — replace the active selection with the register.
/// With `p` the deleted selection lands in the unnamed register (vim's swap);
/// with `P` (`before = true`) the source register is preserved so it can be
/// pasted over multiple selections in turn.
pub fn visual_paste<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    before: bool,
) {
    use hjkl_buffer::{Edit, Position};
    ed.sync_buffer_content_from_textarea();

    // Resolve the source register (selector or unnamed) BEFORE the delete
    // overwrites the unnamed register with the cut selection.
    let selector = vim_mut(ed).pending_register.take();
    // `"+p`/`"*p` in visual mode: same live-clipboard refresh as normal-mode
    // paste (audit-r2 fix 4).
    sync_clipboard_register_for(ed, selector);
    let (reg_text, reg_linewise, reg_blockwise, reg_block_width) =
        ed.with_registers(|regs| match selector.and_then(|c| regs.read(c)) {
            Some(slot) => (
                slot.text.clone(),
                slot.linewise,
                slot.blockwise,
                slot.block_width,
            ),
            None => (
                regs.unnamed.text.clone(),
                regs.unnamed.linewise,
                regs.unnamed.blockwise,
                regs.unnamed.block_width,
            ),
        });
    // For `P`, snapshot the unnamed register so we can restore it afterwards.
    let saved_unnamed = before.then(|| ed.with_registers(|regs| regs.unnamed.clone()));

    let mode = vim(ed).mode;
    ed.push_undo();

    match mode {
        Mode::VisualLine => {
            let cursor_row = buf_cursor_pos(ed.buffer()).row;
            let top = cursor_row.min(vim(ed).visual_line_anchor);
            let bot = cursor_row.max(vim(ed).visual_line_anchor);
            // Delete the selected lines into the unnamed register.
            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
            // Insert the register as fresh line(s) where the selection was.
            let text = reg_text.trim_matches('\n').to_string();
            let line_count = buf_row_count(ed.buffer());
            if top >= line_count {
                // Selection reached the end of the buffer: append below the
                // (new) last line.
                let last = line_count.saturating_sub(1);
                let lc = buf_line_chars(ed.buffer(), last);
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(last, lc),
                    text: format!("\n{text}"),
                });
                buf_set_cursor_rc(ed.buffer_mut(), last + 1, 0);
            } else {
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(top, 0),
                    text: format!("{text}\n"),
                });
                buf_set_cursor_rc(ed.buffer_mut(), top, 0);
            }
            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
        }
        Mode::Visual => {
            let anchor = vim(ed).visual_anchor;
            let cursor = ed.cursor();
            let (top, bot) = order(anchor, cursor);
            // Delete the selection into the unnamed register.
            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
            // Insert the register text where the selection started.
            if reg_linewise {
                // Linewise register into a charwise hole: open a line below.
                let text = reg_text.trim_matches('\n').to_string();
                let lc = buf_line_chars(ed.buffer(), top.0);
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(top.0, lc),
                    text: format!("\n{text}"),
                });
                buf_set_cursor_rc(ed.buffer_mut(), top.0 + 1, 0);
                hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
            } else {
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(top.0, top.1),
                    text: reg_text.clone(),
                });
                // Park the cursor on the last char of the inserted text.
                let inserted_len = reg_text.chars().count();
                let last_col = top.1 + inserted_len.saturating_sub(1);
                buf_set_cursor_rc(ed.buffer_mut(), top.0, last_col);
            }
        }
        Mode::VisualBlock => {
            // `p`/`P` over a VISUAL-BLOCK selection: delete the rectangle,
            // then put the source register according to its kind. Verified
            // against nvim v0.12.4 — each register type places differently:
            //   - blockwise reg → re-inserted as columns at the block's
            //     top-left, exactly like a normal-mode block paste.
            //   - linewise reg  → opened as fresh line(s) BELOW the block's
            //     bottom row (not inline).
            //   - single-line charwise reg → replicated at the block's LEFT
            //     column on EVERY row of the (now-deleted) block.
            //   - multi-line charwise reg → a plain inline charwise paste at
            //     the block's top-left (cursor parks at the paste start).
            let (top, bot, left, right) = block_bounds(ed);
            let to_eol = vim(ed).block_to_eol;
            // Snapshot the rectangle for the `p` swap register.
            let deleted = block_yank(ed, top, bot, left, right, to_eol);
            let del_width = if to_eol {
                deleted
                    .split('\n')
                    .map(|s| s.chars().count())
                    .max()
                    .unwrap_or(0)
            } else {
                right + 1 - left
            };
            delete_block_contents(ed, top, bot, left, right, to_eol);
            // `p` swaps the deleted block into the unnamed register (blockwise);
            // `P` preserves the source register (restored below via
            // `saved_unnamed`).
            if !before && !deleted.is_empty() {
                ed.record_yank_to_host(deleted.clone());
                ed.record_delete_block(deleted, del_width, None);
            }
            if reg_blockwise {
                ed.jump_cursor(top, left);
                // `before = true` makes `do_block_paste` insert AT `left`
                // (the block's now-vacated left column) rather than after it.
                do_block_paste(ed, true, 1, reg_block_width, &reg_text);
            } else if reg_linewise {
                let text = reg_text.trim_matches('\n').to_string();
                let lc = buf_line_chars(ed.buffer(), bot);
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(bot, lc),
                    text: format!("\n{text}"),
                });
                buf_set_cursor_rc(ed.buffer_mut(), bot + 1, 0);
                hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
            } else if reg_text.contains('\n') {
                ed.mutate_edit(Edit::InsertStr {
                    at: Position::new(top, left),
                    text: reg_text.clone(),
                });
                buf_set_cursor_rc(ed.buffer_mut(), top, left);
            } else {
                // Single-line charwise: replicate at the left column on every
                // block row. Rows shorter than `left` are SKIPPED (no
                // padding) — verified against nvim v0.12.4: pasting "d" over
                // a col-3 block whose middle row is only 2 chars leaves that
                // short row untouched.
                for r in top..=bot {
                    let line_len = buf_line_chars(ed.buffer(), r);
                    if left > line_len {
                        continue;
                    }
                    ed.mutate_edit(Edit::InsertStr {
                        at: Position::new(r, left),
                        text: reg_text.clone(),
                    });
                }
                let last_col = left + reg_text.chars().count().saturating_sub(1);
                buf_set_cursor_rc(ed.buffer_mut(), top, last_col);
            }
        }
        _ => {}
    }

    // `P` preserves the source register; restore the snapshot.
    if let Some(slot) = saved_unnamed {
        ed.with_registers_mut(|regs| regs.unnamed = slot);
    }
    vim_mut(ed).mode = Mode::Normal;
    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
}
/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
/// first number on each selected line. When `sequential` is true the increment
/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
/// first gets `delta`, the second `2*delta`, and so on.
pub fn adjust_number_visual<H: hjkl_engine::types::Host>(
    ed: &mut Editor<hjkl_buffer::View, H>,
    delta: i64,
    sequential: bool,
) {
    use hjkl_buffer::{Edit, MotionKind, Position};
    ed.sync_buffer_content_from_textarea();
    let mode = vim(ed).mode;
    let cursor = buf_cursor_pos(ed.buffer());

    // Resolve the row range + the per-row start column to scan from.
    let (top, bot, mut scan_col_first, block_left) = match mode {
        Mode::VisualLine => {
            let t = cursor.row.min(vim(ed).visual_line_anchor);
            let b = cursor.row.max(vim(ed).visual_line_anchor);
            (t, b, 0usize, None)
        }
        Mode::Visual => {
            let (a, c) = order(vim(ed).visual_anchor, (cursor.row, cursor.col));
            (a.0, c.0, a.1, None)
        }
        Mode::VisualBlock => {
            let (a, c) = order(vim(ed).block_anchor, (cursor.row, cursor.col));
            let left = a.1.min(c.1);
            (a.0, c.0, left, Some(left))
        }
        _ => return,
    };

    ed.push_undo();
    let mut found_count: i64 = 0;
    for row in top..=bot {
        let start_col = match block_left {
            Some(left) => left,
            None => {
                // First row of a charwise selection starts at the anchor/cursor
                // column; subsequent rows start at column 0.
                let c = if row == top { scan_col_first } else { 0 };
                scan_col_first = 0;
                c
            }
        };
        let chars: Vec<char> = match buf_line(ed.buffer(), row) {
            Some(l) => l.chars().collect(),
            None => continue,
        };
        // `g<C-a>` scales the increment by how many numbers have been adjusted
        // so far, so the delta for *this* row assumes the row yields one.
        // `found_count` only advances once `adjusted_number_at` confirms it did
        // — a row with no number, or with digits that fail to parse, must not
        // consume a step of the sequence.
        let this_delta = if sequential {
            delta.saturating_mul(found_count.saturating_add(1))
        } else {
            delta
        };
        let Some(AdjustedNumber {
            start: span_start,
            end: span_end,
            text: new_s,
        }) = adjusted_number_at(&chars, start_col, this_delta)
        else {
            continue;
        };
        found_count += 1;
        let span_start_pos = Position::new(row, span_start);
        let span_end_pos = Position::new(row, span_end);
        ed.mutate_edit(Edit::DeleteRange {
            start: span_start_pos,
            end: span_end_pos,
            kind: MotionKind::Char,
        });
        ed.mutate_edit(Edit::InsertStr {
            at: span_start_pos,
            text: new_s,
        });
    }
    // Vim leaves the cursor at the start of the selection.
    buf_set_cursor_rc(ed.buffer_mut(), top, block_left.unwrap_or(0));
    vim_mut(ed).mode = Mode::Normal;
    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
}
#[cfg(test)]
mod replace_char_tests {
    use hjkl_buffer::{View, rope_line_str};
    use hjkl_engine::{DefaultHost, Editor, Options};

    fn line(ed: &Editor<View, DefaultHost>, row: usize) -> String {
        rope_line_str(&ed.buffer().rope(), row)
    }

    #[test]
    fn replace_char_count_exceeding_line_replaces_nothing() {
        let buf = View::from_str("ab\ncd");
        let mut ed = crate::vim::vim_editor(buf, DefaultHost::new(), Options::default());
        // Cursor at (0,0); `3rx` needs 3 chars but the line has 2 — vim aborts
        // the whole command and replaces nothing (not a partial run).
        super::replace_char(&mut ed, 'x', 3);
        assert_eq!(line(&ed, 0), "ab", "partial replace must not happen");
        assert_eq!(line(&ed, 1), "cd", "must not spill onto the next line");
    }

    #[test]
    fn replace_char_count_fitting_replaces_run() {
        let buf = View::from_str("abc");
        let mut ed = crate::vim::vim_editor(buf, DefaultHost::new(), Options::default());
        super::replace_char(&mut ed, 'x', 2);
        assert_eq!(line(&ed, 0), "xxc");
    }
}
#[cfg(test)]
mod g_ampersand_tests {
    use super::*;
    use hjkl_buffer::{View, rope_line_str};
    use hjkl_engine::{DefaultHost, Editor, Options};

    fn make_editor(content: &str) -> Editor<View, DefaultHost> {
        let buf = View::from_str(content);
        let host = DefaultHost::new();
        crate::vim::vim_editor(buf, host, Options::default())
    }

    fn buf_line(ed: &Editor<View, DefaultHost>, row: usize) -> String {
        let rope = ed.buffer().rope();
        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
    }

    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
    /// match per line only).
    #[test]
    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
        let cmd = hjkl_engine::substitute::parse_substitute("/foo/bar/").unwrap();
        ed.set_last_substitute(cmd);
        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
        apply_after_g(&mut ed, '&', 1);
        assert_eq!(buf_line(&ed, 0), "bar");
        // No /g flag — only first match per line.
        assert_eq!(buf_line(&ed, 1), "bar bar foo");
        assert_eq!(buf_line(&ed, 2), "baz");
    }

    /// `g&` with /g flag replaces all matches per line.
    #[test]
    fn g_ampersand_with_g_flag_replaces_all_per_line() {
        let mut ed = make_editor("foo foo\nfoo");
        let cmd = hjkl_engine::substitute::parse_substitute("/foo/bar/g").unwrap();
        ed.set_last_substitute(cmd);
        apply_after_g(&mut ed, '&', 1);
        assert_eq!(buf_line(&ed, 0), "bar bar");
        assert_eq!(buf_line(&ed, 1), "bar");
    }

    /// `g&` with no prior substitute is a no-op.
    #[test]
    fn g_ampersand_noop_when_no_prior_substitute() {
        let mut ed = make_editor("foo\nbar");
        // No last_substitute set — must not panic, must not change buffer.
        apply_after_g(&mut ed, '&', 1);
        assert_eq!(buf_line(&ed, 0), "foo");
        assert_eq!(buf_line(&ed, 1), "bar");
    }
}