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