Skip to main content

hjkl_engine/
selection_shift.rs

1//! Keep selections valid across an edit (#63).
2//!
3//! Multi-cursor edits cascade: an edit made at selection N moves every position
4//! after it, so the other selections have to be rewritten or they silently point
5//! at the wrong text. This module is that rewrite, as a pure function over
6//! `(Position, Edit)` — no editor, no buffer mutation.
7//!
8//! # Contract
9//!
10//! [`shift_position`] is called with the **pre-edit** buffer geometry, i.e.
11//! before `edit` is applied. It answers: where does this selection end up once
12//! the edit lands?
13//!
14//! It returns `Option`, and `None` means **"this position cannot be tracked
15//! through this edit — drop it"**. That is deliberate. The alternative for an
16//! edit whose geometry we do not model exactly is to guess, and a guessed
17//! position is a selection pointing at the wrong text: the edit still applies,
18//! just somewhere the user did not ask for. Dropping degrades multi-cursor to
19//! single-cursor, which is visible and harmless; guessing corrupts the buffer,
20//! which is neither.
21//!
22//! Today `None` is returned only for `SplitLines`, which is the undo-inverse of
23//! a join: it is emitted when history rewinds, not when a user edits, and undo
24//! restores a whole snapshot without preserving secondary carets anyway.
25//!
26//! `JoinLines`, `InsertBlock` and `DeleteBlockChunks` ARE modelled — they mirror
27//! `hjkl_buffer`'s own geometry, and they matter: vim's `J` is a `JoinLines`, and
28//! visual-block `I`/`A` are the block edits, so dropping carets on those would
29//! make multi-cursor collapse under exactly the operations that need it most.
30//!
31//! # Position semantics
32//!
33//! A position exactly at an insertion point moves right (the text lands before
34//! it). A position strictly inside a deleted range collapses to the range start.
35
36use hjkl_buffer::{Edit, MotionKind, Position};
37
38/// One selection: an `anchor` (the fixed end) and a `head` (the end a motion
39/// moves). Both are **inclusive** char positions — `anchor == head` is a bare
40/// caret, and a selection with extent covers `[start, end]` inclusive of both.
41///
42/// # Units
43///
44/// Char columns, like [`hjkl_buffer::Edit`] and `View::cursor` — NOT the
45/// grapheme columns of [`crate::types::Pos`]. Mixing the two is silently wrong
46/// on multi-byte text.
47///
48/// # Why the engine owns the anchor
49///
50/// A discipline could keep its own `Vec` of anchors beside `Editor`'s secondary
51/// carets, but [`shift_position`] may DROP a caret it cannot track, and a
52/// parallel `Vec` would then desync: anchors and heads would pair up wrong and
53/// the next edit would land on text the user never selected. Keeping both ends
54/// in one struct, shifted together by [`shift_sel`], makes that class of bug
55/// unrepresentable — either the whole selection survives the edit or the whole
56/// selection is dropped.
57///
58/// The **primary** selection is deliberately asymmetric: its head is
59/// `View::cursor` and its anchor lives in the discipline's own state (vim's
60/// `visual_anchor` in `VimState`, helix's `anchor` in `HelixState`). That split
61/// predates multi-cursor and unifying it would rewrite vim's visual mode, so it
62/// stays. Only the *secondary* selections live here.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct Sel {
65    /// The end that stays put while a motion runs.
66    pub anchor: Position,
67    /// The end a motion moves; where an edit is applied.
68    pub head: Position,
69}
70
71impl Sel {
72    /// A selection from `anchor` to `head`.
73    pub fn new(anchor: Position, head: Position) -> Self {
74        Self { anchor, head }
75    }
76
77    /// A zero-width selection: anchor and head on the same position.
78    pub fn caret(p: Position) -> Self {
79        Self { anchor: p, head: p }
80    }
81
82    /// True when the selection has no extent.
83    pub fn is_caret(&self) -> bool {
84        self.anchor == self.head
85    }
86
87    /// The earlier of the two ends, in document order.
88    pub fn start(&self) -> Position {
89        self.anchor.min(self.head)
90    }
91
92    /// The later of the two ends, in document order.
93    pub fn end(&self) -> Position {
94        self.anchor.max(self.head)
95    }
96
97    /// True when `self` and `other` cover any common position, treating
98    /// both `[start, end]` ranges as inclusive of both ends (matching the
99    /// doc comment on the struct: a selection covers `[start, end]`
100    /// inclusive).
101    ///
102    /// Used to guard the secondary-selection set against overlaps: the
103    /// bottom-up fan-out in [`crate::editor::Editor::edit_at_all_selections`]
104    /// assumes selections are disjoint, since an edit made at one selection
105    /// only shifts positions strictly after it — an overlapping selection's
106    /// still-queued coordinates would land mid-edit and corrupt (audit A7).
107    pub fn overlaps(&self, other: &Sel) -> bool {
108        self.start() <= other.end() && other.start() <= self.end()
109    }
110}
111
112/// Merge `a` and `b` into the selection spanning their union, in document
113/// order (`anchor == start`, `head == end`). Direction (which end was
114/// originally the anchor) is not preserved — once two selections overlap
115/// there is no single sensible "the user dragged from here" answer, so the
116/// merged range just picks a canonical rightward orientation.
117fn merge_two(a: Sel, b: Sel) -> Sel {
118    Sel::new(a.start().min(b.start()), a.end().max(b.end()))
119}
120
121/// Collapse a set of selections so no two overlap, merging any that do into
122/// their union. Order of the input is not preserved — callers that need a
123/// stable order should re-sort the result.
124///
125/// This is the guard behind [`crate::editor::Editor::add_selection`] and
126/// [`crate::editor::Editor::set_extra_selections`] (audit A7): without it,
127/// two overlapping secondaries would both queue edits against ranges that
128/// alias each other, and the bottom-up fan-out in `edit_at_all_selections`
129/// (which shifts each not-yet-applied selection's coordinates as earlier
130/// edits land) has no way to represent "this position belongs to two
131/// selections at once" — the later edit corrupts the earlier selection's
132/// already-shifted coordinates.
133pub fn merge_overlapping(mut sels: Vec<Sel>) -> Vec<Sel> {
134    sels.sort_by_key(|s| (s.start().row, s.start().col));
135    let mut out: Vec<Sel> = Vec::with_capacity(sels.len());
136    for s in sels {
137        match out.last_mut() {
138            Some(last) if last.overlaps(&s) => *last = merge_two(*last, s),
139            _ => out.push(s),
140        }
141    }
142    out
143}
144
145/// Order positions in document order.
146fn key(p: Position) -> (usize, usize) {
147    (p.row, p.col)
148}
149
150/// Where `p` lands after `text` is inserted at `at`.
151fn after_insert(p: Position, at: Position, text: &str) -> Position {
152    if key(p) < key(at) {
153        return p;
154    }
155    let added_rows = text.matches('\n').count();
156    // Chars on the final line of the inserted text — what a position on `at`'s
157    // row gets pushed right by once the newlines have moved it down.
158    let tail = text.rsplit('\n').next().unwrap_or("");
159    let tail_len = tail.chars().count();
160
161    if p.row == at.row {
162        if added_rows == 0 {
163            Position::new(p.row, p.col + text.chars().count())
164        } else {
165            // Everything at/after `at.col` slides onto the last inserted row.
166            Position::new(p.row + added_rows, tail_len + (p.col - at.col))
167        }
168    } else {
169        // Strictly below the insertion: only the row shifts.
170        Position::new(p.row + added_rows, p.col)
171    }
172}
173
174/// Where `p` lands after the charwise range `[start, end)` is deleted.
175fn after_delete_char(p: Position, start: Position, end: Position) -> Position {
176    if key(p) <= key(start) {
177        return p;
178    }
179    if key(p) < key(end) {
180        // Inside the hole — collapse to where the text used to begin.
181        return start;
182    }
183    if p.row == end.row {
184        // Tail of the end row folds up onto the start row.
185        Position::new(start.row, start.col + (p.col - end.col))
186    } else {
187        Position::new(p.row - (end.row - start.row), p.col)
188    }
189}
190
191/// Where `p` lands after whole rows `start_row..=end_row` are deleted.
192fn after_delete_lines(p: Position, start_row: usize, end_row: usize) -> Position {
193    if p.row < start_row {
194        p
195    } else if p.row <= end_row {
196        // The row the selection lived on is gone.
197        Position::new(start_row, 0)
198    } else {
199        Position::new(p.row - (end_row - start_row + 1), p.col)
200    }
201}
202
203/// Where `p` lands after the rectangle `rows × [lo_col, hi_col]` is deleted.
204fn after_delete_block(p: Position, start: Position, end: Position) -> Position {
205    let (lo_row, hi_row) = (start.row.min(end.row), start.row.max(end.row));
206    let (lo_col, hi_col) = (start.col.min(end.col), start.col.max(end.col));
207    if p.row < lo_row || p.row > hi_row {
208        return p;
209    }
210    let width = hi_col - lo_col + 1;
211    if p.col > hi_col {
212        Position::new(p.row, p.col - width)
213    } else if p.col >= lo_col {
214        Position::new(p.row, lo_col)
215    } else {
216        p
217    }
218}
219
220/// Where `p` lands after `count` rows are joined onto `row`.
221///
222/// Mirrors `hjkl_buffer::Edit::JoinLines` exactly: each step drops the `\n`
223/// ending `row` and inserts a single space **only when both sides are
224/// non-empty**. (It does not strip leading whitespace — vim's `J` does, this
225/// buffer's `JoinLines` does not, and guessing the wrong one here would mis-place
226/// every caret on a joined row.)
227///
228/// `line_len` gives the **pre-edit** char length of a row.
229fn after_join(
230    p: Position,
231    row: usize,
232    count: usize,
233    with_space: bool,
234    line_len: &impl Fn(usize) -> usize,
235    rows: usize,
236) -> Position {
237    if p.row < row {
238        return p;
239    }
240    // Walk the joins, tracking where each joined row's text lands in the merged
241    // row. `start_col[k]` is the column original row `row + k` begins at.
242    let mut cur_len = line_len(row);
243    let mut start_col = Vec::with_capacity(count);
244    let mut joined = 0usize;
245    for k in 1..=count.max(1) {
246        if row + k >= rows {
247            break;
248        }
249        let next_len = line_len(row + k);
250        let space = with_space && cur_len > 0 && next_len > 0;
251        start_col.push(cur_len + usize::from(space));
252        cur_len += usize::from(space) + next_len;
253        joined += 1;
254    }
255    if joined == 0 || p.row == row {
256        // The anchor row keeps its columns; text is only appended after it.
257        return p;
258    }
259    if p.row <= row + joined {
260        let k = p.row - row; // 1..=joined
261        Position::new(row, start_col[k - 1] + p.col)
262    } else {
263        Position::new(p.row - joined, p.col)
264    }
265}
266
267/// Where `p` lands after a block insert: `chunks[i]` spliced at
268/// `(at.row + i, at.col)`. Rows shorter than `at.col` are space-padded first,
269/// but every position on such a row sits left of `at.col` and so cannot move.
270fn after_insert_block(p: Position, at: Position, chunks: &[String]) -> Position {
271    if p.row < at.row || p.row >= at.row + chunks.len() || p.col < at.col {
272        return p;
273    }
274    let width = chunks[p.row - at.row].chars().count();
275    Position::new(p.row, p.col + width)
276}
277
278/// Where `p` lands after a block delete: `widths[i]` chars removed at
279/// `(at.row + i, at.col)`.
280fn after_delete_block_chunks(p: Position, at: Position, widths: &[usize]) -> Position {
281    if p.row < at.row || p.row >= at.row + widths.len() || p.col <= at.col {
282        return p;
283    }
284    let w = widths[p.row - at.row];
285    if p.col >= at.col + w {
286        Position::new(p.row, p.col - w)
287    } else {
288        // Inside the removed chunk.
289        Position::new(p.row, at.col)
290    }
291}
292
293/// Rewrite `p` so it still points at the same text after `edit` lands, or
294/// `None` when the edit's geometry is not modelled and the position must be
295/// dropped rather than guessed.
296///
297/// `line_len` returns the **pre-edit** char length of a row, and `rows` the
298/// pre-edit row count. Only the row-restructuring edits consult them.
299///
300/// # Units
301///
302/// Works in **char columns**, which is what [`Edit`] and `View::cursor` both
303/// speak. Deliberately *not* expressed over [`crate::types::Selection`], whose
304/// `Pos::col` counts **graphemes**: doing this arithmetic in grapheme columns
305/// would silently mis-shift every position sitting after a multi-byte
306/// character. Converting between the two units needs the buffer, so it belongs
307/// at the call boundary, not here.
308pub fn shift_position(
309    p: Position,
310    edit: &Edit,
311    line_len: impl Fn(usize) -> usize,
312    rows: usize,
313) -> Option<Position> {
314    match edit {
315        // A `\n` typed as a char restructures rows exactly like the 1-char
316        // string would, so route both through the same insert geometry.
317        Edit::InsertChar { at, ch } => {
318            let mut buf = [0u8; 4];
319            Some(after_insert(p, *at, ch.encode_utf8(&mut buf)))
320        }
321        Edit::InsertStr { at, text } => Some(after_insert(p, *at, text)),
322        Edit::DeleteRange { start, end, kind } => Some(match kind {
323            MotionKind::Char => after_delete_char(p, *start, *end),
324            MotionKind::Line => after_delete_lines(p, start.row, end.row),
325            MotionKind::Block => after_delete_block(p, *start, *end),
326        }),
327        Edit::Replace { start, end, with } => {
328            // Delete then insert at the (now collapsed) start.
329            let deleted = after_delete_char(p, *start, *end);
330            Some(after_insert(deleted, *start, with))
331        }
332        Edit::JoinLines {
333            row,
334            count,
335            with_space,
336        } => Some(after_join(p, *row, *count, *with_space, &line_len, rows)),
337        Edit::InsertBlock { at, chunks } => Some(after_insert_block(p, *at, chunks)),
338        // `pads` (audit-r2 fix 6) isn't modelled here — `at.col` is always
339        // valid pre-pad geometry, and `pads` is only non-zero on a path
340        // (DeleteBlockChunks as InsertBlock's own inverse) nothing
341        // currently applies; see the `Edit::DeleteBlockChunks` field doc.
342        Edit::DeleteBlockChunks { at, widths, .. } => {
343            Some(after_delete_block_chunks(p, *at, widths))
344        }
345        // `SplitLines` is the undo-inverse of a join, emitted when history rewinds
346        // rather than when a user edits. Undo restores a whole snapshot and does
347        // not preserve secondary carets anyway, so modelling it would buy nothing
348        // real — drop rather than write geometry no test could justify.
349        Edit::SplitLines { .. } => None,
350    }
351}
352
353/// Rewrite BOTH ends of `sel` so it still covers the same text after `edit`,
354/// or `None` when *either* end is untrackable.
355///
356/// All-or-nothing is the whole point: a selection whose head survived and whose
357/// anchor did not is worse than no selection at all — it would still apply the
358/// next edit, just over a range the user never selected. Half-tracked is not a
359/// state this type can be in.
360///
361/// See [`shift_position`] for the contract on `line_len` / `rows` (both are
362/// **pre-edit** geometry).
363pub fn shift_sel(
364    sel: Sel,
365    edit: &Edit,
366    line_len: impl Fn(usize) -> usize,
367    rows: usize,
368) -> Option<Sel> {
369    let anchor = shift_position(sel.anchor, edit, &line_len, rows)?;
370    let head = shift_position(sel.head, edit, &line_len, rows)?;
371    Some(Sel { anchor, head })
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn p(row: usize, col: usize) -> Position {
379        Position::new(row, col)
380    }
381    fn ins(row: usize, col: usize, text: &str) -> Edit {
382        Edit::InsertStr {
383            at: p(row, col),
384            text: text.to_string(),
385        }
386    }
387    fn del(s: (usize, usize), e: (usize, usize), kind: MotionKind) -> Edit {
388        Edit::DeleteRange {
389            start: p(s.0, s.1),
390            end: p(e.0, e.1),
391            kind,
392        }
393    }
394    /// Shift a bare position against a buffer with no interesting geometry.
395    /// (`line_len` / `rows` only matter for `JoinLines`; the join tests below
396    /// pass real metrics.)
397    fn head(row: usize, col: usize, edit: &Edit) -> Option<Position> {
398        shift_position(p(row, col), edit, |_| 0, 0)
399    }
400
401    /// Shift against explicit pre-edit line lengths.
402    fn head_in(row: usize, col: usize, edit: &Edit, lens: &[usize]) -> Option<Position> {
403        shift_position(p(row, col), edit, |r| lens[r], lens.len())
404    }
405
406    // ── Insert ───────────────────────────────────────────────────────────────
407
408    #[test]
409    fn insert_before_on_same_row_pushes_right() {
410        assert_eq!(head(0, 5, &ins(0, 2, "ab")), Some(p(0, 7)));
411    }
412
413    #[test]
414    fn insert_after_on_same_row_does_not_move() {
415        assert_eq!(head(0, 1, &ins(0, 2, "ab")), Some(p(0, 1)));
416    }
417
418    #[test]
419    fn position_exactly_at_insertion_point_moves_right() {
420        // Text lands *before* the caret, so the caret slides.
421        assert_eq!(head(0, 2, &ins(0, 2, "xy")), Some(p(0, 2 + 2)));
422    }
423
424    #[test]
425    fn insert_on_earlier_row_does_not_move_later_col() {
426        assert_eq!(head(3, 4, &ins(1, 0, "abc")), Some(p(3, 4)));
427    }
428
429    #[test]
430    fn multiline_insert_pushes_later_rows_down() {
431        assert_eq!(head(3, 4, &ins(1, 0, "a\nb\n")), Some(p(5, 4)));
432    }
433
434    #[test]
435    fn multiline_insert_relocates_tail_of_the_insert_row() {
436        // "ab|cd" + insert "X\nY" at col 2 -> row0 "abX", row1 "Ycd";
437        // the caret that was at col 2 is now on row 1 after "Y".
438        assert_eq!(head(0, 2, &ins(0, 2, "X\nY")), Some(p(1, 1)));
439    }
440
441    #[test]
442    fn insert_char_newline_restructures_like_a_string() {
443        let e = Edit::InsertChar {
444            at: p(0, 2),
445            ch: '\n',
446        };
447        assert_eq!(head(0, 5, &e), Some(p(1, 3)));
448    }
449
450    // ── Charwise delete ──────────────────────────────────────────────────────
451
452    #[test]
453    fn delete_before_pulls_left() {
454        assert_eq!(
455            head(0, 9, &del((0, 2), (0, 5), MotionKind::Char)),
456            Some(p(0, 6))
457        );
458    }
459
460    #[test]
461    fn delete_after_does_not_move() {
462        assert_eq!(
463            head(0, 1, &del((0, 2), (0, 5), MotionKind::Char)),
464            Some(p(0, 1))
465        );
466    }
467
468    #[test]
469    fn position_inside_deleted_range_collapses_to_start() {
470        assert_eq!(
471            head(0, 3, &del((0, 2), (0, 5), MotionKind::Char)),
472            Some(p(0, 2))
473        );
474    }
475
476    #[test]
477    fn delete_end_is_exclusive() {
478        // A caret exactly at `end` survives; it is the first char kept.
479        assert_eq!(
480            head(0, 5, &del((0, 2), (0, 5), MotionKind::Char)),
481            Some(p(0, 2))
482        );
483    }
484
485    #[test]
486    fn cross_row_delete_folds_tail_onto_start_row() {
487        // Deleting (1,2)..(3,4): a caret at (3,6) lands at (1, 2 + (6-4)).
488        assert_eq!(
489            head(3, 6, &del((1, 2), (3, 4), MotionKind::Char)),
490            Some(p(1, 4))
491        );
492    }
493
494    #[test]
495    fn row_below_a_cross_row_delete_shifts_up() {
496        assert_eq!(
497            head(7, 3, &del((1, 2), (3, 4), MotionKind::Char)),
498            Some(p(5, 3))
499        );
500    }
501
502    // ── Linewise delete ──────────────────────────────────────────────────────
503
504    #[test]
505    fn linewise_delete_shifts_rows_below_up() {
506        assert_eq!(
507            head(9, 3, &del((2, 0), (4, 0), MotionKind::Line)),
508            Some(p(6, 3))
509        );
510    }
511
512    #[test]
513    fn linewise_delete_of_the_selections_own_row_collapses_it() {
514        assert_eq!(
515            head(3, 7, &del((2, 0), (4, 0), MotionKind::Line)),
516            Some(p(2, 0))
517        );
518    }
519
520    #[test]
521    fn linewise_delete_above_leaves_earlier_rows_alone() {
522        assert_eq!(
523            head(1, 7, &del((2, 0), (4, 0), MotionKind::Line)),
524            Some(p(1, 7))
525        );
526    }
527
528    // ── Block delete ─────────────────────────────────────────────────────────
529
530    #[test]
531    fn block_delete_pulls_columns_right_of_the_rectangle_left() {
532        assert_eq!(
533            head(2, 9, &del((1, 2), (3, 5), MotionKind::Block)),
534            Some(p(2, 5))
535        );
536    }
537
538    #[test]
539    fn block_delete_collapses_columns_inside_the_rectangle() {
540        assert_eq!(
541            head(2, 3, &del((1, 2), (3, 5), MotionKind::Block)),
542            Some(p(2, 2))
543        );
544    }
545
546    #[test]
547    fn block_delete_leaves_rows_outside_the_rectangle_alone() {
548        assert_eq!(
549            head(9, 9, &del((1, 2), (3, 5), MotionKind::Block)),
550            Some(p(9, 9))
551        );
552    }
553
554    // ── Replace ──────────────────────────────────────────────────────────────
555
556    #[test]
557    fn replace_shorter_pulls_left() {
558        let e = Edit::Replace {
559            start: p(0, 2),
560            end: p(0, 6),
561            with: "x".to_string(),
562        };
563        // "ab[cdef]gh" -> "ab x gh": a caret at col 8 moves to 2 + 1 + (8-6) = 5.
564        assert_eq!(head(0, 8, &e), Some(p(0, 5)));
565    }
566
567    #[test]
568    fn replace_longer_pushes_right() {
569        let e = Edit::Replace {
570            start: p(0, 2),
571            end: p(0, 3),
572            with: "xyz".to_string(),
573        };
574        assert_eq!(head(0, 5, &e), Some(p(0, 7)));
575    }
576
577    // ── Untracked edits drop rather than guess ───────────────────────────────
578
579    // ── Join ─────────────────────────────────────────────────────────────────
580
581    #[test]
582    fn join_folds_the_next_row_up_after_the_anchor_plus_a_space() {
583        // rows: "abc"(3) "de"(2). J -> "abc de"; a caret at (1,1) lands at col 4+1.
584        let e = Edit::JoinLines {
585            row: 0,
586            count: 1,
587            with_space: true,
588        };
589        assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 5)));
590    }
591
592    #[test]
593    fn join_without_space_folds_flush() {
594        let e = Edit::JoinLines {
595            row: 0,
596            count: 1,
597            with_space: false,
598        };
599        assert_eq!(head_in(1, 1, &e, &[3, 2]), Some(p(0, 4)));
600    }
601
602    #[test]
603    fn join_inserts_no_space_when_a_side_is_empty() {
604        // The buffer only inserts a space when BOTH sides are non-empty.
605        let e = Edit::JoinLines {
606            row: 0,
607            count: 1,
608            with_space: true,
609        };
610        assert_eq!(
611            head_in(1, 1, &e, &[0, 2]),
612            Some(p(0, 1)),
613            "empty prefix -> no space"
614        );
615    }
616
617    #[test]
618    fn join_leaves_the_anchor_rows_own_columns_alone() {
619        let e = Edit::JoinLines {
620            row: 0,
621            count: 1,
622            with_space: true,
623        };
624        assert_eq!(head_in(0, 2, &e, &[3, 2]), Some(p(0, 2)));
625    }
626
627    #[test]
628    fn join_pulls_rows_below_the_joined_span_up() {
629        let e = Edit::JoinLines {
630            row: 0,
631            count: 1,
632            with_space: true,
633        };
634        assert_eq!(head_in(3, 1, &e, &[3, 2, 4, 4]), Some(p(2, 1)));
635    }
636
637    #[test]
638    fn multi_row_join_accumulates_each_rows_offset() {
639        // "ab"(2) "cd"(2) "ef"(2), J J -> "ab cd ef".
640        // row2 col0 -> after "ab"+sp+"cd"+sp = 6.
641        let e = Edit::JoinLines {
642            row: 0,
643            count: 2,
644            with_space: true,
645        };
646        assert_eq!(head_in(2, 0, &e, &[2, 2, 2]), Some(p(0, 6)));
647    }
648
649    // ── Block insert / delete (visual-block I / A / d) ───────────────────────
650
651    #[test]
652    fn block_insert_pushes_columns_at_or_after_the_block_right() {
653        let e = Edit::InsertBlock {
654            at: p(0, 2),
655            chunks: vec!["xx".into(), "xx".into()],
656        };
657        assert_eq!(head(1, 4, &e), Some(p(1, 6)));
658    }
659
660    #[test]
661    fn block_insert_leaves_columns_before_the_block_alone() {
662        let e = Edit::InsertBlock {
663            at: p(0, 2),
664            chunks: vec!["xx".into(), "xx".into()],
665        };
666        assert_eq!(head(1, 1, &e), Some(p(1, 1)));
667    }
668
669    #[test]
670    fn block_insert_leaves_rows_outside_the_block_alone() {
671        let e = Edit::InsertBlock {
672            at: p(0, 2),
673            chunks: vec!["xx".into()],
674        };
675        assert_eq!(head(5, 4, &e), Some(p(5, 4)));
676    }
677
678    #[test]
679    fn block_chunk_delete_pulls_columns_after_the_chunk_left() {
680        let e = Edit::DeleteBlockChunks {
681            at: p(0, 2),
682            widths: vec![2, 2],
683            pads: vec![0, 0],
684        };
685        assert_eq!(head(1, 6, &e), Some(p(1, 4)));
686    }
687
688    #[test]
689    fn block_chunk_delete_collapses_columns_inside_the_chunk() {
690        let e = Edit::DeleteBlockChunks {
691            at: p(0, 2),
692            widths: vec![3],
693            pads: vec![0],
694        };
695        assert_eq!(head(0, 3, &e), Some(p(0, 2)));
696    }
697
698    // ── The one edit still dropped ───────────────────────────────────────────
699
700    // ── Overlap guard (audit A7) ─────────────────────────────────────────────
701
702    #[test]
703    fn adjacent_carets_do_not_overlap() {
704        // Touching-but-disjoint: [0,3] and [0,4] share no position.
705        let a = Sel::new(p(0, 3), p(0, 3));
706        let b = Sel::new(p(0, 4), p(0, 4));
707        assert!(!a.overlaps(&b));
708        assert!(!b.overlaps(&a));
709    }
710
711    #[test]
712    fn ranges_sharing_one_boundary_position_overlap() {
713        // [0,2..0,5] and [0,5..0,8] share position (0,5) — inclusive ranges.
714        let a = Sel::new(p(0, 2), p(0, 5));
715        let b = Sel::new(p(0, 5), p(0, 8));
716        assert!(a.overlaps(&b));
717        assert!(b.overlaps(&a));
718    }
719
720    #[test]
721    fn a_caret_inside_a_range_overlaps_it() {
722        let range = Sel::new(p(0, 2), p(0, 8));
723        let caret = Sel::caret(p(0, 5));
724        assert!(range.overlaps(&caret));
725    }
726
727    #[test]
728    fn merge_overlapping_leaves_disjoint_selections_untouched() {
729        let sels = vec![
730            Sel::new(p(0, 0), p(0, 2)),
731            Sel::new(p(1, 0), p(1, 2)),
732            Sel::new(p(2, 0), p(2, 2)),
733        ];
734        let merged = merge_overlapping(sels.clone());
735        assert_eq!(merged.len(), 3);
736        for s in &sels {
737            assert!(merged.contains(s));
738        }
739    }
740
741    #[test]
742    fn merge_overlapping_unions_two_overlapping_ranges() {
743        // [0,0..0,5] and [0,3..0,9] overlap on [0,3..0,5] — must merge into
744        // one selection spanning the union, not two aliasing entries that
745        // would both queue an edit over the shared text.
746        let sels = vec![Sel::new(p(0, 0), p(0, 5)), Sel::new(p(0, 3), p(0, 9))];
747        let merged = merge_overlapping(sels);
748        assert_eq!(merged, vec![Sel::new(p(0, 0), p(0, 9))]);
749    }
750
751    #[test]
752    fn merge_overlapping_collapses_a_transitive_chain() {
753        // A overlaps B, B overlaps C, but A and C alone would not touch —
754        // the whole chain must still collapse into one selection.
755        let a = Sel::new(p(0, 0), p(0, 3));
756        let b = Sel::new(p(0, 2), p(0, 6));
757        let c = Sel::new(p(0, 5), p(0, 9));
758        let merged = merge_overlapping(vec![c, a, b]);
759        assert_eq!(merged, vec![Sel::new(p(0, 0), p(0, 9))]);
760    }
761
762    // ── Selections shift as a unit ───────────────────────────────────────────
763
764    #[test]
765    fn a_selection_shifts_both_of_its_ends() {
766        // "ab|cdef|gh": insert 2 chars at col 0 -> both ends slide right by 2.
767        let s = Sel::new(p(0, 2), p(0, 5));
768        assert_eq!(
769            shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1),
770            Some(Sel::new(p(0, 4), p(0, 7)))
771        );
772    }
773
774    #[test]
775    fn a_backwards_selection_keeps_its_direction() {
776        let s = Sel::new(p(0, 5), p(0, 2));
777        let out = shift_sel(s, &ins(0, 0, "XY"), |_| 0, 1).unwrap();
778        assert_eq!(out.anchor, p(0, 7));
779        assert_eq!(out.head, p(0, 4));
780        assert!(out.anchor > out.head, "direction must survive the shift");
781    }
782
783    #[test]
784    fn a_selection_whose_text_is_deleted_collapses_to_the_hole() {
785        // Deleting [0,2)..(0,6) swallows a selection living inside it.
786        let s = Sel::new(p(0, 3), p(0, 5));
787        assert_eq!(
788            shift_sel(s, &del((0, 2), (0, 6), MotionKind::Char), |_| 0, 1),
789            Some(Sel::caret(p(0, 2))),
790            "both ends collapse to the deletion start — a caret, not a stale range"
791        );
792    }
793
794    #[test]
795    fn a_selection_is_dropped_whole_when_either_end_is_untrackable() {
796        let e = Edit::SplitLines {
797            row: 0,
798            cols: vec![3],
799            inserted_spaces: vec![true],
800        };
801        assert_eq!(
802            shift_sel(Sel::new(p(1, 0), p(1, 4)), &e, |_| 0, 0),
803            None,
804            "never half-track: a selection with one guessed end edits the wrong text"
805        );
806    }
807
808    #[test]
809    fn split_lines_drops_rather_than_guessing() {
810        // `SplitLines` is the undo-inverse of a join — emitted when history
811        // rewinds, not when a user edits. Undo restores a snapshot and does not
812        // preserve secondary carets anyway, so there is nothing real to model.
813        let e = Edit::SplitLines {
814            row: 0,
815            cols: vec![3],
816            inserted_spaces: vec![true],
817        };
818        assert_eq!(shift_position(p(5, 0), &e, |_| 0, 0), None);
819    }
820}