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