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}