Skip to main content

hjkl_buffer/
buffer.rs

1use std::sync::{Arc, Mutex, MutexGuard};
2
3use crate::content::Content;
4use crate::{Position, Viewport};
5
6/// Per-window view onto a [`Content`].
7///
8/// `Buffer` is the type the rest of `hjkl-buffer` — and all consumers —
9/// use directly. It owns exactly the state that is local to one editor
10/// window:
11///
12/// - `cursor` — the charwise caret for this window.
13///
14/// All document-level state (text rope, dirty generation, folds) lives on
15/// the inner [`Content`] and is accessed via `Arc<Mutex<Content>>`.
16/// Two `Buffer` instances that share the same `Arc` share text + folds
17/// but carry independent cursors — the Helix Document+View model.
18///
19/// ## `Send` + `Sync`
20///
21/// `Arc<Mutex<Content>>` is `Send + Sync`, so `Buffer` remains `Send`.
22/// The engine trait surface requires `Buffer: Send`; this constraint
23/// drove the choice of `Mutex` over `RefCell`. The mutex is never
24/// contended in normal operation (single-threaded app loop), so the
25/// lock cost is negligible (~5 ns uncontested).
26///
27/// ## 0.8.0 migration notes
28///
29/// The existing constructors ([`Buffer::new`], [`Buffer::from_str`],
30/// [`Buffer::replace_all`], etc.) keep the same external signatures.
31/// Callers that do not need multi-window sharing see no behaviour change.
32/// Use [`Buffer::new_view`] to create a second window onto the same
33/// [`Content`].
34///
35/// ## Viewport
36///
37/// The rope invariant — at least one line, never empty — is preserved by
38/// every mutation (ropey's empty rope already reports `len_lines() == 1`).
39/// The viewport itself (top_row, top_col, width, height, wrap, text_width)
40/// lives on the engine `Host` adapter; methods that need it take a
41/// `&Viewport` / `&mut Viewport` parameter so the rope-walking math stays
42/// here while runtime state lives there.
43pub struct Buffer {
44    /// Shared per-document state (text rope, dirty gen, folds).
45    pub(crate) content: Arc<Mutex<Content>>,
46    /// Charwise cursor. `col` is bound by the char count of `row` in
47    /// normal mode, one past it in operator-pending / insert.
48    cursor: Position,
49}
50
51impl Default for Buffer {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl Buffer {
58    // ── Constructors ──────────────────────────────────────────────
59
60    /// Construct an empty buffer with one empty row + cursor at `(0, 0)`.
61    pub fn new() -> Self {
62        Self {
63            content: Arc::new(Mutex::new(Content::new())),
64            cursor: Position::default(),
65        }
66    }
67
68    /// Build a buffer from a flat string. Splits on `\n`; a trailing
69    /// `\n` produces a trailing empty line (matches every text
70    /// editor's behaviour and keeps `from_text(buf.as_string())` an
71    /// identity round-trip in the common case).
72    #[allow(clippy::should_implement_trait)]
73    pub fn from_str(text: &str) -> Self {
74        Self {
75            content: Arc::new(Mutex::new(Content::from_str(text))),
76            cursor: Position::default(),
77        }
78    }
79
80    /// Create a second per-window view onto existing [`Content`].
81    ///
82    /// The new `Buffer` shares text + folds with every other view on the
83    /// same `Arc`. Its cursor starts at `(0, 0)` independently. This is
84    /// the primary entry point for split-window features.
85    ///
86    /// ```rust
87    /// # use hjkl_buffer::{Buffer, Content, Position};
88    /// # use std::sync::Arc;
89    /// # use std::sync::Mutex;
90    /// let a = Buffer::from_str("hello\nworld");
91    /// let content = a.content_arc();
92    /// let mut b = Buffer::new_view(Arc::clone(&content));
93    ///
94    /// // Cursors are independent.
95    /// let mut a = Buffer::new_view(Arc::clone(&content));
96    /// a.set_cursor(Position::new(1, 0));
97    /// assert_eq!(b.cursor(), Position::new(0, 0));
98    /// ```
99    pub fn new_view(content: Arc<Mutex<Content>>) -> Self {
100        Self {
101            content,
102            cursor: Position::default(),
103        }
104    }
105
106    /// Return a clone of the `Arc<Mutex<Content>>` so callers can
107    /// create additional views with [`Buffer::new_view`].
108    pub fn content_arc(&self) -> Arc<Mutex<Content>> {
109        Arc::clone(&self.content)
110    }
111
112    // ── Read-only accessors (delegate to Content) ─────────────────
113
114    pub fn cursor(&self) -> Position {
115        self.cursor
116    }
117
118    pub fn dirty_gen(&self) -> u64 {
119        self.content.lock().unwrap().dirty_gen
120    }
121
122    /// Number of rows in the buffer. Always `>= 1`.
123    pub fn row_count(&self) -> usize {
124        self.content.lock().unwrap().text.len_lines()
125    }
126
127    /// Concatenate the rows into a single `String` joined by `\n`.
128    ///
129    /// Equivalent to `rope.to_string()` — ropey's rope-to-string already
130    /// produces `\n`-joined content matching `split('\n').join("\n")`.
131    pub fn as_string(&self) -> String {
132        self.content.lock().unwrap().text.to_string()
133    }
134
135    // ── Cursor ops ────────────────────────────────────────────────
136
137    /// Set cursor without scrolling. Clamps to valid positions.
138    ///
139    /// The optional sticky column for `j`/`k` motions is **not** reset
140    /// by this call — it survives `set_cursor` intentionally.
141    pub fn set_cursor(&mut self, pos: Position) {
142        let c = self.content.lock().unwrap();
143        let n = c.text.len_lines();
144        let last_row = n.saturating_sub(1);
145        let row = pos.row.min(last_row);
146        let line_chars = rope_line_char_count(&c.text, row);
147        let col = pos.col.min(line_chars);
148        drop(c);
149        self.cursor = Position::new(row, col);
150    }
151
152    /// Bring the cursor into the visible [`Viewport`], scrolling by the
153    /// minimum amount needed.
154    pub fn ensure_cursor_visible(&mut self, viewport: &mut Viewport) {
155        let cursor = self.cursor;
156        let v = *viewport;
157        let wrap_active = !matches!(v.wrap, crate::Wrap::None) && v.text_width > 0;
158        if !wrap_active {
159            viewport.ensure_visible(cursor);
160            return;
161        }
162        if v.height == 0 {
163            return;
164        }
165        // Cursor above the visible region: snap top_row to it.
166        if cursor.row < v.top_row {
167            viewport.top_row = cursor.row;
168            viewport.top_col = 0;
169            return;
170        }
171        let height = v.height as usize;
172        // Push top_row forward until cursor lands inside [0, height).
173        loop {
174            let csr = self.cursor_screen_row_from(viewport, viewport.top_row);
175            match csr {
176                Some(row) if row < height => break,
177                _ => {}
178            }
179            let next = {
180                let c = self.content.lock().unwrap();
181                let mut n = viewport.top_row + 1;
182                while n <= cursor.row && c.folds.iter().any(|f| f.hides(n)) {
183                    n += 1;
184                }
185                n
186            };
187            if next > cursor.row {
188                viewport.top_row = cursor.row;
189                break;
190            }
191            viewport.top_row = next;
192        }
193        viewport.top_col = 0;
194    }
195
196    /// Cursor's screen row offset (0-based) from `viewport.top_row`.
197    pub fn cursor_screen_row(&self, viewport: &Viewport) -> Option<usize> {
198        if matches!(viewport.wrap, crate::Wrap::None) || viewport.text_width == 0 {
199            return None;
200        }
201        self.cursor_screen_row_from(viewport, viewport.top_row)
202    }
203
204    /// Number of screen rows the doc range `start..=end` occupies.
205    pub fn screen_rows_between(&self, viewport: &Viewport, start: usize, end: usize) -> usize {
206        if start > end {
207            return 0;
208        }
209        let c = self.content.lock().unwrap();
210        let n = c.text.len_lines();
211        let last = n.saturating_sub(1);
212        let end = end.min(last);
213        let v = *viewport;
214        let mut total = 0usize;
215        for r in start..=end {
216            if c.folds.iter().any(|f| f.hides(r)) {
217                continue;
218            }
219            if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
220                total += 1;
221            } else {
222                let line = rope_line_str(&c.text, r);
223                total += crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len();
224            }
225        }
226        total
227    }
228
229    /// Earliest `top_row` such that `screen_rows_between(top, last)`
230    /// is at least `height`.
231    pub fn max_top_for_height(&self, viewport: &Viewport, height: usize) -> usize {
232        if height == 0 {
233            return 0;
234        }
235        let c = self.content.lock().unwrap();
236        let n = c.text.len_lines();
237        let last = n.saturating_sub(1);
238        let mut total = 0usize;
239        let mut row = last;
240        loop {
241            if !c.folds.iter().any(|f| f.hides(row)) {
242                let v = *viewport;
243                total += if matches!(v.wrap, crate::Wrap::None) || v.text_width == 0 {
244                    1
245                } else {
246                    let line = rope_line_str(&c.text, row);
247                    crate::wrap::wrap_segments(&line, v.text_width, v.wrap).len()
248                };
249            }
250            if total >= height {
251                return row;
252            }
253            if row == 0 {
254                return 0;
255            }
256            row -= 1;
257        }
258    }
259
260    /// Clamp `pos` to the buffer's content.
261    pub fn clamp_position(&self, pos: Position) -> Position {
262        let c = self.content.lock().unwrap();
263        let n = c.text.len_lines();
264        let last_row = n.saturating_sub(1);
265        let row = pos.row.min(last_row);
266        let line_chars = rope_line_char_count(&c.text, row);
267        let col = pos.col.min(line_chars);
268        Position::new(row, col)
269    }
270
271    /// Replace the buffer's full text in place. Cursor is clamped to
272    /// the new content.
273    pub fn replace_all(&mut self, text: &str) {
274        let new_cursor = {
275            let mut c = self.content.lock().unwrap();
276            c.text = ropey::Rope::from_str(text);
277            let n = c.text.len_lines();
278            let last_row = n.saturating_sub(1);
279            let row = self.cursor.row.min(last_row);
280            let line_chars = rope_line_char_count(&c.text, row);
281            let col = self.cursor.col.min(line_chars);
282            c.dirty_gen = c.dirty_gen.wrapping_add(1);
283            c.cached_joined = None;
284            c.cached_byte_len = None;
285            Position::new(row, col)
286        };
287        self.cursor = new_cursor;
288    }
289
290    // ── Crate-internal accessors (used by folds.rs) ───────────────
291
292    /// Bump the render-cache generation. Crate-internal.
293    pub(crate) fn dirty_gen_bump(&mut self) {
294        let mut c = self.content.lock().unwrap();
295        c.dirty_gen = c.dirty_gen.wrapping_add(1);
296        c.cached_joined = None;
297        c.cached_byte_len = None;
298    }
299
300    /// Canonical byte length of the document. `Rope::len_bytes()` is O(1)
301    /// and returns the same value as `to_string().len()` (i.e.
302    /// `sum(line_bytes) + (n_lines-1)` separators). Cached against
303    /// `dirty_gen` for API compatibility; the O(1) rope call makes the
304    /// cache essentially free but keeps the invalidation contract identical.
305    pub fn byte_len(&self) -> usize {
306        let mut c = self.content.lock().unwrap();
307        let dg = c.dirty_gen;
308        if let Some((cached_dg, len)) = c.cached_byte_len
309            && cached_dg == dg
310        {
311            return len;
312        }
313        let total = c.text.len_bytes();
314        c.cached_byte_len = Some((dg, total));
315        total
316    }
317
318    /// Return an `Arc<String>` of the full document, cached against
319    /// `dirty_gen`. Multiple per-tick consumers (syntax pipeline, LSP
320    /// notify, git signature, dirty hash) share the same `Arc` for the
321    /// same generation — first caller pays the `rope.to_string()` cost
322    /// (one alloc + one lock), the rest are O(1).
323    ///
324    /// Cache invalidates automatically on every `dirty_gen_bump` and on
325    /// `replace_all`, so callers never need to manage invalidation.
326    pub fn content_joined(&self) -> std::sync::Arc<String> {
327        let mut c = self.content.lock().unwrap();
328        let dg = c.dirty_gen;
329        if let Some((cached_dg, ref s)) = c.cached_joined
330            && cached_dg == dg
331        {
332            return std::sync::Arc::clone(s);
333        }
334        let joined = std::sync::Arc::new(c.text.to_string());
335        c.cached_joined = Some((dg, std::sync::Arc::clone(&joined)));
336        joined
337    }
338
339    /// Borrow the underlying rope. Hot-path consumers (tree-sitter
340    /// streaming parse, byte-range slicing) should use this instead of
341    /// `content_joined()` to avoid materializing the whole document as
342    /// a `String`.
343    ///
344    /// `ropey::Rope::clone` is O(1) — it Arc-clones the root node.
345    /// The clone gives the caller a snapshot they can read without
346    /// holding the content mutex.
347    pub fn rope(&self) -> ropey::Rope {
348        self.content.lock().unwrap().text.clone()
349    }
350
351    /// Shared access to the content guard. Crate-internal.
352    pub(crate) fn content_lock(&self) -> MutexGuard<'_, Content> {
353        self.content.lock().unwrap()
354    }
355
356    /// Exclusive access to Content. Crate-internal.
357    pub(crate) fn content_lock_mut(&mut self) -> MutexGuard<'_, Content> {
358        self.content.lock().unwrap()
359    }
360
361    // ── Screen-row helpers (private) ──────────────────────────────
362
363    fn cursor_screen_row_from(&self, viewport: &Viewport, top: usize) -> Option<usize> {
364        let cursor = self.cursor;
365        if cursor.row < top {
366            return None;
367        }
368        let c = self.content.lock().unwrap();
369        let v = *viewport;
370        let mut screen = 0usize;
371        for r in top..=cursor.row {
372            if c.folds.iter().any(|f| f.hides(r)) {
373                continue;
374            }
375            let line = rope_line_str(&c.text, r);
376            let segs = crate::wrap::wrap_segments(&line, v.text_width, v.wrap);
377            if r == cursor.row {
378                let seg_idx = crate::wrap::segment_for_col(&segs, cursor.col);
379                return Some(screen + seg_idx);
380            }
381            screen += segs.len();
382        }
383        None
384    }
385}
386
387// ── Rope line helpers (free functions over &ropey::Rope) ─────────────
388
389/// Return logical line `row` as a `String`, stripping the trailing `\n`
390/// that ropey includes for non-final lines.
391pub fn rope_line_str(rope: &ropey::Rope, row: usize) -> String {
392    let mut s = rope.line(row).to_string();
393    // ropey includes the trailing '\n' for non-final lines; strip it.
394    if s.ends_with('\n') {
395        s.pop();
396    }
397    s
398}
399
400/// Byte length of logical line `row` (excluding the trailing `\n`).
401pub fn rope_line_bytes(rope: &ropey::Rope, row: usize) -> usize {
402    let slice = rope.line(row);
403    let bytes = slice.len_bytes();
404    // ropey includes the '\n' byte for non-final lines; subtract it.
405    if row + 1 < rope.len_lines() && bytes > 0 {
406        bytes - 1
407    } else {
408        bytes
409    }
410}
411
412/// Char count of logical line `row` (excluding the trailing `\n`).
413pub(crate) fn rope_line_char_count(rope: &ropey::Rope, row: usize) -> usize {
414    let slice = rope.line(row);
415    let chars = slice.len_chars();
416    // ropey includes the '\n' char for non-final lines; subtract it.
417    if row + 1 < rope.len_lines() && chars > 0 {
418        chars - 1
419    } else {
420        chars
421    }
422}
423
424/// Char index from `(row, col)` where `col` is a char index within the line.
425pub(crate) fn pos_to_char_idx(rope: &ropey::Rope, row: usize, col: usize) -> usize {
426    let line_start = rope.line_to_char(row);
427    let line_char_count = rope_line_char_count(rope, row);
428    line_start + col.min(line_char_count)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn new_has_one_empty_row() {
437        let b = Buffer::new();
438        assert_eq!(b.row_count(), 1);
439        assert_eq!(rope_line_str(&b.rope(), 0), "");
440        assert_eq!(b.cursor(), Position::default());
441    }
442
443    #[test]
444    fn from_str_splits_on_newline() {
445        let b = Buffer::from_str("foo\nbar\nbaz");
446        assert_eq!(b.row_count(), 3);
447        assert_eq!(rope_line_str(&b.rope(), 0), "foo");
448        assert_eq!(rope_line_str(&b.rope(), 2), "baz");
449    }
450
451    #[test]
452    fn from_str_trailing_newline_keeps_empty_row() {
453        let b = Buffer::from_str("foo\n");
454        assert_eq!(b.row_count(), 2);
455        assert_eq!(rope_line_str(&b.rope(), 1), "");
456    }
457
458    #[test]
459    fn from_str_empty_input_keeps_one_row() {
460        let b = Buffer::from_str("");
461        assert_eq!(b.row_count(), 1);
462        assert_eq!(rope_line_str(&b.rope(), 0), "");
463    }
464
465    #[test]
466    fn as_string_round_trips() {
467        let b = Buffer::from_str("a\nb\nc");
468        assert_eq!(b.as_string(), "a\nb\nc");
469    }
470
471    #[test]
472    fn dirty_gen_starts_at_zero() {
473        assert_eq!(Buffer::new().dirty_gen(), 0);
474    }
475
476    fn vp_wrap(width: u16, height: u16) -> Viewport {
477        Viewport {
478            top_row: 0,
479            top_col: 0,
480            width,
481            height,
482            wrap: crate::Wrap::Char,
483            text_width: width,
484            tab_width: 0,
485        }
486    }
487
488    #[test]
489    fn ensure_cursor_visible_wrap_scrolls_when_cursor_below_screen() {
490        let mut b = Buffer::from_str("aaaaaaaaaa\nb\nc");
491        let mut v = vp_wrap(4, 3);
492        b.set_cursor(Position::new(2, 0));
493        b.ensure_cursor_visible(&mut v);
494        assert_eq!(v.top_row, 1);
495    }
496
497    #[test]
498    fn ensure_cursor_visible_wrap_no_scroll_when_visible() {
499        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
500        let mut v = vp_wrap(4, 4);
501        b.set_cursor(Position::new(0, 5));
502        b.ensure_cursor_visible(&mut v);
503        assert_eq!(v.top_row, 0);
504    }
505
506    #[test]
507    fn ensure_cursor_visible_wrap_snaps_top_when_cursor_above() {
508        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
509        let mut v = vp_wrap(4, 2);
510        v.top_row = 3;
511        b.set_cursor(Position::new(1, 0));
512        b.ensure_cursor_visible(&mut v);
513        assert_eq!(v.top_row, 1);
514    }
515
516    #[test]
517    fn screen_rows_between_sums_segments_under_wrap() {
518        let b = Buffer::from_str("aaaaaaaaa\nb\n");
519        let v = vp_wrap(4, 0);
520        assert_eq!(b.screen_rows_between(&v, 0, 0), 3);
521        assert_eq!(b.screen_rows_between(&v, 0, 1), 4);
522        assert_eq!(b.screen_rows_between(&v, 0, 2), 5);
523        assert_eq!(b.screen_rows_between(&v, 1, 2), 2);
524    }
525
526    #[test]
527    fn screen_rows_between_one_per_doc_row_when_wrap_off() {
528        let b = Buffer::from_str("aaaaa\nb\nc");
529        let v = Viewport::default();
530        assert_eq!(b.screen_rows_between(&v, 0, 2), 3);
531    }
532
533    #[test]
534    fn max_top_for_height_walks_back_until_height_reached() {
535        let b = Buffer::from_str("a\nb\nc\nd\neeeeeeee");
536        let v = vp_wrap(4, 0);
537        assert_eq!(b.max_top_for_height(&v, 4), 2);
538        assert_eq!(b.max_top_for_height(&v, 99), 0);
539    }
540
541    #[test]
542    fn cursor_screen_row_returns_none_when_wrap_off() {
543        let b = Buffer::from_str("a");
544        let v = Viewport::default();
545        assert!(b.cursor_screen_row(&v).is_none());
546    }
547
548    #[test]
549    fn cursor_screen_row_under_wrap() {
550        let mut b = Buffer::from_str("aaaaaaaaaa\nb");
551        let v = vp_wrap(4, 0);
552        b.set_cursor(Position::new(0, 5));
553        assert_eq!(b.cursor_screen_row(&v), Some(1));
554        b.set_cursor(Position::new(1, 0));
555        assert_eq!(b.cursor_screen_row(&v), Some(3));
556    }
557
558    #[test]
559    fn ensure_cursor_visible_falls_back_when_wrap_disabled() {
560        let mut b = Buffer::from_str("a\nb\nc\nd\ne");
561        let mut v = Viewport {
562            top_row: 0,
563            top_col: 0,
564            width: 4,
565            height: 2,
566            wrap: crate::Wrap::None,
567            text_width: 4,
568            tab_width: 0,
569        };
570        b.set_cursor(Position::new(4, 0));
571        b.ensure_cursor_visible(&mut v);
572        assert_eq!(v.top_row, 3);
573    }
574
575    // ── View-split tests (new in 0.8.0) ──────────────────────────
576
577    /// Two `Buffer` views sharing one `Content` must have independent
578    /// cursors.
579    #[test]
580    fn buffer_views_independent_cursors() {
581        let a = Buffer::from_str("hello\nworld");
582        let arc = a.content_arc();
583        let mut view_a = Buffer::new_view(Arc::clone(&arc));
584        let mut view_b = Buffer::new_view(Arc::clone(&arc));
585
586        view_a.set_cursor(Position::new(1, 3));
587        // view_b cursor must remain at (0, 0).
588        assert_eq!(view_b.cursor(), Position::new(0, 0));
589
590        view_b.set_cursor(Position::new(0, 2));
591        // view_a cursor must remain at (1, 3).
592        assert_eq!(view_a.cursor(), Position::new(1, 3));
593    }
594
595    /// An edit applied via one view must be visible via the other.
596    #[test]
597    fn buffer_views_share_content() {
598        use crate::edit::Edit;
599
600        let a = Buffer::from_str("foo");
601        let arc = a.content_arc();
602        let mut view_a = Buffer::new_view(Arc::clone(&arc));
603        let view_b = Buffer::new_view(Arc::clone(&arc));
604
605        view_a.apply_edit(Edit::InsertStr {
606            at: Position::new(0, 3),
607            text: "bar".into(),
608        });
609
610        assert_eq!(rope_line_str(&view_a.rope(), 0), "foobar");
611        assert_eq!(rope_line_str(&view_b.rope(), 0), "foobar");
612    }
613}