Skip to main content

hjkl_engine/
buffer_impl.rs

1//! Canonical [`Buffer`] trait impl over [`hjkl_buffer::Buffer`].
2//!
3//! Wires the engine trait surface (`Cursor` / `Query` / `BufferEdit` /
4//! `Search`, sealed via [`crate::types::sealed::Sealed`]) onto the
5//! in-tree rope-backed buffer. Pos⇄Position conversion lives at this
6//! boundary — engine code (FSM, editor) keeps using `hjkl_buffer`'s
7//! concrete API directly until the motion / fold relocation lands;
8//! external trait users see the engine trait surface.
9//!
10//! # Why concrete-Editor today
11//!
12//! The trait surface here is 13 methods. The engine FSM today calls
13//! ~46 distinct methods on `hjkl_buffer::Buffer` — most of them are
14//! motion / fold / viewport helpers that don't belong on `Buffer`
15//! (they're computed over the buffer, not delegated to it). Generic-ifying
16//! `Editor<B: Buffer, H: Host>` therefore requires relocating those
17//! ~33 helpers from `hjkl-buffer` into `hjkl-engine` as free functions
18//! over `B: Cursor + Query`. That's a separate, multi-thousand-LOC
19//! patch tracked for the 0.1.0 cut.
20//!
21//! Until then this module ships the canonical impl + a compile-time
22//! assertion that `hjkl_buffer::Buffer` satisfies the trait, so
23//! downstream callers can write `fn f<B: hjkl_engine::Buffer>(…)`
24//! today and the engine's own `Editor` becomes generic over `B` in a
25//! follow-up patch without breaking the trait contract.
26
27use std::borrow::Cow;
28
29use hjkl_buffer::Buffer as RopeBuffer;
30use hjkl_buffer::Position;
31use regex::Regex;
32
33use crate::types::sealed::Sealed;
34use crate::types::{Buffer, BufferEdit, Cursor, FoldOp, FoldProvider, Pos, Query, Search};
35
36// ── Pos ⇄ Position conversion ──────────────────────────────────────
37
38/// Engine [`Pos`] → buffer [`Position`].
39///
40/// Engine `Pos` is `(line: u32, col: u32)` grapheme-indexed; buffer
41/// [`Position`] is `(row: usize, col: usize)` char-indexed. The two
42/// indexings happen to match for the in-tree rope today (graphemes
43/// without combining marks == chars); future grapheme-aware backends
44/// will need to thread a real grapheme→char map through this fn.
45#[inline]
46pub(crate) fn pos_to_position(p: Pos) -> Position {
47    Position {
48        row: p.line as usize,
49        col: p.col as usize,
50    }
51}
52
53/// Buffer [`Position`] → engine [`Pos`].
54#[inline]
55pub(crate) fn position_to_pos(p: Position) -> Pos {
56    Pos {
57        line: p.row as u32,
58        col: p.col as u32,
59    }
60}
61
62// ── Sealed marker ──────────────────────────────────────────────────
63
64impl Sealed for RopeBuffer {}
65
66// ── Cursor ─────────────────────────────────────────────────────────
67
68impl Cursor for RopeBuffer {
69    fn cursor(&self) -> Pos {
70        position_to_pos(RopeBuffer::cursor(self))
71    }
72
73    fn set_cursor(&mut self, pos: Pos) {
74        RopeBuffer::set_cursor(self, pos_to_position(pos));
75    }
76
77    fn byte_offset(&self, pos: Pos) -> usize {
78        let p = pos_to_position(pos);
79        let rope = self.rope();
80        let n = rope.len_lines();
81        // O(log N) rope index — no per-row String materialization.
82        let row = p.row.min(n);
83        let line_start = rope.line_to_byte(row);
84        if p.row < n {
85            // Only the cursor's own line needs to materialize for the
86            // col→byte offset translation.
87            let line = hjkl_buffer::rope_line_str(&rope, p.row);
88            line_start + p.byte_offset(&line)
89        } else {
90            line_start
91        }
92    }
93
94    fn pos_at_byte(&self, byte: usize) -> Pos {
95        let rope = self.rope();
96        let total = rope.len_bytes();
97        if total == 0 {
98            return Pos { line: 0, col: 0 };
99        }
100        // Clamp into [0, total]; `byte_to_line` panics on OOB.
101        let byte_clamped = byte.min(total);
102        // O(log N) rope index — no per-row scan.
103        let row = rope.byte_to_line(byte_clamped);
104        let line_start = rope.line_to_byte(row);
105        let line = hjkl_buffer::rope_line_str(&rope, row);
106        let mut col_byte = byte_clamped.saturating_sub(line_start);
107        // Clamp to the line body (exclude the trailing `\n` if any).
108        col_byte = col_byte.min(line.len());
109        // Round down to the nearest char boundary so a byte index
110        // landing inside a multi-byte codepoint maps to the column
111        // of the char that contains it (not a slice panic).
112        while col_byte > 0 && !line.is_char_boundary(col_byte) {
113            col_byte -= 1;
114        }
115        let col = line[..col_byte].chars().count();
116        Pos {
117            line: row as u32,
118            col: col as u32,
119        }
120    }
121}
122
123// ── Query ──────────────────────────────────────────────────────────
124
125impl Query for RopeBuffer {
126    fn line_count(&self) -> u32 {
127        self.row_count() as u32
128    }
129
130    fn line(&self, idx: u32) -> String {
131        let row = idx as usize;
132        let rope = self.rope();
133        // SPEC: panic on OOB rather than silently return empty.
134        if row >= rope.len_lines() {
135            panic!(
136                "Query::line: index {idx} out of bounds (line_count = {})",
137                self.row_count()
138            );
139        }
140        hjkl_buffer::rope_line_str(&rope, row)
141    }
142
143    fn len_bytes(&self) -> usize {
144        // Use cached byte_len — O(1), no allocation.
145        RopeBuffer::byte_len(self)
146    }
147
148    fn dirty_gen(&self) -> u64 {
149        RopeBuffer::dirty_gen(self)
150    }
151
152    fn content_joined(&self) -> std::sync::Arc<String> {
153        RopeBuffer::content_joined(self)
154    }
155
156    fn line_bytes(&self, row: usize) -> usize {
157        // One lock, zero allocations via rope API.
158        let rope = self.rope();
159        hjkl_buffer::rope_line_bytes(&rope, row)
160    }
161
162    fn rope(&self) -> ropey::Rope {
163        // O(1): Arc-clone of the rope root — no byte copying.
164        RopeBuffer::rope(self)
165    }
166
167    fn slice(&self, range: core::ops::Range<Pos>) -> Cow<'_, str> {
168        let start = pos_to_position(range.start);
169        let end = pos_to_position(range.end);
170        if start >= end {
171            return Cow::Borrowed("");
172        }
173        let rope = self.rope();
174        let n = rope.len_lines();
175        // Single-line slice — allocate since Buffer::rope_line_str returns owned String.
176        if start.row == end.row {
177            if start.row < n {
178                let line = hjkl_buffer::rope_line_str(&rope, start.row);
179                let lo = start.byte_offset(&line).min(line.len());
180                let hi = end.byte_offset(&line).min(line.len());
181                return Cow::Owned(line[lo..hi].to_owned());
182            }
183            return Cow::Borrowed("");
184        }
185        // Multi-line: allocate.
186        let mut out = String::new();
187        for r in start.row..=end.row.min(self.row_count().saturating_sub(1)) {
188            let line = if r < n {
189                hjkl_buffer::rope_line_str(&rope, r)
190            } else {
191                String::new()
192            };
193            if r == start.row {
194                let lo = start.byte_offset(&line).min(line.len());
195                out.push_str(&line[lo..]);
196                out.push('\n');
197            } else if r == end.row {
198                let hi = end.byte_offset(&line).min(line.len());
199                out.push_str(&line[..hi]);
200            } else {
201                out.push_str(&line);
202                out.push('\n');
203            }
204        }
205        Cow::Owned(out)
206    }
207}
208
209// ── BufferEdit ─────────────────────────────────────────────────────
210
211impl BufferEdit for RopeBuffer {
212    fn insert_at(&mut self, pos: Pos, text: &str) {
213        let at = clamp_to_buf(self, pos_to_position(pos));
214        let _ = self.apply_edit(hjkl_buffer::Edit::InsertStr {
215            at,
216            text: text.to_string(),
217        });
218    }
219
220    fn delete_range(&mut self, range: core::ops::Range<Pos>) {
221        let start = clamp_to_buf(self, pos_to_position(range.start));
222        let end = clamp_to_buf(self, pos_to_position(range.end));
223        if start >= end {
224            return;
225        }
226        let _ = self.apply_edit(hjkl_buffer::Edit::DeleteRange {
227            start,
228            end,
229            kind: hjkl_buffer::MotionKind::Char,
230        });
231    }
232
233    fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str) {
234        let start = clamp_to_buf(self, pos_to_position(range.start));
235        let end = clamp_to_buf(self, pos_to_position(range.end));
236        if start >= end {
237            // Treat as pure insert at `start`.
238            let _ = self.apply_edit(hjkl_buffer::Edit::InsertStr {
239                at: start,
240                text: replacement.to_string(),
241            });
242            return;
243        }
244        let _ = self.apply_edit(hjkl_buffer::Edit::Replace {
245            start,
246            end,
247            with: replacement.to_string(),
248        });
249    }
250
251    fn replace_all(&mut self, text: &str) {
252        // Forward to the inherent in-tree fast path which rebuilds
253        // the line vector in one pass + bumps `dirty_gen`.
254        RopeBuffer::replace_all(self, text);
255    }
256}
257
258#[inline]
259fn clamp_to_buf(buf: &RopeBuffer, p: Position) -> Position {
260    buf.clamp_position(p)
261}
262
263// ── Search ─────────────────────────────────────────────────────────
264
265impl Search for RopeBuffer {
266    fn find_next(&self, from: Pos, pat: &Regex) -> Option<core::ops::Range<Pos>> {
267        let start = pos_to_position(from);
268        let total = self.row_count();
269        if total == 0 {
270            return None;
271        }
272        // Scan the from-row from `start.col` onward, then every row
273        // after, then wrap to rows before. SPEC: "first match
274        // at-or-after `from`". 0.0.37: wrap policy now lives on the
275        // engine's `SearchState::wrap_around` (see
276        // `DESIGN_33_METHOD_CLASSIFICATION.md` step 3); the trait
277        // impl always wraps and the engine's `search_*` free
278        // functions are responsible for honouring `wrapscan` by
279        // wrapping or not invoking the trait at all.
280        let wrap = true;
281        let rope = self.rope();
282        let from_line = hjkl_buffer::rope_line_str(&rope, start.row);
283        let from_byte = start.byte_offset(&from_line).min(from_line.len());
284        if let Some(m) = pat.find_at(&from_line, from_byte) {
285            return Some(byte_range_to_pos_range(
286                start.row,
287                m.start(),
288                start.row,
289                m.end(),
290                &from_line,
291            ));
292        }
293        for offset in 1..total {
294            let row = start.row + offset;
295            if row >= total && !wrap {
296                break;
297            }
298            let row = row % total;
299            if !wrap && row <= start.row {
300                break;
301            }
302            let line = hjkl_buffer::rope_line_str(&rope, row);
303            if let Some(m) = pat.find(&line) {
304                return Some(byte_range_to_pos_range(row, m.start(), row, m.end(), &line));
305            }
306            if row == start.row {
307                break;
308            }
309        }
310        None
311    }
312
313    fn find_prev(&self, from: Pos, pat: &Regex) -> Option<core::ops::Range<Pos>> {
314        let start = pos_to_position(from);
315        let total = self.row_count();
316        if total == 0 {
317            return None;
318        }
319        // 0.0.37: wrap moved to engine SearchState; trait impl always wraps.
320        let wrap = true;
321        // Last match at-or-before `from`. We can't run the regex
322        // backwards, so iterate matches and pick the last one with
323        // start <= from-byte on the from-row, then walk previous rows
324        // taking the last match per row.
325        let rope = self.rope();
326        let from_line = hjkl_buffer::rope_line_str(&rope, start.row);
327        let from_byte = start.byte_offset(&from_line).min(from_line.len());
328        let mut best: Option<(usize, usize)> = None;
329        for m in pat.find_iter(&from_line) {
330            if m.start() <= from_byte {
331                best = Some((m.start(), m.end()));
332            } else {
333                break;
334            }
335        }
336        if let Some((s, e)) = best {
337            return Some(byte_range_to_pos_range(
338                start.row, s, start.row, e, &from_line,
339            ));
340        }
341        for offset in 1..total {
342            // Walk backwards.
343            let row = if offset > start.row {
344                if !wrap {
345                    break;
346                }
347                total - (offset - start.row)
348            } else {
349                start.row - offset
350            };
351            if !wrap && row >= start.row {
352                break;
353            }
354            let line = hjkl_buffer::rope_line_str(&rope, row);
355            let last = pat.find_iter(&line).last();
356            if let Some(m) = last {
357                return Some(byte_range_to_pos_range(row, m.start(), row, m.end(), &line));
358            }
359            if row == start.row {
360                break;
361            }
362        }
363        None
364    }
365}
366
367#[inline]
368fn byte_range_to_pos_range(
369    s_row: usize,
370    s_byte: usize,
371    e_row: usize,
372    e_byte: usize,
373    line: &str,
374) -> core::ops::Range<Pos> {
375    let s_col = line[..s_byte.min(line.len())].chars().count();
376    let e_col = line[..e_byte.min(line.len())].chars().count();
377    Pos {
378        line: s_row as u32,
379        col: s_col as u32,
380    }..Pos {
381        line: e_row as u32,
382        col: e_col as u32,
383    }
384}
385
386// ── Buffer super-trait ─────────────────────────────────────────────
387
388impl Buffer for RopeBuffer {}
389
390// ── Fold provider ──────────────────────────────────────────────────
391
392/// [`FoldProvider`] adapter wrapping a `&hjkl_buffer::Buffer`. Lets
393/// engine call sites ask the buffer's fold storage about visible
394/// rows without reaching into `Buffer::next_visible_row` &c. directly.
395///
396/// Construct with [`BufferFoldProvider::new`]. Hosts that want to
397/// expose their own fold model (a separate fold tree, LSP-derived
398/// folding ranges, …) can implement `FoldProvider` against their own
399/// state and skip this adapter entirely.
400///
401/// Introduced in 0.0.32 (Patch C-β) as part of the fold-iteration
402/// relocation. Fold *storage* still lives on the buffer for
403/// `dirty_gen` / render-cache reasons; only the iteration API moved.
404pub struct BufferFoldProvider<'a> {
405    buffer: &'a RopeBuffer,
406}
407
408impl<'a> BufferFoldProvider<'a> {
409    pub fn new(buffer: &'a RopeBuffer) -> Self {
410        Self { buffer }
411    }
412}
413
414impl FoldProvider for BufferFoldProvider<'_> {
415    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
416        // Buffer ignores the row_count hint — it knows its own size.
417        RopeBuffer::next_visible_row(self.buffer, row)
418    }
419
420    fn prev_visible_row(&self, row: usize) -> Option<usize> {
421        RopeBuffer::prev_visible_row(self.buffer, row)
422    }
423
424    fn is_row_hidden(&self, row: usize) -> bool {
425        RopeBuffer::is_row_hidden(self.buffer, row)
426    }
427
428    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
429        let f = self.buffer.fold_at_row(row)?;
430        Some((f.start_row, f.end_row, f.closed))
431    }
432
433    // `apply` / `invalidate_range` use the trait's default no-op impl
434    // because `BufferFoldProvider` only borrows the buffer immutably.
435    // For fold mutation, use [`BufferFoldProviderMut`] instead.
436}
437
438/// Mutable [`FoldProvider`] adapter wrapping a `&mut hjkl_buffer::Buffer`.
439/// Engine call sites that need to dispatch a [`FoldOp`] (vim's `z…`
440/// keystrokes, the `:fold*` Ex commands, edit-pipeline invalidation)
441/// construct this on the fly from `&mut self.buffer` and call
442/// [`FoldProvider::apply`] / [`FoldProvider::invalidate_range`] on it.
443///
444/// Introduced in 0.0.38 (Patch C-δ.4) as part of routing fold mutation
445/// through the [`FoldProvider`] surface. Fold *storage* still lives
446/// on [`hjkl_buffer::Buffer`] for `dirty_gen` / render-cache reasons;
447/// only the dispatch path moved.
448pub struct BufferFoldProviderMut<'a> {
449    buffer: &'a mut RopeBuffer,
450}
451
452impl<'a> BufferFoldProviderMut<'a> {
453    pub fn new(buffer: &'a mut RopeBuffer) -> Self {
454        Self { buffer }
455    }
456}
457
458impl FoldProvider for BufferFoldProviderMut<'_> {
459    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
460        RopeBuffer::next_visible_row(self.buffer, row)
461    }
462
463    fn prev_visible_row(&self, row: usize) -> Option<usize> {
464        RopeBuffer::prev_visible_row(self.buffer, row)
465    }
466
467    fn is_row_hidden(&self, row: usize) -> bool {
468        RopeBuffer::is_row_hidden(self.buffer, row)
469    }
470
471    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
472        let f = self.buffer.fold_at_row(row)?;
473        Some((f.start_row, f.end_row, f.closed))
474    }
475
476    fn apply(&mut self, op: FoldOp) {
477        match op {
478            FoldOp::Add {
479                start_row,
480                end_row,
481                closed,
482            } => {
483                self.buffer.add_fold(start_row, end_row, closed);
484            }
485            FoldOp::RemoveAt(row) => {
486                self.buffer.remove_fold_at(row);
487            }
488            FoldOp::OpenAt(row) => {
489                self.buffer.open_fold_at(row);
490            }
491            FoldOp::CloseAt(row) => {
492                self.buffer.close_fold_at(row);
493            }
494            FoldOp::ToggleAt(row) => {
495                self.buffer.toggle_fold_at(row);
496            }
497            FoldOp::OpenAll => {
498                self.buffer.open_all_folds();
499            }
500            FoldOp::CloseAll => {
501                self.buffer.close_all_folds();
502            }
503            FoldOp::ClearAll => {
504                self.buffer.clear_all_folds();
505            }
506            FoldOp::Invalidate { start_row, end_row } => {
507                self.buffer.invalidate_folds_in_range(start_row, end_row);
508            }
509        }
510    }
511
512    fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
513        self.buffer.invalidate_folds_in_range(start_row, end_row);
514    }
515}
516
517/// Owned-snapshot [`FoldProvider`] adapter. Carries a copy of the
518/// buffer's fold list (one `Vec<Fold>` clone — fold lists are tiny in
519/// practice) plus the buffer's `row_count`, so the call site can hold
520/// the snapshot for fold queries while passing `&mut hjkl_buffer::Buffer`
521/// to a motion function that needs cursor mutation.
522///
523/// Introduced in 0.0.40 (Patch C-δ.5) so the lifted motion fns can
524/// take `&dyn FoldProvider` separately from `&mut B: Cursor + Query`
525/// without the call site running into the immutable-vs-mutable
526/// borrow conflict that arises with [`BufferFoldProvider`] /
527/// [`BufferFoldProviderMut`] (both of which hold a buffer borrow).
528///
529/// The snapshot is read-only — `apply` and `invalidate_range` are
530/// no-ops (any fold mutation must go through the canonical
531/// [`BufferFoldProviderMut`] adapter against the live buffer).
532pub struct SnapshotFoldProvider {
533    folds: Vec<hjkl_buffer::Fold>,
534    row_count: usize,
535}
536
537impl SnapshotFoldProvider {
538    /// Snapshot the current fold list + row-count from `buffer`.
539    /// The snapshot is decoupled from the buffer's lifetime, so the
540    /// caller can immediately re-borrow the buffer mutably.
541    pub fn from_buffer(buffer: &RopeBuffer) -> Self {
542        Self {
543            folds: buffer.folds().to_vec(),
544            row_count: buffer.row_count(),
545        }
546    }
547
548    /// True iff `row` is hidden by any closed fold in the snapshot.
549    /// Mirrors [`hjkl_buffer::Buffer::is_row_hidden`] over the
550    /// snapshotted fold list.
551    fn snapshot_is_row_hidden(&self, row: usize) -> bool {
552        self.folds.iter().any(|f| f.hides(row))
553    }
554}
555
556impl FoldProvider for SnapshotFoldProvider {
557    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
558        // Mirrors [`hjkl_buffer::Buffer::next_visible_row`]: walk
559        // forward, skipping closed-fold-hidden rows, stop at end.
560        let last = self.row_count.saturating_sub(1);
561        if last == 0 && row == 0 {
562            return None;
563        }
564        let mut r = row.checked_add(1)?;
565        while r <= last && self.snapshot_is_row_hidden(r) {
566            r += 1;
567        }
568        (r <= last).then_some(r)
569    }
570
571    fn prev_visible_row(&self, row: usize) -> Option<usize> {
572        // Mirrors [`hjkl_buffer::Buffer::prev_visible_row`].
573        let mut r = row.checked_sub(1)?;
574        while self.snapshot_is_row_hidden(r) {
575            r = r.checked_sub(1)?;
576        }
577        Some(r)
578    }
579
580    fn is_row_hidden(&self, row: usize) -> bool {
581        self.snapshot_is_row_hidden(row)
582    }
583
584    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
585        self.folds
586            .iter()
587            .find(|f| f.contains(row))
588            .map(|f| (f.start_row, f.end_row, f.closed))
589    }
590
591    // `apply` / `invalidate_range` use the trait's default no-op impl.
592}
593
594// ── Tests ──────────────────────────────────────────────────────────
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    /// Compile-time check: the in-tree `hjkl_buffer::Buffer` satisfies
601    /// the SPEC `Buffer` super-trait (and therefore all four sub-traits).
602    /// If this stops compiling, the trait surface diverged from the
603    /// canonical impl — fix the impl, not this assertion.
604    #[test]
605    fn rope_buffer_implements_spec_buffer() {
606        fn assert_buffer<B: Buffer>() {}
607        fn assert_cursor<B: Cursor>() {}
608        fn assert_query<B: Query>() {}
609        fn assert_edit<B: BufferEdit>() {}
610        fn assert_search<B: Search>() {}
611        assert_buffer::<RopeBuffer>();
612        assert_cursor::<RopeBuffer>();
613        assert_query::<RopeBuffer>();
614        assert_edit::<RopeBuffer>();
615        assert_search::<RopeBuffer>();
616    }
617
618    #[test]
619    fn cursor_roundtrip() {
620        let mut b = RopeBuffer::from_str("hello\nworld");
621        Cursor::set_cursor(&mut b, Pos::new(1, 3));
622        assert_eq!(Cursor::cursor(&b), Pos::new(1, 3));
623    }
624
625    #[test]
626    fn query_line_count_and_line() {
627        let b = RopeBuffer::from_str("a\nb\nc");
628        assert_eq!(Query::line_count(&b), 3);
629        assert_eq!(Query::line(&b, 0), "a");
630        assert_eq!(Query::line(&b, 2), "c");
631    }
632
633    #[test]
634    fn query_len_bytes_matches_join() {
635        let b = RopeBuffer::from_str("foo\nbar\nbaz");
636        assert_eq!(Query::len_bytes(&b), b.as_string().len());
637    }
638
639    #[test]
640    fn query_slice_single_line_borrows() {
641        let b = RopeBuffer::from_str("hello world");
642        let s = Query::slice(&b, Pos::new(0, 0)..Pos::new(0, 5));
643        assert_eq!(&*s, "hello");
644        // Buffer::line now returns owned String; single-line slice is Owned.
645        assert!(matches!(s, Cow::Owned(_)));
646    }
647
648    #[test]
649    fn query_slice_multiline_allocates() {
650        let b = RopeBuffer::from_str("ab\ncd\nef");
651        let s = Query::slice(&b, Pos::new(0, 1)..Pos::new(2, 1));
652        assert_eq!(&*s, "b\ncd\ne");
653        assert!(matches!(s, Cow::Owned(_)));
654    }
655
656    #[test]
657    fn cursor_byte_offset_and_inverse() {
658        let b = RopeBuffer::from_str("hello\nworld");
659        // Start of row 1 = 6 bytes ('h','e','l','l','o','\n').
660        let p = Pos::new(1, 0);
661        assert_eq!(Cursor::byte_offset(&b, p), 6);
662        assert_eq!(Cursor::pos_at_byte(&b, 6), p);
663        // Roundtrip mid-line.
664        let p2 = Pos::new(1, 3);
665        let off = Cursor::byte_offset(&b, p2);
666        assert_eq!(Cursor::pos_at_byte(&b, off), p2);
667    }
668
669    #[test]
670    fn buffer_edit_insert_delete_replace() {
671        let mut b = RopeBuffer::from_str("hello");
672        BufferEdit::insert_at(&mut b, Pos::new(0, 5), " world");
673        assert_eq!(b.as_string(), "hello world");
674        BufferEdit::delete_range(&mut b, Pos::new(0, 5)..Pos::new(0, 11));
675        assert_eq!(b.as_string(), "hello");
676        BufferEdit::replace_range(&mut b, Pos::new(0, 0)..Pos::new(0, 5), "HI");
677        assert_eq!(b.as_string(), "HI");
678    }
679
680    /// Default `BufferEdit::replace_all` impl forwards to
681    /// `replace_range(ORIGIN..MAX, text)`. Non-canonical backends that
682    /// don't override `replace_all` rely on this; locked in here with
683    /// a minimal mock that records the calls.
684    #[test]
685    fn buffer_edit_default_replace_all_routes_through_replace_range() {
686        struct MockBuf {
687            cursor: Pos,
688            lines: Vec<String>,
689            last_replace_range: Option<core::ops::Range<Pos>>,
690        }
691        impl Sealed for MockBuf {}
692        impl Cursor for MockBuf {
693            fn cursor(&self) -> Pos {
694                self.cursor
695            }
696            fn set_cursor(&mut self, p: Pos) {
697                self.cursor = p;
698            }
699            fn byte_offset(&self, _p: Pos) -> usize {
700                0
701            }
702            fn pos_at_byte(&self, _b: usize) -> Pos {
703                Pos::ORIGIN
704            }
705        }
706        impl Query for MockBuf {
707            fn line_count(&self) -> u32 {
708                self.lines.len() as u32
709            }
710            fn line(&self, idx: u32) -> String {
711                self.lines[idx as usize].clone()
712            }
713            fn len_bytes(&self) -> usize {
714                0
715            }
716            fn slice(&self, _r: core::ops::Range<Pos>) -> Cow<'_, str> {
717                Cow::Borrowed("")
718            }
719        }
720        impl BufferEdit for MockBuf {
721            fn insert_at(&mut self, _p: Pos, _t: &str) {}
722            fn delete_range(&mut self, _r: core::ops::Range<Pos>) {}
723            fn replace_range(&mut self, range: core::ops::Range<Pos>, _t: &str) {
724                self.last_replace_range = Some(range);
725            }
726        }
727        impl Search for MockBuf {
728            fn find_next(&self, _f: Pos, _p: &Regex) -> Option<core::ops::Range<Pos>> {
729                None
730            }
731            fn find_prev(&self, _f: Pos, _p: &Regex) -> Option<core::ops::Range<Pos>> {
732                None
733            }
734        }
735        impl Buffer for MockBuf {}
736
737        let mut m = MockBuf {
738            cursor: Pos::ORIGIN,
739            lines: vec!["hi".into()],
740            last_replace_range: None,
741        };
742        BufferEdit::replace_all(&mut m, "new content");
743        let r = m
744            .last_replace_range
745            .expect("default impl must hit replace_range");
746        assert_eq!(r.start, Pos::ORIGIN);
747        assert_eq!(r.end.line, u32::MAX);
748        assert_eq!(r.end.col, u32::MAX);
749    }
750
751    #[test]
752    fn buffer_edit_replace_all_rebuilds_content() {
753        let mut b = RopeBuffer::from_str("hello\nworld");
754        Cursor::set_cursor(&mut b, Pos::new(1, 3));
755        BufferEdit::replace_all(&mut b, "alpha\nbeta\ngamma");
756        assert_eq!(b.as_string(), "alpha\nbeta\ngamma");
757        assert_eq!(Query::line_count(&b), 3);
758        // Cursor clamped to surviving content (`replace_all` invariant).
759        let c = Cursor::cursor(&b);
760        assert!((c.line as usize) < Query::line_count(&b) as usize);
761    }
762
763    #[test]
764    fn search_find_next_same_row() {
765        let b = RopeBuffer::from_str("abc def abc");
766        let pat = Regex::new("abc").unwrap();
767        let r = Search::find_next(&b, Pos::new(0, 0), &pat).unwrap();
768        assert_eq!(r, Pos::new(0, 0)..Pos::new(0, 3));
769        let r2 = Search::find_next(&b, Pos::new(0, 1), &pat).unwrap();
770        assert_eq!(r2, Pos::new(0, 8)..Pos::new(0, 11));
771    }
772
773    #[test]
774    fn search_find_next_wraps() {
775        let b = RopeBuffer::from_str("foo\nbar\nfoo");
776        // 0.0.37: wrap policy moved to engine `SearchState::wrap_around`.
777        // The trait impl always wraps; engine code that wants
778        // non-wrap semantics short-circuits before invoking the trait.
779        let pat = Regex::new("foo").unwrap();
780        // Starting on row 1: should find row 2's "foo".
781        let r = Search::find_next(&b, Pos::new(1, 0), &pat).unwrap();
782        assert_eq!(r, Pos::new(2, 0)..Pos::new(2, 3));
783    }
784
785    #[test]
786    fn search_find_prev_same_row() {
787        let b = RopeBuffer::from_str("abc def abc");
788        let pat = Regex::new("abc").unwrap();
789        let r = Search::find_prev(&b, Pos::new(0, 11), &pat).unwrap();
790        assert_eq!(r, Pos::new(0, 8)..Pos::new(0, 11));
791    }
792
793    #[test]
794    fn pos_position_roundtrip() {
795        let p = Pos::new(7, 3);
796        assert_eq!(position_to_pos(pos_to_position(p)), p);
797    }
798
799    // ── BufferFoldProviderMut dispatch (0.0.38, Patch C-δ.4) ───────
800
801    #[test]
802    fn fold_provider_mut_apply_add_open_close_toggle() {
803        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
804        {
805            let mut p = BufferFoldProviderMut::new(&mut buf);
806            p.apply(FoldOp::Add {
807                start_row: 1,
808                end_row: 3,
809                closed: true,
810            });
811            assert_eq!(p.fold_at_row(2), Some((1, 3, true)));
812            p.apply(FoldOp::OpenAt(2));
813            assert_eq!(p.fold_at_row(2), Some((1, 3, false)));
814            p.apply(FoldOp::CloseAt(2));
815            assert_eq!(p.fold_at_row(2), Some((1, 3, true)));
816            p.apply(FoldOp::ToggleAt(2));
817            assert_eq!(p.fold_at_row(2), Some((1, 3, false)));
818        }
819        assert_eq!(buf.folds().len(), 1);
820    }
821
822    #[test]
823    fn fold_provider_mut_apply_open_close_clear_all() {
824        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
825        buf.add_fold(0, 1, false);
826        buf.add_fold(2, 3, true);
827        {
828            let mut p = BufferFoldProviderMut::new(&mut buf);
829            p.apply(FoldOp::CloseAll);
830        }
831        assert!(buf.folds().iter().all(|f| f.closed));
832        {
833            let mut p = BufferFoldProviderMut::new(&mut buf);
834            p.apply(FoldOp::OpenAll);
835        }
836        assert!(buf.folds().iter().all(|f| !f.closed));
837        {
838            let mut p = BufferFoldProviderMut::new(&mut buf);
839            p.apply(FoldOp::ClearAll);
840        }
841        assert!(buf.folds().is_empty());
842    }
843
844    #[test]
845    fn fold_provider_mut_invalidate_range_drops_overlapping() {
846        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
847        buf.add_fold(0, 1, true);
848        buf.add_fold(2, 3, true);
849        buf.add_fold(4, 4, true);
850        {
851            let mut p = BufferFoldProviderMut::new(&mut buf);
852            p.invalidate_range(2, 3);
853        }
854        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
855        assert_eq!(starts, vec![0, 4]);
856    }
857
858    #[test]
859    fn fold_provider_mut_apply_remove_at() {
860        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
861        buf.add_fold(1, 3, true);
862        {
863            let mut p = BufferFoldProviderMut::new(&mut buf);
864            p.apply(FoldOp::RemoveAt(2));
865        }
866        assert!(buf.folds().is_empty());
867    }
868
869    #[test]
870    fn noop_fold_provider_apply_is_noop() {
871        // The default `apply` impl on the trait is a no-op; verify
872        // NoopFoldProvider inherits it without panicking.
873        let mut p = crate::types::NoopFoldProvider;
874        FoldProvider::apply(&mut p, FoldOp::OpenAll);
875        FoldProvider::invalidate_range(&mut p, 0, 5);
876        // Read methods unaffected.
877        assert!(!FoldProvider::is_row_hidden(&p, 3));
878    }
879}