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