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