Skip to main content

hjkl_engine/
buffer_impl.rs

1//! Canonical [`View`] trait impl over [`hjkl_buffer::View`].
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::View` — most of them are
14//! motion / fold / viewport helpers that don't belong on `View`
15//! (they're computed over the buffer, not delegated to it). Generic-ifying
16//! `Editor<B: View, 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::View` satisfies the trait, so
23//! downstream callers can write `fn f<B: hjkl_engine::View>(…)`
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::Position;
30use hjkl_buffer::View as RopeBuffer;
31use regex::Regex;
32
33use crate::types::sealed::Sealed;
34use crate::types::{BufferEdit, Cursor, FoldOp, FoldProvider, Pos, Query, Search, View};
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/// View [`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 View::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        // The buffer's true last row — it never has a trailing newline
188        // (vim invariant), so a row equal to `last_row` must never get
189        // a fabricated `\n` appended.
190        let last_row = self.row_count().saturating_sub(1);
191        // Clamp the end row for iteration, but remember whether the
192        // *original* end row was in-bounds: only an in-bounds end row
193        // is a genuine mid-line cut. An out-of-bounds (past-end
194        // sentinel) end row means "through the end of the buffer",
195        // which must fall through to the full-line branch below
196        // instead of comparing against a phantom row that can never
197        // be reached by the clamped loop.
198        let end_row = end.row.min(last_row);
199        let end_in_bounds = end.row <= last_row;
200        for r in start.row..=end_row {
201            let line = if r < n {
202                hjkl_buffer::rope_line_str(&rope, r)
203            } else {
204                String::new()
205            };
206            let lo = if r == start.row {
207                start.byte_offset(&line).min(line.len())
208            } else {
209                0
210            };
211            if r == end_row && end_in_bounds {
212                let hi = end.byte_offset(&line).min(line.len()).max(lo);
213                out.push_str(&line[lo..hi]);
214            } else {
215                out.push_str(&line[lo..]);
216                if r < last_row {
217                    out.push('\n');
218                }
219            }
220        }
221        Cow::Owned(out)
222    }
223}
224
225// ── BufferEdit ─────────────────────────────────────────────────────
226
227impl BufferEdit for RopeBuffer {
228    fn insert_at(&mut self, pos: Pos, text: &str) {
229        let at = clamp_to_buf(self, pos_to_position(pos));
230        let _ = self.apply_edit(hjkl_buffer::Edit::InsertStr {
231            at,
232            text: text.to_string(),
233        });
234    }
235
236    fn delete_range(&mut self, range: core::ops::Range<Pos>) {
237        let start = clamp_to_buf(self, pos_to_position(range.start));
238        let end = clamp_to_buf(self, pos_to_position(range.end));
239        if start >= end {
240            return;
241        }
242        let _ = self.apply_edit(hjkl_buffer::Edit::DeleteRange {
243            start,
244            end,
245            kind: hjkl_buffer::MotionKind::Char,
246        });
247    }
248
249    fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str) {
250        let start = clamp_to_buf(self, pos_to_position(range.start));
251        let end = clamp_to_buf(self, pos_to_position(range.end));
252        if start >= end {
253            // Treat as pure insert at `start`.
254            let _ = self.apply_edit(hjkl_buffer::Edit::InsertStr {
255                at: start,
256                text: replacement.to_string(),
257            });
258            return;
259        }
260        let _ = self.apply_edit(hjkl_buffer::Edit::Replace {
261            start,
262            end,
263            with: replacement.to_string(),
264        });
265    }
266
267    fn replace_all(&mut self, text: &str) {
268        // Forward to the inherent in-tree fast path which rebuilds
269        // the line vector in one pass + bumps `dirty_gen`.
270        RopeBuffer::replace_all(self, text);
271    }
272}
273
274#[inline]
275fn clamp_to_buf(buf: &RopeBuffer, p: Position) -> Position {
276    buf.clamp_position(p)
277}
278
279// ── Search ─────────────────────────────────────────────────────────
280
281impl Search for RopeBuffer {
282    fn find_next(&self, from: Pos, pat: &Regex) -> Option<core::ops::Range<Pos>> {
283        let start = pos_to_position(from);
284        let total = self.row_count();
285        if total == 0 {
286            return None;
287        }
288        // Scan the from-row from `start.col` onward, then every row
289        // after, then wrap to rows before. SPEC: "first match
290        // at-or-after `from`". 0.0.37: wrap policy now lives on the
291        // engine's `SearchState::wrap_around` (see
292        // `DESIGN_33_METHOD_CLASSIFICATION.md` step 3); the trait
293        // impl always wraps and the engine's `search_*` free
294        // functions are responsible for honouring `wrapscan` by
295        // wrapping or not invoking the trait at all.
296        let wrap = true;
297        let rope = self.rope();
298        let from_line = hjkl_buffer::rope_line_str(&rope, start.row);
299        let from_byte = start.byte_offset(&from_line).min(from_line.len());
300        if let Some(m) = pat.find_at(&from_line, from_byte) {
301            return Some(byte_range_to_pos_range(
302                start.row,
303                m.start(),
304                start.row,
305                m.end(),
306                &from_line,
307            ));
308        }
309        for offset in 1..total {
310            let row = start.row + offset;
311            if row >= total && !wrap {
312                break;
313            }
314            let row = row % total;
315            if !wrap && row <= start.row {
316                break;
317            }
318            let line = hjkl_buffer::rope_line_str(&rope, row);
319            if let Some(m) = pat.find(&line) {
320                return Some(byte_range_to_pos_range(row, m.start(), row, m.end(), &line));
321            }
322            if row == start.row {
323                break;
324            }
325        }
326        None
327    }
328
329    fn find_prev(&self, from: Pos, pat: &Regex) -> Option<core::ops::Range<Pos>> {
330        let start = pos_to_position(from);
331        let total = self.row_count();
332        if total == 0 {
333            return None;
334        }
335        // 0.0.37: wrap moved to engine SearchState; trait impl always wraps.
336        let wrap = true;
337        // Last match at-or-before `from`. We can't run the regex
338        // backwards, so iterate matches and pick the last one with
339        // start <= from-byte on the from-row, then walk previous rows
340        // taking the last match per row.
341        let rope = self.rope();
342        let from_line = hjkl_buffer::rope_line_str(&rope, start.row);
343        let from_byte = start.byte_offset(&from_line).min(from_line.len());
344        let mut best: Option<(usize, usize)> = None;
345        for m in pat.find_iter(&from_line) {
346            if m.start() <= from_byte {
347                best = Some((m.start(), m.end()));
348            } else {
349                break;
350            }
351        }
352        if let Some((s, e)) = best {
353            return Some(byte_range_to_pos_range(
354                start.row, s, start.row, e, &from_line,
355            ));
356        }
357        for offset in 1..total {
358            // Walk backwards.
359            let row = if offset > start.row {
360                if !wrap {
361                    break;
362                }
363                total - (offset - start.row)
364            } else {
365                start.row - offset
366            };
367            if !wrap && row >= start.row {
368                break;
369            }
370            let line = hjkl_buffer::rope_line_str(&rope, row);
371            let last = pat.find_iter(&line).last();
372            if let Some(m) = last {
373                return Some(byte_range_to_pos_range(row, m.start(), row, m.end(), &line));
374            }
375            if row == start.row {
376                break;
377            }
378        }
379        None
380    }
381}
382
383#[inline]
384fn byte_range_to_pos_range(
385    s_row: usize,
386    s_byte: usize,
387    e_row: usize,
388    e_byte: usize,
389    line: &str,
390) -> core::ops::Range<Pos> {
391    let s_col = line[..s_byte.min(line.len())].chars().count();
392    let e_col = line[..e_byte.min(line.len())].chars().count();
393    Pos {
394        line: s_row as u32,
395        col: s_col as u32,
396    }..Pos {
397        line: e_row as u32,
398        col: e_col as u32,
399    }
400}
401
402// ── View super-trait ─────────────────────────────────────────────
403
404impl View for RopeBuffer {}
405
406// ── Fold provider ──────────────────────────────────────────────────
407
408/// [`FoldProvider`] adapter wrapping a `&hjkl_buffer::View`. Lets
409/// engine call sites ask the buffer's fold storage about visible
410/// rows without reaching into `View::next_visible_row` &c. directly.
411///
412/// Construct with [`BufferFoldProvider::new`]. Hosts that want to
413/// expose their own fold model (a separate fold tree, LSP-derived
414/// folding ranges, …) can implement `FoldProvider` against their own
415/// state and skip this adapter entirely.
416///
417/// Introduced in 0.0.32 (Patch C-β) as part of the fold-iteration
418/// relocation. Fold *storage* still lives on the buffer for
419/// `dirty_gen` / render-cache reasons; only the iteration API moved.
420pub struct BufferFoldProvider<'a> {
421    buffer: &'a RopeBuffer,
422}
423
424impl<'a> BufferFoldProvider<'a> {
425    pub fn new(buffer: &'a RopeBuffer) -> Self {
426        Self { buffer }
427    }
428}
429
430impl FoldProvider for BufferFoldProvider<'_> {
431    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
432        // View ignores the row_count hint — it knows its own size.
433        RopeBuffer::next_visible_row(self.buffer, row)
434    }
435
436    fn prev_visible_row(&self, row: usize) -> Option<usize> {
437        RopeBuffer::prev_visible_row(self.buffer, row)
438    }
439
440    fn is_row_hidden(&self, row: usize) -> bool {
441        RopeBuffer::is_row_hidden(self.buffer, row)
442    }
443
444    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
445        let f = self.buffer.fold_at_row(row)?;
446        Some((f.start_row, f.end_row, f.closed))
447    }
448
449    // `apply` / `invalidate_range` use the trait's default no-op impl
450    // because `BufferFoldProvider` only borrows the buffer immutably.
451    // For fold mutation, use [`BufferFoldProviderMut`] instead.
452}
453
454/// Mutable [`FoldProvider`] adapter wrapping a `&mut hjkl_buffer::View`.
455/// Engine call sites that need to dispatch a [`FoldOp`] (vim's `z…`
456/// keystrokes, the `:fold*` Ex commands, edit-pipeline invalidation)
457/// construct this on the fly from `&mut self.buffer` and call
458/// [`FoldProvider::apply`] / [`FoldProvider::invalidate_range`] on it.
459///
460/// Introduced in 0.0.38 (Patch C-δ.4) as part of routing fold mutation
461/// through the [`FoldProvider`] surface. Fold *storage* still lives
462/// on [`hjkl_buffer::View`] for `dirty_gen` / render-cache reasons;
463/// only the dispatch path moved.
464pub struct BufferFoldProviderMut<'a> {
465    buffer: &'a mut RopeBuffer,
466}
467
468impl<'a> BufferFoldProviderMut<'a> {
469    pub fn new(buffer: &'a mut RopeBuffer) -> Self {
470        Self { buffer }
471    }
472}
473
474impl FoldProvider for BufferFoldProviderMut<'_> {
475    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
476        RopeBuffer::next_visible_row(self.buffer, row)
477    }
478
479    fn prev_visible_row(&self, row: usize) -> Option<usize> {
480        RopeBuffer::prev_visible_row(self.buffer, row)
481    }
482
483    fn is_row_hidden(&self, row: usize) -> bool {
484        RopeBuffer::is_row_hidden(self.buffer, row)
485    }
486
487    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
488        let f = self.buffer.fold_at_row(row)?;
489        Some((f.start_row, f.end_row, f.closed))
490    }
491
492    fn apply(&mut self, op: FoldOp) {
493        match op {
494            FoldOp::Add {
495                start_row,
496                end_row,
497                closed,
498            } => {
499                self.buffer.add_fold(start_row, end_row, closed);
500            }
501            FoldOp::RemoveAt(row) => {
502                self.buffer.remove_fold_at(row);
503            }
504            FoldOp::OpenAt(row) => {
505                self.buffer.open_fold_at(row);
506            }
507            FoldOp::CloseAt(row) => {
508                self.buffer.close_fold_at(row);
509            }
510            FoldOp::ToggleAt(row) => {
511                self.buffer.toggle_fold_at(row);
512            }
513            FoldOp::OpenAll => {
514                self.buffer.open_all_folds();
515            }
516            FoldOp::CloseAll => {
517                self.buffer.close_all_folds();
518            }
519            FoldOp::ClearAll => {
520                self.buffer.clear_all_folds();
521            }
522            FoldOp::Invalidate { start_row, end_row } => {
523                self.buffer.invalidate_folds_in_range(start_row, end_row);
524            }
525            // FoldOp is #[non_exhaustive] — new variants added in hjkl-buffer
526            // are silently ignored here until buffer_impl is updated.
527            _ => {}
528        }
529    }
530
531    fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
532        self.buffer.invalidate_folds_in_range(start_row, end_row);
533    }
534}
535
536/// Owned-snapshot [`FoldProvider`] adapter. Carries a copy of the
537/// buffer's fold list (one `Vec<Fold>` clone — fold lists are tiny in
538/// practice) plus the buffer's `row_count`, so the call site can hold
539/// the snapshot for fold queries while passing `&mut hjkl_buffer::View`
540/// to a motion function that needs cursor mutation.
541///
542/// Introduced in 0.0.40 (Patch C-δ.5) so the lifted motion fns can
543/// take `&dyn FoldProvider` separately from `&mut B: Cursor + Query`
544/// without the call site running into the immutable-vs-mutable
545/// borrow conflict that arises with [`BufferFoldProvider`] /
546/// [`BufferFoldProviderMut`] (both of which hold a buffer borrow).
547///
548/// The snapshot is read-only — `apply` and `invalidate_range` are
549/// no-ops (any fold mutation must go through the canonical
550/// [`BufferFoldProviderMut`] adapter against the live buffer).
551pub struct SnapshotFoldProvider {
552    folds: Vec<hjkl_buffer::Fold>,
553    row_count: usize,
554}
555
556impl SnapshotFoldProvider {
557    /// Snapshot the current fold list + row-count from `buffer`.
558    /// The snapshot is decoupled from the buffer's lifetime, so the
559    /// caller can immediately re-borrow the buffer mutably.
560    ///
561    /// `row_count` is clamped to skip vim's single phantom trailing
562    /// empty row — `ropey`'s `len_lines()` always synthesizes one
563    /// extra empty final "line" when the buffer text ends in `\n`.
564    /// Without this, `j`/`k` (which route through this snapshot, not
565    /// [`hjkl_buffer::View::next_visible_row`] directly) could step
566    /// the cursor onto that phantom row. Mirrors
567    /// `hjkl_engine::motions::move_bottom`'s clamp (`G`).
568    pub fn from_buffer(buffer: &RopeBuffer) -> Self {
569        let raw_count = buffer.row_count();
570        let raw_last = raw_count.saturating_sub(1);
571        let row_count =
572            if raw_last > 0 && hjkl_buffer::rope_line_str(&buffer.rope(), raw_last).is_empty() {
573                raw_last
574            } else {
575                raw_count
576            };
577        Self {
578            folds: buffer.folds().to_vec(),
579            row_count,
580        }
581    }
582
583    /// True iff `row` is hidden by any closed fold in the snapshot.
584    /// Mirrors [`hjkl_buffer::View::is_row_hidden`] over the
585    /// snapshotted fold list.
586    fn snapshot_is_row_hidden(&self, row: usize) -> bool {
587        self.folds.iter().any(|f| f.hides(row))
588    }
589}
590
591impl FoldProvider for SnapshotFoldProvider {
592    fn next_visible_row(&self, row: usize, _row_count: usize) -> Option<usize> {
593        // Mirrors [`hjkl_buffer::View::next_visible_row`]: walk
594        // forward, skipping closed-fold-hidden rows, stop at end.
595        let last = self.row_count.saturating_sub(1);
596        if last == 0 && row == 0 {
597            return None;
598        }
599        let mut r = row.checked_add(1)?;
600        while r <= last && self.snapshot_is_row_hidden(r) {
601            r += 1;
602        }
603        (r <= last).then_some(r)
604    }
605
606    fn prev_visible_row(&self, row: usize) -> Option<usize> {
607        // Mirrors [`hjkl_buffer::View::prev_visible_row`].
608        let mut r = row.checked_sub(1)?;
609        while self.snapshot_is_row_hidden(r) {
610            r = r.checked_sub(1)?;
611        }
612        Some(r)
613    }
614
615    fn is_row_hidden(&self, row: usize) -> bool {
616        self.snapshot_is_row_hidden(row)
617    }
618
619    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)> {
620        self.folds
621            .iter()
622            .find(|f| f.contains(row))
623            .map(|f| (f.start_row, f.end_row, f.closed))
624    }
625
626    // `apply` / `invalidate_range` use the trait's default no-op impl.
627}
628
629// ── Tests ──────────────────────────────────────────────────────────
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    /// Compile-time check: the in-tree `hjkl_buffer::View` satisfies
636    /// the SPEC `View` super-trait (and therefore all four sub-traits).
637    /// If this stops compiling, the trait surface diverged from the
638    /// canonical impl — fix the impl, not this assertion.
639    #[test]
640    fn rope_buffer_implements_spec_buffer() {
641        fn assert_buffer<B: View>() {}
642        fn assert_cursor<B: Cursor>() {}
643        fn assert_query<B: Query>() {}
644        fn assert_edit<B: BufferEdit>() {}
645        fn assert_search<B: Search>() {}
646        assert_buffer::<RopeBuffer>();
647        assert_cursor::<RopeBuffer>();
648        assert_query::<RopeBuffer>();
649        assert_edit::<RopeBuffer>();
650        assert_search::<RopeBuffer>();
651    }
652
653    #[test]
654    fn cursor_roundtrip() {
655        let mut b = RopeBuffer::from_str("hello\nworld");
656        Cursor::set_cursor(&mut b, Pos::new(1, 3));
657        assert_eq!(Cursor::cursor(&b), Pos::new(1, 3));
658    }
659
660    #[test]
661    fn query_line_count_and_line() {
662        let b = RopeBuffer::from_str("a\nb\nc");
663        assert_eq!(Query::line_count(&b), 3);
664        assert_eq!(Query::line(&b, 0), "a");
665        assert_eq!(Query::line(&b, 2), "c");
666    }
667
668    #[test]
669    fn query_len_bytes_matches_join() {
670        let b = RopeBuffer::from_str("foo\nbar\nbaz");
671        assert_eq!(Query::len_bytes(&b), b.as_string().len());
672    }
673
674    #[test]
675    fn query_slice_single_line_borrows() {
676        let b = RopeBuffer::from_str("hello world");
677        let s = Query::slice(&b, Pos::new(0, 0)..Pos::new(0, 5));
678        assert_eq!(&*s, "hello");
679        // View::line now returns owned String; single-line slice is Owned.
680        assert!(matches!(s, Cow::Owned(_)));
681    }
682
683    #[test]
684    fn query_slice_multiline_allocates() {
685        let b = RopeBuffer::from_str("ab\ncd\nef");
686        let s = Query::slice(&b, Pos::new(0, 1)..Pos::new(2, 1));
687        assert_eq!(&*s, "b\ncd\ne");
688        assert!(matches!(s, Cow::Owned(_)));
689    }
690
691    /// Regression for audit finding A9: an end row past the buffer's
692    /// last row (a "through end of buffer" sentinel, e.g. `u32::MAX`
693    /// as used by `BufferEdit::replace_all`'s default range) used to
694    /// compare against the *unclamped* end row, so the clamped loop
695    /// never hit the mid-line-cut branch and instead fell through to
696    /// the "full line + \n" branch on the buffer's real last row —
697    /// fabricating a trailing newline that isn't in the source text
698    /// (the last line of a buffer never has one). The slice must stop
699    /// exactly at the end of the real content, with no spurious `\n`.
700    #[test]
701    fn query_slice_past_end_sentinel_row_has_no_spurious_trailing_newline() {
702        let b = RopeBuffer::from_str("ab\ncd\nef");
703        let s = Query::slice(&b, Pos::new(0, 1)..Pos::new(u32::MAX, u32::MAX));
704        assert_eq!(&*s, "b\ncd\nef");
705        assert!(!s.ends_with('\n'));
706    }
707
708    #[test]
709    fn cursor_byte_offset_and_inverse() {
710        let b = RopeBuffer::from_str("hello\nworld");
711        // Start of row 1 = 6 bytes ('h','e','l','l','o','\n').
712        let p = Pos::new(1, 0);
713        assert_eq!(Cursor::byte_offset(&b, p), 6);
714        assert_eq!(Cursor::pos_at_byte(&b, 6), p);
715        // Roundtrip mid-line.
716        let p2 = Pos::new(1, 3);
717        let off = Cursor::byte_offset(&b, p2);
718        assert_eq!(Cursor::pos_at_byte(&b, off), p2);
719    }
720
721    #[test]
722    fn buffer_edit_insert_delete_replace() {
723        let mut b = RopeBuffer::from_str("hello");
724        BufferEdit::insert_at(&mut b, Pos::new(0, 5), " world");
725        assert_eq!(b.as_string(), "hello world");
726        BufferEdit::delete_range(&mut b, Pos::new(0, 5)..Pos::new(0, 11));
727        assert_eq!(b.as_string(), "hello");
728        BufferEdit::replace_range(&mut b, Pos::new(0, 0)..Pos::new(0, 5), "HI");
729        assert_eq!(b.as_string(), "HI");
730    }
731
732    /// Default `BufferEdit::replace_all` impl forwards to
733    /// `replace_range(ORIGIN..MAX, text)`. Non-canonical backends that
734    /// don't override `replace_all` rely on this; locked in here with
735    /// a minimal mock that records the calls.
736    #[test]
737    fn buffer_edit_default_replace_all_routes_through_replace_range() {
738        struct MockBuf {
739            cursor: Pos,
740            lines: Vec<String>,
741            last_replace_range: Option<core::ops::Range<Pos>>,
742        }
743        impl Sealed for MockBuf {}
744        impl Cursor for MockBuf {
745            fn cursor(&self) -> Pos {
746                self.cursor
747            }
748            fn set_cursor(&mut self, p: Pos) {
749                self.cursor = p;
750            }
751            fn byte_offset(&self, _p: Pos) -> usize {
752                0
753            }
754            fn pos_at_byte(&self, _b: usize) -> Pos {
755                Pos::ORIGIN
756            }
757        }
758        impl Query for MockBuf {
759            fn line_count(&self) -> u32 {
760                self.lines.len() as u32
761            }
762            fn line(&self, idx: u32) -> String {
763                self.lines[idx as usize].clone()
764            }
765            fn len_bytes(&self) -> usize {
766                0
767            }
768            fn slice(&self, _r: core::ops::Range<Pos>) -> Cow<'_, str> {
769                Cow::Borrowed("")
770            }
771        }
772        impl BufferEdit for MockBuf {
773            fn insert_at(&mut self, _p: Pos, _t: &str) {}
774            fn delete_range(&mut self, _r: core::ops::Range<Pos>) {}
775            fn replace_range(&mut self, range: core::ops::Range<Pos>, _t: &str) {
776                self.last_replace_range = Some(range);
777            }
778        }
779        impl Search for MockBuf {
780            fn find_next(&self, _f: Pos, _p: &Regex) -> Option<core::ops::Range<Pos>> {
781                None
782            }
783            fn find_prev(&self, _f: Pos, _p: &Regex) -> Option<core::ops::Range<Pos>> {
784                None
785            }
786        }
787        impl View for MockBuf {}
788
789        let mut m = MockBuf {
790            cursor: Pos::ORIGIN,
791            lines: vec!["hi".into()],
792            last_replace_range: None,
793        };
794        BufferEdit::replace_all(&mut m, "new content");
795        let r = m
796            .last_replace_range
797            .expect("default impl must hit replace_range");
798        assert_eq!(r.start, Pos::ORIGIN);
799        assert_eq!(r.end.line, u32::MAX);
800        assert_eq!(r.end.col, u32::MAX);
801    }
802
803    #[test]
804    fn buffer_edit_replace_all_rebuilds_content() {
805        let mut b = RopeBuffer::from_str("hello\nworld");
806        Cursor::set_cursor(&mut b, Pos::new(1, 3));
807        BufferEdit::replace_all(&mut b, "alpha\nbeta\ngamma");
808        assert_eq!(b.as_string(), "alpha\nbeta\ngamma");
809        assert_eq!(Query::line_count(&b), 3);
810        // Cursor clamped to surviving content (`replace_all` invariant).
811        let c = Cursor::cursor(&b);
812        assert!((c.line as usize) < Query::line_count(&b) as usize);
813    }
814
815    #[test]
816    fn search_find_next_same_row() {
817        let b = RopeBuffer::from_str("abc def abc");
818        let pat = Regex::new("abc").unwrap();
819        let r = Search::find_next(&b, Pos::new(0, 0), &pat).unwrap();
820        assert_eq!(r, Pos::new(0, 0)..Pos::new(0, 3));
821        let r2 = Search::find_next(&b, Pos::new(0, 1), &pat).unwrap();
822        assert_eq!(r2, Pos::new(0, 8)..Pos::new(0, 11));
823    }
824
825    #[test]
826    fn search_find_next_wraps() {
827        let b = RopeBuffer::from_str("foo\nbar\nfoo");
828        // 0.0.37: wrap policy moved to engine `SearchState::wrap_around`.
829        // The trait impl always wraps; engine code that wants
830        // non-wrap semantics short-circuits before invoking the trait.
831        let pat = Regex::new("foo").unwrap();
832        // Starting on row 1: should find row 2's "foo".
833        let r = Search::find_next(&b, Pos::new(1, 0), &pat).unwrap();
834        assert_eq!(r, Pos::new(2, 0)..Pos::new(2, 3));
835    }
836
837    #[test]
838    fn search_find_prev_same_row() {
839        let b = RopeBuffer::from_str("abc def abc");
840        let pat = Regex::new("abc").unwrap();
841        let r = Search::find_prev(&b, Pos::new(0, 11), &pat).unwrap();
842        assert_eq!(r, Pos::new(0, 8)..Pos::new(0, 11));
843    }
844
845    #[test]
846    fn pos_position_roundtrip() {
847        let p = Pos::new(7, 3);
848        assert_eq!(position_to_pos(pos_to_position(p)), p);
849    }
850
851    // ── BufferFoldProviderMut dispatch (0.0.38, Patch C-δ.4) ───────
852
853    #[test]
854    fn fold_provider_mut_apply_add_open_close_toggle() {
855        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
856        {
857            let mut p = BufferFoldProviderMut::new(&mut buf);
858            p.apply(FoldOp::Add {
859                start_row: 1,
860                end_row: 3,
861                closed: true,
862            });
863            assert_eq!(p.fold_at_row(2), Some((1, 3, true)));
864            p.apply(FoldOp::OpenAt(2));
865            assert_eq!(p.fold_at_row(2), Some((1, 3, false)));
866            p.apply(FoldOp::CloseAt(2));
867            assert_eq!(p.fold_at_row(2), Some((1, 3, true)));
868            p.apply(FoldOp::ToggleAt(2));
869            assert_eq!(p.fold_at_row(2), Some((1, 3, false)));
870        }
871        assert_eq!(buf.folds().len(), 1);
872    }
873
874    #[test]
875    fn fold_provider_mut_apply_open_close_clear_all() {
876        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
877        buf.add_fold(0, 1, false);
878        buf.add_fold(2, 3, true);
879        {
880            let mut p = BufferFoldProviderMut::new(&mut buf);
881            p.apply(FoldOp::CloseAll);
882        }
883        assert!(buf.folds().iter().all(|f| f.closed));
884        {
885            let mut p = BufferFoldProviderMut::new(&mut buf);
886            p.apply(FoldOp::OpenAll);
887        }
888        assert!(buf.folds().iter().all(|f| !f.closed));
889        {
890            let mut p = BufferFoldProviderMut::new(&mut buf);
891            p.apply(FoldOp::ClearAll);
892        }
893        assert!(buf.folds().is_empty());
894    }
895
896    #[test]
897    fn fold_provider_mut_invalidate_range_drops_overlapping() {
898        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
899        buf.add_fold(0, 1, true);
900        buf.add_fold(2, 3, true);
901        buf.add_fold(4, 4, true);
902        {
903            let mut p = BufferFoldProviderMut::new(&mut buf);
904            p.invalidate_range(2, 3);
905        }
906        let starts: Vec<usize> = buf.folds().iter().map(|f| f.start_row).collect();
907        assert_eq!(starts, vec![0, 4]);
908    }
909
910    #[test]
911    fn fold_provider_mut_apply_remove_at() {
912        let mut buf = RopeBuffer::from_str("a\nb\nc\nd\ne");
913        buf.add_fold(1, 3, true);
914        {
915            let mut p = BufferFoldProviderMut::new(&mut buf);
916            p.apply(FoldOp::RemoveAt(2));
917        }
918        assert!(buf.folds().is_empty());
919    }
920
921    #[test]
922    fn noop_fold_provider_apply_is_noop() {
923        // The default `apply` impl on the trait is a no-op; verify
924        // NoopFoldProvider inherits it without panicking.
925        let mut p = crate::types::NoopFoldProvider;
926        FoldProvider::apply(&mut p, FoldOp::OpenAll);
927        FoldProvider::invalidate_range(&mut p, 0, 5);
928        // Read methods unaffected.
929        assert!(!FoldProvider::is_row_hidden(&p, 3));
930    }
931}