Skip to main content

hjkl_vim/vim/
command.rs

1//! Vim FSM: command.
2//!
3//! Split out of the monolithic `vim.rs` (#267 follow-up).
4
5use hjkl_vim_types::{LastChange, Mode, RangeKind};
6
7use hjkl_engine::rope_util::{rope_line_to_str, rope_row_range_str};
8
9use super::*;
10use crate::vim_state::{vim, vim_mut};
11use hjkl_engine::Editor;
12use hjkl_engine::buf_helpers::{
13    buf_cursor_pos, buf_line, buf_line_chars, buf_row_count, buf_set_cursor_pos, buf_set_cursor_rc,
14};
15
16/// Read the text in a vim-shaped range without mutating. Used by
17/// `Operator::Yank` so we can pipe the same range translation as
18/// [`cut_vim_range`] but skip the delete + inverse extraction.
19pub fn read_vim_range<H: hjkl_engine::types::Host>(
20    ed: &mut Editor<hjkl_buffer::View, H>,
21    start: (usize, usize),
22    end: (usize, usize),
23    kind: RangeKind,
24) -> String {
25    let (top, bot) = order(start, end);
26    ed.sync_buffer_content_from_textarea();
27    let rope = hjkl_engine::types::Query::rope(ed.buffer());
28    let n_lines = rope.len_lines();
29    match kind {
30        RangeKind::Linewise => {
31            let lo = top.0;
32            let hi = bot.0.min(n_lines.saturating_sub(1));
33            let mut text = rope_row_range_str(&rope, lo, hi);
34            text.push('\n');
35            text
36        }
37        RangeKind::Inclusive | RangeKind::Exclusive => {
38            let inclusive = matches!(kind, RangeKind::Inclusive);
39            // Walk row-by-row collecting chars in `[top, end_exclusive)`.
40            let mut out = String::new();
41            for row in top.0..=bot.0 {
42                if row >= n_lines {
43                    break;
44                }
45                let line = rope_line_to_str(&rope, row);
46                let lo = if row == top.0 { top.1 } else { 0 };
47                let hi_unclamped = if row == bot.0 {
48                    if inclusive { bot.1 + 1 } else { bot.1 }
49                } else {
50                    line.chars().count()
51                };
52                let row_chars: Vec<char> = line.chars().collect();
53                let hi = hi_unclamped.min(row_chars.len());
54                if lo < hi {
55                    out.push_str(&row_chars[lo..hi].iter().collect::<String>());
56                }
57                if row < bot.0 {
58                    out.push('\n');
59                }
60            }
61            out
62        }
63    }
64}
65/// Cut a vim-shaped range through the View edit funnel and return
66/// the deleted text. Translates vim's `RangeKind`
67/// (Linewise/Inclusive/Exclusive) into the buffer's
68/// `hjkl_buffer::MotionKind` (Line/Char) and applies the right end-
69/// position adjustment so inclusive motions actually include the bot
70/// cell. Pushes the cut text into the clipboard via `record_yank_to_host`
71/// and the textarea yank buffer (still observed by `p`/`P` until the paste
72/// path is ported), and updates `yank_linewise` for linewise cuts.
73pub fn cut_vim_range<H: hjkl_engine::types::Host>(
74    ed: &mut Editor<hjkl_buffer::View, H>,
75    start: (usize, usize),
76    end: (usize, usize),
77    kind: RangeKind,
78) -> String {
79    cut_vim_range_inner(ed, start, end, kind, true)
80}
81/// Shared implementation of [`cut_vim_range`]. With `record = false` the
82/// delete edit still happens and the deleted text is still returned, but no
83/// register and no clipboard is touched — vim's case operators (`gU`/`gu`/
84/// `g~`/`g?` and the visual `U`/`u`/`~`) transform text without recording
85/// anything (`:h gU`). The delete itself is load-bearing: the case-op
86/// re-insertion in
87/// [`apply_case_op_to_selection`](crate::vim::text_object_ops::apply_case_op_to_selection)
88/// consumes the delete's inverse edit.
89pub(super) fn cut_vim_range_inner<H: hjkl_engine::types::Host>(
90    ed: &mut Editor<hjkl_buffer::View, H>,
91    start: (usize, usize),
92    end: (usize, usize),
93    kind: RangeKind,
94    record: bool,
95) -> String {
96    use hjkl_buffer::{Edit, MotionKind as BufKind, Position};
97    let (top, bot) = order(start, end);
98    ed.sync_buffer_content_from_textarea();
99    let (buf_start, buf_end, buf_kind) = match kind {
100        RangeKind::Linewise => (
101            Position::new(top.0, 0),
102            Position::new(bot.0, 0),
103            BufKind::Line,
104        ),
105        RangeKind::Inclusive => {
106            let line_chars = buf_line_chars(ed.buffer(), bot.0);
107            // Advance one cell past `bot` so the buffer's exclusive
108            // `cut_chars` actually drops the inclusive endpoint. Wrap
109            // to the next row when bot already sits on the last char.
110            let next = if bot.1 < line_chars {
111                Position::new(bot.0, bot.1 + 1)
112            } else if bot.0 + 1 < buf_row_count(ed.buffer()) {
113                Position::new(bot.0 + 1, 0)
114            } else {
115                Position::new(bot.0, line_chars)
116            };
117            (Position::new(top.0, top.1), next, BufKind::Char)
118        }
119        RangeKind::Exclusive => (
120            Position::new(top.0, top.1),
121            Position::new(bot.0, bot.1),
122            BufKind::Char,
123        ),
124    };
125    let inverse = ed.mutate_edit(Edit::DeleteRange {
126        start: buf_start,
127        end: buf_end,
128        kind: buf_kind,
129    });
130    let raw_text = match inverse {
131        Edit::InsertStr { text, .. } => text,
132        _ => String::new(),
133    };
134    // Normalize linewise register text: vim always appends '\n' to
135    // linewise register content. The inverse from do_delete_range may
136    // produce text with a leading '\n' (last-line delete with rows
137    // above) or no newline at all (whole-buffer delete / empty buffer).
138    let text = if matches!(kind, RangeKind::Linewise) {
139        if raw_text.ends_with('\n') {
140            // Normal mid-buffer delete: trailing '\n' already correct.
141            raw_text
142        } else if raw_text.starts_with('\n') {
143            // Last-line delete with rows above: leading '\n' belongs
144            // at the end (vim register convention).
145            format!("{}\n", raw_text.strip_prefix('\n').unwrap())
146        } else if raw_text.is_empty() {
147            // The buffer reports an empty inverse only when this linewise
148            // operation removed nothing. Keep registers untouched.
149            return String::new();
150        } else {
151            // Whole-buffer delete: no newline in inverse text, append one.
152            format!("{raw_text}\n")
153        }
154    } else {
155        raw_text
156    };
157    if text.is_empty() {
158        // Non-linewise delete yielded no text — nothing to record.
159        return text;
160    }
161    if record {
162        ed.record_yank_to_host(text.clone());
163        let target = vim_mut(ed).pending_register.take();
164        ed.record_delete(text.clone(), matches!(kind, RangeKind::Linewise), target);
165    }
166    text
167}
168/// `D` / `C` — delete from cursor to end of line through the edit
169/// funnel. Pushes the deleted text to the clipboard via `record_yank_to_host`
170/// and the textarea's yank buffer (still observed by `p`/`P` until the paste
171/// path is ported). Cursor lands at the deletion start so the caller
172/// can decide whether to step it left (`D`) or open insert mode (`C`).
173pub fn delete_to_eol<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) {
174    use hjkl_buffer::{Edit, MotionKind, Position};
175    ed.sync_buffer_content_from_textarea();
176    let cursor = buf_cursor_pos(ed.buffer());
177    let line_chars = buf_line_chars(ed.buffer(), cursor.row);
178    if cursor.col >= line_chars {
179        return;
180    }
181    let inverse = ed.mutate_edit(Edit::DeleteRange {
182        start: cursor,
183        end: Position::new(cursor.row, line_chars),
184        kind: MotionKind::Char,
185    });
186    if let Edit::InsertStr { text, .. } = inverse
187        && !text.is_empty()
188    {
189        ed.record_yank_to_host(text.clone());
190        ed.set_yank_linewise(false);
191        // `record_delete`, not `set_yank`: `"aD` / `"aC` must write the named
192        // register, and an unnamed `D` is a small delete, so vim also puts it
193        // in `"-`. `set_yank` reached neither.
194        let target = vim_mut(ed).pending_register.take();
195        ed.record_delete(text, false, target);
196    }
197    buf_set_cursor_pos(ed.buffer_mut(), cursor);
198}
199pub fn do_char_delete<H: hjkl_engine::types::Host>(
200    ed: &mut Editor<hjkl_buffer::View, H>,
201    forward: bool,
202    count: usize,
203) {
204    use hjkl_buffer::{Edit, MotionKind, Position};
205    ed.push_undo();
206    ed.sync_buffer_content_from_textarea();
207    // Collect deleted chars so we can write them to the unnamed register
208    // (vim's `x`/`X` populate `"` so that `xp` round-trips the char).
209    let mut deleted = String::new();
210    for _ in 0..count {
211        let cursor = buf_cursor_pos(ed.buffer());
212        let line_chars = buf_line_chars(ed.buffer(), cursor.row);
213        if forward {
214            // `x` — delete the char under the cursor. Vim no-ops on
215            // an empty line; the buffer would drop a row otherwise.
216            // `break`, not `continue`: the cursor can't regress below
217            // EOL, so remaining iterations would spin uselessly (a
218            // saturated count prefix would hang the editor).
219            if cursor.col >= line_chars {
220                break;
221            }
222            let inverse = ed.mutate_edit(Edit::DeleteRange {
223                start: cursor,
224                end: Position::new(cursor.row, cursor.col + 1),
225                kind: MotionKind::Char,
226            });
227            if let Edit::InsertStr { text, .. } = inverse {
228                deleted.push_str(&text);
229            }
230        } else {
231            // `X` — delete the char before the cursor. `break` for the
232            // same no-further-progress reason as the `x` arm above.
233            if cursor.col == 0 {
234                break;
235            }
236            let inverse = ed.mutate_edit(Edit::DeleteRange {
237                start: Position::new(cursor.row, cursor.col - 1),
238                end: cursor,
239                kind: MotionKind::Char,
240            });
241            if let Edit::InsertStr { text, .. } = inverse {
242                // X deletes backwards; prepend so the register text
243                // matches reading order (first deleted char first).
244                deleted = text + &deleted;
245            }
246        }
247    }
248    if !deleted.is_empty() {
249        ed.record_yank_to_host(deleted.clone());
250        let target = vim_mut(ed).pending_register.take();
251        ed.record_delete(deleted, false, target);
252    }
253    // B11: `x` deleting the last char(s) of a line can leave the cursor one
254    // past the new end — vim clamps to the new last column in Normal mode.
255    let cursor = buf_cursor_pos(ed.buffer());
256    let line_chars = buf_line_chars(ed.buffer(), cursor.row);
257    if line_chars > 0 && cursor.col >= line_chars {
258        buf_set_cursor_pos(ed.buffer_mut(), Position::new(cursor.row, line_chars - 1));
259    }
260}
261/// A number located on one line, with its post-`delta` text already formatted.
262///
263/// `start..end` are char indices into the line the span was found on; replacing
264/// that range with `text` performs the adjustment.
265struct AdjustedNumber {
266    start: usize,
267    end: usize,
268    text: String,
269}
270
271/// Find the leftmost number at or after char index `from` on `chars`, add
272/// `delta`, and format the result the way vim does.
273///
274/// A `0x`/`0X` hex literal wins over a bare decimal starting at the same index.
275/// Returns `None` when the rest of the line holds no number, or when the digits
276/// found do not fit the parse type — both cases mean "leave the line alone".
277///
278/// This is the single implementation shared by normal-mode
279/// [`adjust_number`] and visual-mode [`adjust_number_visual`]; keeping one
280/// copy is what stops the two modes from drifting on padding and digit case.
281fn adjusted_number_at(chars: &[char], from: usize, delta: i64) -> Option<AdjustedNumber> {
282    let len = chars.len();
283    let is_hex_prefix = |i: usize| {
284        chars[i] == '0'
285            && matches!(chars.get(i + 1), Some('x' | 'X'))
286            && chars.get(i + 2).is_some_and(|c| c.is_ascii_hexdigit())
287    };
288    let start = (from.min(len)..len).find(|&i| is_hex_prefix(i) || chars[i].is_ascii_digit())?;
289
290    if is_hex_prefix(start) {
291        // `0x` + hex digits. Increment in hex, preserve the digit width.
292        let digits_start = start + 2;
293        let mut digits_end = digits_start;
294        while digits_end < len && chars[digits_end].is_ascii_hexdigit() {
295            digits_end += 1;
296        }
297        let digits: String = chars[digits_start..digits_end].iter().collect();
298        let n = u64::from_str_radix(&digits, 16).ok()?;
299        let new_val = (n as i128 + delta as i128).max(0) as u64;
300        let width = digits_end - digits_start;
301        let prefix: String = chars[start..digits_start].iter().collect();
302        // Vim picks the output's letter case from the *last* letter digit of
303        // the original ("0xaB" <C-a> -> "0xAC", "0xAb" -> "0xac"). With no
304        // letter digit to go by it falls back to the `x`/`X` prefix's own case
305        // ("0X19" <C-a> -> "0X1A", "0x19" -> "0x1a").
306        let upper = digits
307            .chars()
308            .rev()
309            .find(|c| c.is_ascii_alphabetic())
310            .map_or(chars[start + 1] == 'X', |c| c.is_ascii_uppercase());
311        let text = if upper {
312            format!("{prefix}{new_val:0width$X}")
313        } else {
314            format!("{prefix}{new_val:0width$x}")
315        };
316        return Some(AdjustedNumber {
317            start,
318            end: digits_end,
319            text,
320        });
321    }
322
323    // Signed decimal. A leading `-` immediately before the digits is part of
324    // the number.
325    let span_start = if start > 0 && chars[start - 1] == '-' {
326        start - 1
327    } else {
328        start
329    };
330    let mut span_end = start;
331    while span_end < len && chars[span_end].is_ascii_digit() {
332        span_end += 1;
333    }
334    let s: String = chars[span_start..span_end].iter().collect();
335    let n = s.parse::<i64>().ok()?;
336    let new_val = n as i128 + delta as i128;
337    // Vim zero-pads the result back to the original digit width, but only when
338    // the original number actually had a leading zero (`:h CTRL-A`): "10"
339    // <C-x> -> "9", not "09"; "007" <C-x> -> "006". The `-` sign is never part
340    // of the padded width — "-007" <C-a> -> "-006", and crossing zero into
341    // negative still pads the digits ("009" 20<C-x> -> "-011").
342    let digits: String = chars[start..span_end].iter().collect();
343    let width = digits.len();
344    let text = if width > 1 && digits.starts_with('0') {
345        if new_val < 0 {
346            let mag = new_val.unsigned_abs();
347            format!("-{mag:0width$}")
348        } else {
349            format!("{new_val:0width$}")
350        }
351    } else {
352        new_val.to_string()
353    };
354    Some(AdjustedNumber {
355        start: span_start,
356        end: span_end,
357        text,
358    })
359}
360
361/// Vim `Ctrl-a` / `Ctrl-x` — find the next number at or after the cursor on the
362/// current line, add `delta`, leave the cursor on the last digit of the result.
363/// Recognises `0x`/`0X` hex literals (incremented in hex, width and digit case
364/// preserved) as well as signed decimals. No-op if the line has no number to
365/// the right.
366pub fn adjust_number<H: hjkl_engine::types::Host>(
367    ed: &mut Editor<hjkl_buffer::View, H>,
368    delta: i64,
369) -> bool {
370    use hjkl_buffer::{Edit, MotionKind, Position};
371    ed.sync_buffer_content_from_textarea();
372    let cursor = buf_cursor_pos(ed.buffer());
373    let row = cursor.row;
374    let chars: Vec<char> = match buf_line(ed.buffer(), row) {
375        Some(l) => l.chars().collect(),
376        None => return false,
377    };
378    let Some(AdjustedNumber {
379        start: span_start,
380        end: span_end,
381        text: new_s,
382    }) = adjusted_number_at(&chars, cursor.col, delta)
383    else {
384        return false;
385    };
386
387    ed.push_undo();
388    let span_start_pos = Position::new(row, span_start);
389    let span_end_pos = Position::new(row, span_end);
390    ed.mutate_edit(Edit::DeleteRange {
391        start: span_start_pos,
392        end: span_end_pos,
393        kind: MotionKind::Char,
394    });
395    ed.mutate_edit(Edit::InsertStr {
396        at: span_start_pos,
397        text: new_s.clone(),
398    });
399    let new_len = new_s.chars().count();
400    buf_set_cursor_rc(ed.buffer_mut(), row, span_start + new_len.saturating_sub(1));
401    true
402}
403pub fn replace_char<H: hjkl_engine::types::Host>(
404    ed: &mut Editor<hjkl_buffer::View, H>,
405    ch: char,
406    count: usize,
407) {
408    use hjkl_buffer::{Edit, MotionKind, Position};
409    ed.sync_buffer_content_from_textarea();
410    // Vim aborts `r{count}{char}` entirely — replacing nothing — when fewer
411    // than `count` characters remain from the cursor to end-of-line, rather
412    // than replacing a partial run. Check before touching undo/the buffer.
413    let start = buf_cursor_pos(ed.buffer());
414    let start_line_chars = buf_line_chars(ed.buffer(), start.row);
415    if count == 0 || start.col + count > start_line_chars {
416        return;
417    }
418    ed.push_undo();
419    for _ in 0..count {
420        let cursor = buf_cursor_pos(ed.buffer());
421        let line_chars = buf_line_chars(ed.buffer(), cursor.row);
422        if cursor.col >= line_chars {
423            break;
424        }
425        ed.mutate_edit(Edit::DeleteRange {
426            start: cursor,
427            end: Position::new(cursor.row, cursor.col + 1),
428            kind: MotionKind::Char,
429        });
430        ed.mutate_edit(Edit::InsertChar { at: cursor, ch });
431    }
432    // Vim leaves the cursor on the last replaced char.
433    hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
434}
435/// Returns `false` when there is no char under the cursor to toggle
436/// (end of line / empty line) so counted loops can stop instead of
437/// spinning through a saturated count prefix.
438pub fn toggle_case_at_cursor<H: hjkl_engine::types::Host>(
439    ed: &mut Editor<hjkl_buffer::View, H>,
440) -> bool {
441    use hjkl_buffer::{Edit, MotionKind, Position};
442    ed.sync_buffer_content_from_textarea();
443    let cursor = buf_cursor_pos(ed.buffer());
444    let Some(c) = buf_line(ed.buffer(), cursor.row).and_then(|l| l.chars().nth(cursor.col)) else {
445        return false;
446    };
447    let toggled = if c.is_uppercase() {
448        c.to_lowercase().collect::<String>()
449    } else {
450        c.to_uppercase().collect::<String>()
451    };
452    ed.mutate_edit(Edit::DeleteRange {
453        start: cursor,
454        end: Position::new(cursor.row, cursor.col + 1),
455        kind: MotionKind::Char,
456    });
457    ed.mutate_edit(Edit::InsertStr {
458        at: cursor,
459        text: toggled,
460    });
461    true
462}
463/// Returns `false` when the cursor is on the last line (nothing to
464/// join) so counted loops can stop instead of spinning.
465pub fn join_line<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) -> bool {
466    use hjkl_buffer::{Edit, Position};
467    ed.sync_buffer_content_from_textarea();
468    let row = buf_cursor_pos(ed.buffer()).row;
469    let n_rows = buf_row_count(ed.buffer());
470    if row + 1 >= n_rows {
471        return false;
472    }
473    // Vim does not join with ropey's phantom trailing empty row (the
474    // ropey representation of a trailing newline).  If the only row
475    // below is the phantom, treat it as "no line below" and abort.
476    if row + 2 >= n_rows && buf_line(ed.buffer(), row + 1).is_some_and(|s| s.is_empty()) {
477        return false;
478    }
479    let cur_line = buf_line(ed.buffer(), row).unwrap_or_default();
480    let next_raw = buf_line(ed.buffer(), row + 1).unwrap_or_default();
481    let next_trimmed = next_raw.trim_start();
482    let cur_chars = cur_line.chars().count();
483    let next_chars = next_raw.chars().count();
484    // `J` inserts a single space iff both sides are non-empty after
485    // stripping the next line's leading whitespace.
486    let separator = if !cur_line.is_empty()
487        && !next_trimmed.is_empty()
488        && !cur_line.ends_with([' ', '\t'])
489        && !next_trimmed.starts_with(')')
490    {
491        " "
492    } else {
493        ""
494    };
495    let joined = format!("{cur_line}{separator}{next_trimmed}");
496    ed.mutate_edit(Edit::Replace {
497        start: Position::new(row, 0),
498        end: Position::new(row + 1, next_chars),
499        with: joined,
500    });
501    // Vim parks the cursor on the inserted space — or at the join
502    // point when no space went in (which is the same column either
503    // way, since the space sits exactly at `cur_chars`).
504    buf_set_cursor_rc(ed.buffer_mut(), row, cur_chars);
505    true
506}
507/// `gJ` — join the next line onto the current one without inserting a
508/// separating space or stripping leading whitespace.
509/// Returns `false` when the cursor is on the last line. See [`join_line`].
510pub fn join_line_raw<H: hjkl_engine::types::Host>(ed: &mut Editor<hjkl_buffer::View, H>) -> bool {
511    use hjkl_buffer::Edit;
512    ed.sync_buffer_content_from_textarea();
513    let row = buf_cursor_pos(ed.buffer()).row;
514    let n_rows = buf_row_count(ed.buffer());
515    if row + 1 >= n_rows {
516        return false;
517    }
518    // Same phantom-row guard as `join_line` — see there for rationale.
519    if row + 2 >= n_rows && buf_line(ed.buffer(), row + 1).is_some_and(|s| s.is_empty()) {
520        return false;
521    }
522    let join_col = buf_line_chars(ed.buffer(), row);
523    ed.mutate_edit(Edit::JoinLines {
524        row,
525        count: 1,
526        with_space: false,
527    });
528    // Vim leaves the cursor at the join point (end of original line).
529    buf_set_cursor_rc(ed.buffer_mut(), row, join_col);
530    true
531}
532/// Visual-mode `J` (`with_space = true`) / `gJ` (`with_space = false`) — join
533/// every line spanned by the selection into one. A single-line selection joins
534/// the current line with the one below (matching normal-mode `J`).
535pub fn visual_join<H: hjkl_engine::types::Host>(
536    ed: &mut Editor<hjkl_buffer::View, H>,
537    with_space: bool,
538) {
539    let cursor_row = buf_cursor_pos(ed.buffer()).row;
540    let (top, bot) = match vim(ed).mode {
541        Mode::VisualLine => (
542            cursor_row.min(vim(ed).visual_line_anchor),
543            cursor_row.max(vim(ed).visual_line_anchor),
544        ),
545        Mode::VisualBlock => {
546            let a = vim(ed).block_anchor.0;
547            (a.min(cursor_row), a.max(cursor_row))
548        }
549        Mode::Visual => {
550            let a = vim(ed).visual_anchor.0;
551            (a.min(cursor_row), a.max(cursor_row))
552        }
553        _ => return,
554    };
555    // N selected lines → N-1 joins; a single line still does one join (with the
556    // line below) like normal-mode `J`.
557    let joins = (bot - top).max(1);
558    ed.push_undo();
559    buf_set_cursor_rc(ed.buffer_mut(), top, 0);
560    for _ in 0..joins {
561        let joined = if with_space {
562            join_line(ed)
563        } else {
564            join_line_raw(ed)
565        };
566        if !joined {
567            break;
568        }
569    }
570    // B1: visual `J`/`gJ` is exactly `[joins + 1]J`/`gJ` from the top row —
571    // reuse the existing `JoinLine` dot-repeat entry (`:h v_J`) rather than
572    // adding a bespoke visual variant; replay already starts at whatever
573    // row the cursor is on, same as this function does via the
574    // `buf_set_cursor_rc` above.
575    if !vim(ed).replaying {
576        vim_mut(ed).last_change = Some(LastChange::JoinLine { count: joins });
577    }
578    vim_mut(ed).mode = Mode::Normal;
579    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
580}
581/// `[count]%` — go to the line at `count` percent of the file (vim: line
582/// `(count * line_count + 99) / 100`), cursor on the first non-blank.
583pub fn goto_percent<H: hjkl_engine::types::Host>(
584    ed: &mut Editor<hjkl_buffer::View, H>,
585    count: usize,
586) {
587    let rows = buf_row_count(ed.buffer());
588    if rows == 0 {
589        return;
590    }
591    // Exclude the phantom trailing empty line (a file ending in `\n` is N lines
592    // in vim, not N+1) so the percentage matches nvim.
593    let total = if rows >= 2 && buf_line(ed.buffer(), rows - 1).is_some_and(|s| s.is_empty()) {
594        rows - 1
595    } else {
596        rows
597    };
598    // 1-based target line, clamped to the buffer (vim: ceil(count*lines/100)).
599    // Saturating: a pathological count prefix (e.g. 20 typed digits) must not
600    // overflow the multiply; the clamp below caps the result at `total` anyway.
601    let line = count.saturating_mul(total).div_ceil(100).clamp(1, total);
602    let pre = ed.cursor();
603    ed.jump_cursor(line - 1, 0);
604    move_first_non_whitespace(ed);
605    ed.set_sticky_col(Some(ed.cursor().1));
606    if ed.cursor() != pre {
607        ed.push_jump(pre);
608    }
609}
610/// Indent width of a leading-whitespace prefix, counting a `\t` as advancing
611/// to the next `tabstop` boundary and a space as one column.
612pub fn indent_width(s: &str, tabstop: usize) -> usize {
613    let ts = tabstop.max(1);
614    let mut w = 0usize;
615    for c in s.chars() {
616        match c {
617            ' ' => w += 1,
618            '\t' => w += ts - (w % ts),
619            _ => break,
620        }
621    }
622    w
623}
624/// Build a leading-whitespace string of `width` columns honoring `expandtab`
625/// (spaces) vs `noexpandtab` (tabs for full `tabstop` runs, spaces remainder).
626pub fn build_indent(width: usize, settings: &hjkl_engine::Settings) -> String {
627    if settings.expandtab {
628        return " ".repeat(width);
629    }
630    let ts = settings.tabstop.max(1);
631    let tabs = width / ts;
632    let spaces = width % ts;
633    format!("{}{}", "\t".repeat(tabs), " ".repeat(spaces))
634}
635/// `]p` / `[p` reindent: shift every line of `text` so the FIRST line's indent
636/// matches `target_width` columns; later lines keep their relative offset.
637pub fn reindent_block(text: &str, target_width: usize, settings: &hjkl_engine::Settings) -> String {
638    let ts = settings.tabstop.max(1);
639    let lines: Vec<&str> = text.split('\n').collect();
640    let first_width = lines.first().map_or(0, |l| indent_width(l, ts));
641    let delta = target_width as isize - first_width as isize;
642    lines
643        .iter()
644        .map(|line| {
645            let trimmed = line.trim_start_matches([' ', '\t']);
646            if trimmed.is_empty() {
647                // Preserve blank lines as truly empty (vim does not indent them).
648                return String::new();
649            }
650            let old_w = indent_width(line, ts) as isize;
651            let new_w = (old_w + delta).max(0) as usize;
652            format!("{}{}", build_indent(new_w, settings), trimmed)
653        })
654        .collect::<Vec<_>>()
655        .join("\n")
656}
657/// Upper bound on the bytes one `p` / `P` may insert, counting the `count`
658/// prefix's multiplication. A count can request terabytes (`yy` then
659/// `999999999p`), so a bound has to exist; what it must not do is refuse an
660/// ordinary large yank.
661///
662/// Measured on the batched paste path (release, Linux; glibc and mimalloc
663/// within noise of each other): peak RSS is linear in payload at ~3.1x
664/// charwise, ~4.1x linewise and ~2.9x blockwise, and independent of document
665/// size. At this value a paste costs ~208 MiB peak and ~73 ms charwise, an
666/// order of magnitude under the 2 GiB ceiling the weekly cron fuzz job runs
667/// with — under that ceiling the batched path only aborts past a 448 MiB
668/// payload. It also matches `hjkl_fs::read::BODY`, so pasting is no longer
669/// stricter than opening a file of the same size.
670///
671/// The 1 MiB this replaced was derived from the pre-batching implementation,
672/// whose cost tracked iteration count rather than payload bytes.
673pub const MAX_PASTE_BYTES: usize = 64 * 1024 * 1024;
674
675/// Queue vim's own out-of-memory message for a paste that would exceed
676/// [`MAX_PASTE_BYTES`]. `bytes` is what the user asked for, not the cap.
677fn reject_oversized_paste<H: hjkl_engine::types::Host>(
678    ed: &mut Editor<hjkl_buffer::View, H>,
679    bytes: usize,
680) {
681    ed.push_error(format!("E342: Out of memory!  (allocating {bytes} bytes)"));
682}
683
684pub fn do_paste<H: hjkl_engine::types::Host>(
685    ed: &mut Editor<hjkl_buffer::View, H>,
686    before: bool,
687    count: usize,
688    cursor_after: bool,
689    reindent: bool,
690) -> bool {
691    use hjkl_buffer::{Edit, Position};
692    // Resolve the source register: `"reg` prefix (consumed) or the
693    // unnamed register otherwise. Read text + linewise from the
694    // selected slot rather than the global `vim.yank_linewise` so
695    // pasting from `"0` after a delete still uses the yank's layout.
696    let selector = vim_mut(ed).pending_register.take();
697    // `"+p`/`"*p`: refresh the register slot from the live OS clipboard
698    // before reading it below (audit-r2 fix 4) — otherwise this reads
699    // whatever the internal slot last had from an in-editor `"+y`.
700    sync_clipboard_register_for(ed, selector);
701    let (yank, linewise, blockwise, block_width) =
702        ed.with_registers(|regs| match selector.and_then(|c| regs.read(c)) {
703            Some(slot) => (
704                slot.text.clone(),
705                slot.linewise,
706                slot.blockwise,
707                slot.block_width,
708            ),
709            // Read both fields from the unnamed slot rather than mixing the
710            // slot's text with `vim.yank_linewise`. The cached vim flag is
711            // per-editor, so a register imported from another editor (e.g.
712            // cross-buffer yank/paste) carried the wrong linewise without
713            // this — pasting a linewise yank inserted at the char cursor.
714            None => (
715                regs.unnamed.text.clone(),
716                regs.unnamed.linewise,
717                regs.unnamed.blockwise,
718                regs.unnamed.block_width,
719            ),
720        });
721    // Vim `:h '[` / `:h ']`: after paste `[` = first inserted char of
722    // the final paste, `]` = last inserted char of the final paste.
723    // We track (lo, hi) across iterations; the last value wins.
724    // Capture the cursor row before any paste iterations. Vim's
725    // linewise `[count]p` lands the cursor on the FIRST pasted line
726    // (original_row + 1), not on the last iteration's paste row.
727    // Without this snapshot the per-iteration cursor advancement leaves
728    // the cursor at `original_row + count` instead.
729    let original_row_for_linewise_after = if linewise && !before {
730        // Fold-aware: `p` on a closed fold pastes after the fold, so the first
731        // pasted line is `fold_end + 1`, not `cursor_row + 1`.
732        let r = buf_cursor_pos(ed.buffer()).row;
733        let (_, fold_end) = expand_linewise_over_closed_folds(ed.buffer(), r, r);
734        Some(fold_end)
735    } else {
736        None
737    };
738    // Empty register: nothing to paste on any iteration — bail before the
739    // loop instead of `continue`-spinning through a huge count prefix.
740    if yank.is_empty() {
741        return false;
742    }
743    // Bound requested source bytes before allocating or opening an undo entry.
744    // A rejection here used to be indistinguishable from an empty register:
745    // `do_paste` returned `false` and nothing downstream could tell the two
746    // apart. It reports vim's own code for the case now, through the engine's
747    // error queue (`apps/hjkl` drains it after each key). An arithmetic
748    // overflow is reported as the budget it would have blown rather than as a
749    // separate condition — the user's ask is the same size either way.
750    let Some(requested_bytes) = yank.len().checked_mul(count) else {
751        reject_oversized_paste(ed, yank.len().saturating_mul(count));
752        return false;
753    };
754    if requested_bytes > MAX_PASTE_BYTES {
755        reject_oversized_paste(ed, requested_bytes);
756        return false;
757    }
758    if blockwise {
759        let Some(block_bytes) = block_width
760            .checked_mul(yank.split('\n').count())
761            .and_then(|bytes| bytes.checked_mul(count))
762        else {
763            reject_oversized_paste(ed, usize::MAX);
764            return false;
765        };
766        let Some(total_bytes) = requested_bytes.checked_add(block_bytes) else {
767            reject_oversized_paste(ed, usize::MAX);
768            return false;
769        };
770        if total_bytes > MAX_PASTE_BYTES {
771            reject_oversized_paste(ed, total_bytes);
772            return false;
773        }
774    }
775    ed.push_undo();
776    // Blockwise register (`<C-v>` yank/delete): re-insert the row segments
777    // as columns at the cursor. Handles its own cursor / marks / sticky
778    // column, so return before the charwise/linewise loop below.
779    if blockwise {
780        do_block_paste(ed, before, count, block_width, &yank);
781        return true;
782    }
783    // Charwise pastes insert the register text repeated `count` times as a
784    // single block. Linewise pastes likewise construct one multi-line edit:
785    // applying a separate edit per copy magnifies undo and allocation costs.
786    let paste_mark = if linewise {
787        ed.sync_buffer_content_from_textarea();
788        let row = buf_cursor_pos(ed.buffer()).row;
789        let mut text = yank.trim_matches('\n').to_string();
790        if reindent {
791            let cur_line = buf_line(ed.buffer(), row).unwrap_or_default();
792            let target_w = indent_width(&cur_line, ed.settings().tabstop.max(1));
793            text = reindent_block(&text, target_w, ed.settings());
794        }
795        let (fold_start, fold_end) = expand_linewise_over_closed_folds(ed.buffer(), row, row);
796        let payload = std::iter::repeat_n(text.as_str(), count)
797            .collect::<Vec<_>>()
798            .join("\n");
799        let target_row = if before {
800            ed.mutate_edit(Edit::InsertStr {
801                at: Position::new(fold_start, 0),
802                text: format!("{payload}\n"),
803            });
804            fold_start
805        } else {
806            let line_chars = buf_line_chars(ed.buffer(), fold_end);
807            ed.mutate_edit(Edit::InsertStr {
808                at: Position::new(fold_end, line_chars),
809                text: format!("\n{payload}"),
810            });
811            fold_end + 1
812        };
813        buf_set_cursor_rc(ed.buffer_mut(), target_row, 0);
814        hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
815        let payload_lines = payload.lines().count().max(1);
816        let bot_row = target_row + payload_lines - 1;
817        let bot_last_col = buf_line_chars(ed.buffer(), bot_row).saturating_sub(1);
818        ((target_row, 0), (bot_row, bot_last_col))
819    } else {
820        ed.sync_buffer_content_from_textarea();
821        // Charwise paste. `P` inserts at cursor (shifting cell
822        // right); `p` inserts after cursor (advance one cell first,
823        // clamped to the end of the line).
824        let cursor = buf_cursor_pos(ed.buffer());
825        let at = if before {
826            cursor
827        } else {
828            let line_chars = buf_line_chars(ed.buffer(), cursor.row);
829            Position::new(cursor.row, (cursor.col + 1).min(line_chars))
830        };
831        let repeated = yank.repeat(count);
832        ed.mutate_edit(Edit::InsertStr { at, text: repeated });
833        // Vim parks the cursor on the last char of the pasted text
834        // (do_insert_str leaves it one past the end). `gp` instead
835        // leaves the cursor just AFTER the pasted text, so skip the
836        // step-back there.
837        if !cursor_after && ed.cursor().1 > 0 {
838            hjkl_engine::motions::move_left(ed.buffer_mut(), 1);
839        }
840        // Charwise: `[` = insert start, `]` = last pasted char.
841        let lo = (at.row, at.col);
842        let hi = if cursor_after {
843            let c = ed.cursor();
844            (c.0, c.1.saturating_sub(1))
845        } else {
846            ed.cursor()
847        };
848        (lo, hi)
849    };
850    ed.set_mark('[', paste_mark.0);
851    ed.set_mark(']', paste_mark.1);
852    // `gp` / `gP` linewise: cursor lands on the line just AFTER the pasted
853    // block (the `]` mark's row + 1), at column 0, clamped to the last row.
854    if cursor_after && linewise {
855        let bot_row = paste_mark.1.0;
856        let last_row = buf_row_count(ed.buffer()).saturating_sub(1);
857        let target = (bot_row + 1).min(last_row);
858        buf_set_cursor_rc(ed.buffer_mut(), target, 0);
859    } else if let Some(orig_row) = original_row_for_linewise_after {
860        // Linewise `p` (after) with count: cursor lands on the FIRST pasted
861        // line (original_row + 1) — vim parity. The per-iteration loop
862        // moves cursor to each paste's target_row, so without this reset
863        // `5p` would land at original_row + 5 instead of original_row + 1.
864        let first_target = orig_row.saturating_add(1);
865        buf_set_cursor_rc(ed.buffer_mut(), first_target, 0);
866        hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
867    }
868    // Any paste re-anchors the sticky column to the new cursor position.
869    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
870    true
871}
872/// Blockwise paste (`p`/`P` with a visual-block register). Re-inserts the
873/// register's row segments as COLUMNS at the cursor — vim's true
874/// block-paste geometry — rather than spilling each segment onto its own
875/// new line. `count` repeats each segment horizontally.
876///
877/// Geometry (verified against nvim v0.12.4):
878/// - `p` inserts starting at the column AFTER the cursor, `P` AT the
879///   cursor column; segment row `i` lands on buffer row `cursor.row + i`.
880/// - Rows past the buffer end are created; a target row shorter than the
881///   insert column is padded with spaces up to it.
882/// - Each segment is padded with trailing spaces to the register's block
883///   `width` and then repeated `count` times — but ONLY when there is text
884///   after the insert column on that row; at end-of-line no trailing
885///   padding is added (matches nvim).
886/// - The cursor lands on the top-left cell of the pasted block.
887///
888/// The caller (`do_paste`) has already pushed the undo checkpoint.
889fn do_block_paste<H: hjkl_engine::types::Host>(
890    ed: &mut Editor<hjkl_buffer::View, H>,
891    before: bool,
892    count: usize,
893    width: usize,
894    yank: &str,
895) {
896    use hjkl_buffer::{Edit, Position};
897    ed.sync_buffer_content_from_textarea();
898    let cursor = buf_cursor_pos(ed.buffer());
899    let start_row = cursor.row;
900    // Insert column (char index). `P` inserts at the cursor; `p` after it.
901    // On an empty line `p` has no char to sit after, so it inserts at col 0.
902    let cur_len = buf_line_chars(ed.buffer(), start_row);
903    let insert_col = if before {
904        cursor.col
905    } else if cur_len == 0 {
906        0
907    } else {
908        cursor.col + 1
909    };
910    let segments: Vec<&str> = yank.split('\n').collect();
911    // `Edit::InsertBlock` splices into rows that already exist and skips any
912    // row past the end of the rope, so the rows a block paste extends the
913    // buffer by have to be opened first. One `InsertStr` of the bare line
914    // breaks does that; both edits sit inside the single undo entry
915    // `do_paste` already pushed, so the paste stays one undo step.
916    let last_row = start_row + segments.len().saturating_sub(1);
917    let raw_rows = buf_row_count(ed.buffer());
918    // ropey reports a phantom trailing empty line whenever the buffer ends in
919    // a newline. That line is the terminator, not a row a block may be pasted
920    // into: splicing a segment there consumes the final newline and the buffer
921    // silently stops ending in one. Anchor the new rows on the last row of
922    // real content instead, which leaves the terminator past them.
923    let trailing_nl = raw_rows > 1 && buf_line_chars(ed.buffer(), raw_rows - 1) == 0;
924    let content_rows = if trailing_nl { raw_rows - 1 } else { raw_rows };
925    if last_row >= content_rows {
926        let anchor = content_rows.saturating_sub(1);
927        let at = Position::new(anchor, buf_line_chars(ed.buffer(), anchor));
928        ed.mutate_edit(Edit::InsertStr {
929            at,
930            text: "\n".repeat(last_row + 1 - content_rows),
931        });
932    }
933    // Build one chunk per row. `do_insert_block` space-pads a row shorter
934    // than `insert_col` itself and records the pad width on the inverse, so
935    // the ragged-row case needs no rewriting of the row here.
936    let chunks: Vec<String> = segments
937        .iter()
938        .enumerate()
939        .map(|(i, seg)| {
940            // Pad the segment to the block width only when it is followed by
941            // text on this row — at EOL vim adds no trailing spaces.
942            let tail_is_empty = buf_line_chars(ed.buffer(), start_row + i) <= insert_col;
943            if tail_is_empty {
944                return seg.repeat(count);
945            }
946            let seg_len = seg.chars().count();
947            let mut padded = String::with_capacity(seg.len() + width.saturating_sub(seg_len));
948            padded.push_str(seg);
949            if seg_len < width {
950                padded.extend(std::iter::repeat_n(' ', width - seg_len));
951            }
952            padded.repeat(count)
953        })
954        .collect();
955    ed.mutate_edit(Edit::InsertBlock {
956        at: Position::new(start_row, insert_col),
957        chunks,
958    });
959    // Cursor lands on the top-left cell of the pasted block.
960    ed.jump_cursor(start_row, insert_col);
961    // `[` / `]` span the pasted block's top-left .. bottom-left column.
962    let bot_row = start_row + segments.len().saturating_sub(1);
963    ed.set_mark('[', (start_row, insert_col));
964    ed.set_mark(']', (bot_row, insert_col));
965    ed.set_sticky_col(Some(insert_col));
966}
967/// Visual-mode `p` / `P` — replace the active selection with the register.
968/// With `p` the deleted selection lands in the unnamed register (vim's swap);
969/// with `P` (`before = true`) the source register is preserved so it can be
970/// pasted over multiple selections in turn.
971pub fn visual_paste<H: hjkl_engine::types::Host>(
972    ed: &mut Editor<hjkl_buffer::View, H>,
973    before: bool,
974) {
975    use hjkl_buffer::{Edit, Position};
976    ed.sync_buffer_content_from_textarea();
977
978    // Resolve the source register (selector or unnamed) BEFORE the delete
979    // overwrites the unnamed register with the cut selection.
980    let selector = vim_mut(ed).pending_register.take();
981    // `"+p`/`"*p` in visual mode: same live-clipboard refresh as normal-mode
982    // paste (audit-r2 fix 4).
983    sync_clipboard_register_for(ed, selector);
984    let (reg_text, reg_linewise, reg_blockwise, reg_block_width) =
985        ed.with_registers(|regs| match selector.and_then(|c| regs.read(c)) {
986            Some(slot) => (
987                slot.text.clone(),
988                slot.linewise,
989                slot.blockwise,
990                slot.block_width,
991            ),
992            None => (
993                regs.unnamed.text.clone(),
994                regs.unnamed.linewise,
995                regs.unnamed.blockwise,
996                regs.unnamed.block_width,
997            ),
998        });
999    // For `P`, snapshot the unnamed register so we can restore it afterwards.
1000    let saved_unnamed = before.then(|| ed.with_registers(|regs| regs.unnamed.clone()));
1001
1002    let mode = vim(ed).mode;
1003    ed.push_undo();
1004
1005    match mode {
1006        Mode::VisualLine => {
1007            let cursor_row = buf_cursor_pos(ed.buffer()).row;
1008            let top = cursor_row.min(vim(ed).visual_line_anchor);
1009            let bot = cursor_row.max(vim(ed).visual_line_anchor);
1010            // Delete the selected lines into the unnamed register.
1011            cut_vim_range(ed, (top, 0), (bot, 0), RangeKind::Linewise);
1012            // Insert the register as fresh line(s) where the selection was.
1013            let text = reg_text.trim_matches('\n').to_string();
1014            let line_count = buf_row_count(ed.buffer());
1015            if top >= line_count {
1016                // Selection reached the end of the buffer: append below the
1017                // (new) last line.
1018                let last = line_count.saturating_sub(1);
1019                let lc = buf_line_chars(ed.buffer(), last);
1020                ed.mutate_edit(Edit::InsertStr {
1021                    at: Position::new(last, lc),
1022                    text: format!("\n{text}"),
1023                });
1024                buf_set_cursor_rc(ed.buffer_mut(), last + 1, 0);
1025            } else {
1026                ed.mutate_edit(Edit::InsertStr {
1027                    at: Position::new(top, 0),
1028                    text: format!("{text}\n"),
1029                });
1030                buf_set_cursor_rc(ed.buffer_mut(), top, 0);
1031            }
1032            hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
1033        }
1034        Mode::Visual => {
1035            let anchor = vim(ed).visual_anchor;
1036            let cursor = ed.cursor();
1037            let (top, bot) = order(anchor, cursor);
1038            // Delete the selection into the unnamed register.
1039            cut_vim_range(ed, top, bot, RangeKind::Inclusive);
1040            // Insert the register text where the selection started.
1041            if reg_linewise {
1042                // Linewise register into a charwise hole: open a line below.
1043                let text = reg_text.trim_matches('\n').to_string();
1044                let lc = buf_line_chars(ed.buffer(), top.0);
1045                ed.mutate_edit(Edit::InsertStr {
1046                    at: Position::new(top.0, lc),
1047                    text: format!("\n{text}"),
1048                });
1049                buf_set_cursor_rc(ed.buffer_mut(), top.0 + 1, 0);
1050                hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
1051            } else {
1052                ed.mutate_edit(Edit::InsertStr {
1053                    at: Position::new(top.0, top.1),
1054                    text: reg_text.clone(),
1055                });
1056                // Park the cursor on the last char of the inserted text.
1057                let inserted_len = reg_text.chars().count();
1058                let last_col = top.1 + inserted_len.saturating_sub(1);
1059                buf_set_cursor_rc(ed.buffer_mut(), top.0, last_col);
1060            }
1061        }
1062        Mode::VisualBlock => {
1063            // `p`/`P` over a VISUAL-BLOCK selection: delete the rectangle,
1064            // then put the source register according to its kind. Verified
1065            // against nvim v0.12.4 — each register type places differently:
1066            //   - blockwise reg → re-inserted as columns at the block's
1067            //     top-left, exactly like a normal-mode block paste.
1068            //   - linewise reg  → opened as fresh line(s) BELOW the block's
1069            //     bottom row (not inline).
1070            //   - single-line charwise reg → replicated at the block's LEFT
1071            //     column on EVERY row of the (now-deleted) block.
1072            //   - multi-line charwise reg → a plain inline charwise paste at
1073            //     the block's top-left (cursor parks at the paste start).
1074            let (top, bot, left, right) = block_bounds(ed);
1075            let to_eol = vim(ed).block_to_eol;
1076            // Snapshot the rectangle for the `p` swap register.
1077            let deleted = block_yank(ed, top, bot, left, right, to_eol);
1078            let del_width = if to_eol {
1079                deleted
1080                    .split('\n')
1081                    .map(|s| s.chars().count())
1082                    .max()
1083                    .unwrap_or(0)
1084            } else {
1085                right + 1 - left
1086            };
1087            delete_block_contents(ed, top, bot, left, right, to_eol);
1088            // `p` swaps the deleted block into the unnamed register (blockwise);
1089            // `P` preserves the source register (restored below via
1090            // `saved_unnamed`).
1091            if !before && !deleted.is_empty() {
1092                ed.record_yank_to_host(deleted.clone());
1093                ed.record_delete_block(deleted, del_width, None);
1094            }
1095            if reg_blockwise {
1096                ed.jump_cursor(top, left);
1097                // `before = true` makes `do_block_paste` insert AT `left`
1098                // (the block's now-vacated left column) rather than after it.
1099                do_block_paste(ed, true, 1, reg_block_width, &reg_text);
1100            } else if reg_linewise {
1101                let text = reg_text.trim_matches('\n').to_string();
1102                let lc = buf_line_chars(ed.buffer(), bot);
1103                ed.mutate_edit(Edit::InsertStr {
1104                    at: Position::new(bot, lc),
1105                    text: format!("\n{text}"),
1106                });
1107                buf_set_cursor_rc(ed.buffer_mut(), bot + 1, 0);
1108                hjkl_engine::motions::move_first_non_blank(ed.buffer_mut());
1109            } else if reg_text.contains('\n') {
1110                ed.mutate_edit(Edit::InsertStr {
1111                    at: Position::new(top, left),
1112                    text: reg_text.clone(),
1113                });
1114                buf_set_cursor_rc(ed.buffer_mut(), top, left);
1115            } else {
1116                // Single-line charwise: replicate at the left column on every
1117                // block row. Rows shorter than `left` are SKIPPED (no
1118                // padding) — verified against nvim v0.12.4: pasting "d" over
1119                // a col-3 block whose middle row is only 2 chars leaves that
1120                // short row untouched.
1121                for r in top..=bot {
1122                    let line_len = buf_line_chars(ed.buffer(), r);
1123                    if left > line_len {
1124                        continue;
1125                    }
1126                    ed.mutate_edit(Edit::InsertStr {
1127                        at: Position::new(r, left),
1128                        text: reg_text.clone(),
1129                    });
1130                }
1131                let last_col = left + reg_text.chars().count().saturating_sub(1);
1132                buf_set_cursor_rc(ed.buffer_mut(), top, last_col);
1133            }
1134        }
1135        _ => {}
1136    }
1137
1138    // `P` preserves the source register; restore the snapshot.
1139    if let Some(slot) = saved_unnamed {
1140        ed.with_registers_mut(|regs| regs.unnamed = slot);
1141    }
1142    vim_mut(ed).mode = Mode::Normal;
1143    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
1144}
1145/// Visual-mode `<C-a>` / `<C-x>` and `g<C-a>` / `g<C-x>`. Adds `delta` to the
1146/// first number on each selected line. When `sequential` is true the increment
1147/// grows by `delta` for each successive number found (vim's `g<C-a>`): the
1148/// first gets `delta`, the second `2*delta`, and so on.
1149pub fn adjust_number_visual<H: hjkl_engine::types::Host>(
1150    ed: &mut Editor<hjkl_buffer::View, H>,
1151    delta: i64,
1152    sequential: bool,
1153) {
1154    use hjkl_buffer::{Edit, MotionKind, Position};
1155    ed.sync_buffer_content_from_textarea();
1156    let mode = vim(ed).mode;
1157    let cursor = buf_cursor_pos(ed.buffer());
1158
1159    // Resolve the row range + the per-row start column to scan from.
1160    let (top, bot, mut scan_col_first, block_left) = match mode {
1161        Mode::VisualLine => {
1162            let t = cursor.row.min(vim(ed).visual_line_anchor);
1163            let b = cursor.row.max(vim(ed).visual_line_anchor);
1164            (t, b, 0usize, None)
1165        }
1166        Mode::Visual => {
1167            let (a, c) = order(vim(ed).visual_anchor, (cursor.row, cursor.col));
1168            (a.0, c.0, a.1, None)
1169        }
1170        Mode::VisualBlock => {
1171            let (a, c) = order(vim(ed).block_anchor, (cursor.row, cursor.col));
1172            let left = a.1.min(c.1);
1173            (a.0, c.0, left, Some(left))
1174        }
1175        _ => return,
1176    };
1177
1178    ed.push_undo();
1179    let mut found_count: i64 = 0;
1180    for row in top..=bot {
1181        let start_col = match block_left {
1182            Some(left) => left,
1183            None => {
1184                // First row of a charwise selection starts at the anchor/cursor
1185                // column; subsequent rows start at column 0.
1186                let c = if row == top { scan_col_first } else { 0 };
1187                scan_col_first = 0;
1188                c
1189            }
1190        };
1191        let chars: Vec<char> = match buf_line(ed.buffer(), row) {
1192            Some(l) => l.chars().collect(),
1193            None => continue,
1194        };
1195        // `g<C-a>` scales the increment by how many numbers have been adjusted
1196        // so far, so the delta for *this* row assumes the row yields one.
1197        // `found_count` only advances once `adjusted_number_at` confirms it did
1198        // — a row with no number, or with digits that fail to parse, must not
1199        // consume a step of the sequence.
1200        let this_delta = if sequential {
1201            delta.saturating_mul(found_count.saturating_add(1))
1202        } else {
1203            delta
1204        };
1205        let Some(AdjustedNumber {
1206            start: span_start,
1207            end: span_end,
1208            text: new_s,
1209        }) = adjusted_number_at(&chars, start_col, this_delta)
1210        else {
1211            continue;
1212        };
1213        found_count += 1;
1214        let span_start_pos = Position::new(row, span_start);
1215        let span_end_pos = Position::new(row, span_end);
1216        ed.mutate_edit(Edit::DeleteRange {
1217            start: span_start_pos,
1218            end: span_end_pos,
1219            kind: MotionKind::Char,
1220        });
1221        ed.mutate_edit(Edit::InsertStr {
1222            at: span_start_pos,
1223            text: new_s,
1224        });
1225    }
1226    // Vim leaves the cursor at the start of the selection.
1227    buf_set_cursor_rc(ed.buffer_mut(), top, block_left.unwrap_or(0));
1228    vim_mut(ed).mode = Mode::Normal;
1229    ed.set_sticky_col(Some(buf_cursor_pos(ed.buffer()).col));
1230}
1231#[cfg(test)]
1232mod replace_char_tests {
1233    use hjkl_buffer::{View, rope_line_str};
1234    use hjkl_engine::{DefaultHost, Editor, Options};
1235
1236    fn line(ed: &Editor<View, DefaultHost>, row: usize) -> String {
1237        rope_line_str(&ed.buffer().rope(), row)
1238    }
1239
1240    #[test]
1241    fn replace_char_count_exceeding_line_replaces_nothing() {
1242        let buf = View::from_str("ab\ncd");
1243        let mut ed = crate::vim::vim_editor(buf, DefaultHost::new(), Options::default());
1244        // Cursor at (0,0); `3rx` needs 3 chars but the line has 2 — vim aborts
1245        // the whole command and replaces nothing (not a partial run).
1246        super::replace_char(&mut ed, 'x', 3);
1247        assert_eq!(line(&ed, 0), "ab", "partial replace must not happen");
1248        assert_eq!(line(&ed, 1), "cd", "must not spill onto the next line");
1249    }
1250
1251    #[test]
1252    fn replace_char_count_fitting_replaces_run() {
1253        let buf = View::from_str("abc");
1254        let mut ed = crate::vim::vim_editor(buf, DefaultHost::new(), Options::default());
1255        super::replace_char(&mut ed, 'x', 2);
1256        assert_eq!(line(&ed, 0), "xxc");
1257    }
1258}
1259#[cfg(test)]
1260mod g_ampersand_tests {
1261    use super::*;
1262    use hjkl_buffer::{View, rope_line_str};
1263    use hjkl_engine::{DefaultHost, Editor, Options};
1264
1265    fn make_editor(content: &str) -> Editor<View, DefaultHost> {
1266        let buf = View::from_str(content);
1267        let host = DefaultHost::new();
1268        crate::vim::vim_editor(buf, host, Options::default())
1269    }
1270
1271    fn buf_line(ed: &Editor<View, DefaultHost>, row: usize) -> String {
1272        let rope = ed.buffer().rope();
1273        rope_line_str(&rope, row).trim_end_matches('\n').to_string()
1274    }
1275
1276    /// `g&` repeats last `:s/foo/bar/` over every line (no /g flag → first
1277    /// match per line only).
1278    #[test]
1279    fn g_ampersand_repeats_last_substitute_on_whole_buffer() {
1280        let mut ed = make_editor("foo\nfoo bar foo\nbaz");
1281        // Simulate a prior `:s/foo/bar/` by setting last_substitute directly.
1282        let cmd = hjkl_engine::substitute::parse_substitute("/foo/bar/").unwrap();
1283        ed.set_last_substitute(cmd);
1284        // Cursor on line 0 (to confirm g& operates on ALL lines, not just current).
1285        apply_after_g(&mut ed, '&', 1);
1286        assert_eq!(buf_line(&ed, 0), "bar");
1287        // No /g flag — only first match per line.
1288        assert_eq!(buf_line(&ed, 1), "bar bar foo");
1289        assert_eq!(buf_line(&ed, 2), "baz");
1290    }
1291
1292    /// `g&` with /g flag replaces all matches per line.
1293    #[test]
1294    fn g_ampersand_with_g_flag_replaces_all_per_line() {
1295        let mut ed = make_editor("foo foo\nfoo");
1296        let cmd = hjkl_engine::substitute::parse_substitute("/foo/bar/g").unwrap();
1297        ed.set_last_substitute(cmd);
1298        apply_after_g(&mut ed, '&', 1);
1299        assert_eq!(buf_line(&ed, 0), "bar bar");
1300        assert_eq!(buf_line(&ed, 1), "bar");
1301    }
1302
1303    /// `g&` with no prior substitute is a no-op.
1304    #[test]
1305    fn g_ampersand_noop_when_no_prior_substitute() {
1306        let mut ed = make_editor("foo\nbar");
1307        // No last_substitute set — must not panic, must not change buffer.
1308        apply_after_g(&mut ed, '&', 1);
1309        assert_eq!(buf_line(&ed, 0), "foo");
1310        assert_eq!(buf_line(&ed, 1), "bar");
1311    }
1312}