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