Skip to main content

hjkl_buffer/
content.rs

1//! Per-document text content. Arc-shareable across multiple [`crate::View`]
2//! views.
3//!
4//! [`Buffer`] owns everything that belongs to the document itself:
5//!
6//! - The `text` rope (text content).
7//! - The `dirty_gen` render-cache generation counter.
8//! - Manual folds (`folds`).
9//!
10//! [`crate::View`] is the per-window wrapper. It holds an
11//! `Arc<Mutex<Buffer>>` plus the per-window cursor. Two `View`
12//! instances that share one `Buffer` see the same text and folds, but
13//! each moves its cursor independently.
14//!
15//! ## Concurrency
16//!
17//! Held inside `Arc<Mutex<Buffer>>` so multiple `View` views can share
18//! one document safely. `Mutex` (not `RefCell`) because the engine's
19//! `Cursor`, `Query`, `BufferEdit`, and `Search` traits require `Send`,
20//! and `RefCell` is `!Send`. Lock contention is near-zero in the
21//! single-threaded app loop; the Mutex is essentially a free `Send`
22//! adapter.
23
24use crate::folds::Fold;
25
26/// Per-document state shared across all [`crate::View`] views of the
27/// same file. Wrap in `Arc<Mutex<Buffer>>` and pass to
28/// [`crate::View::new_view`] to create an additional window onto the
29/// same content.
30///
31/// Uses a `ropey::Rope` for O(log N) edits and O(1) byte-length queries.
32/// The rope always contains at least one logical line: a freshly constructed
33/// `Buffer` holds an empty rope (which `ropey` reports as 1 line) so
34/// cursor positions never need an "is the buffer empty?" branch.
35///
36/// ## Line semantics
37///
38/// `ropey::Rope::len_lines()` and `split('\n').count()` agree for all inputs:
39/// - `""` → 1 line
40/// - `"foo\n"` → 2 lines (trailing empty line)
41/// - `"a\nb\n"` → 3 lines
42///
43/// `Rope::line(i)` returns a `RopeSlice` that includes the trailing `\n`
44/// for non-final lines. Public accessors strip it before returning `String`.
45pub struct Buffer {
46    /// Rope-backed document text. Always non-empty: `ropey::Rope::new()`
47    /// (an empty rope) reports `len_lines() == 1`, satisfying the "at least
48    /// one row" invariant without a separate sentinel.
49    pub(crate) text: ropey::Rope,
50    /// Bumps on every mutation; render cache keys against this so a
51    /// per-row `Line` gets recomputed when its source row changes.
52    pub(crate) dirty_gen: u64,
53    /// Manual folds — closed ranges hide rows in the render path.
54    /// `pub(crate)` so the [`crate::folds`] module can read/write
55    /// directly (same visibility as before the split).
56    pub(crate) folds: Vec<Fold>,
57    /// Bumps whenever the fold set is mutated (add / remove / toggle /
58    /// open-all / close-all / invalidate / rebase / wholesale set). Text
59    /// edits that leave folds untouched do NOT bump it — that is the whole
60    /// point: hosts that keep a per-window fold snapshot (`hjkl`'s
61    /// `window_folds`) compare this counter instead of cloning the fold
62    /// `Vec` on every keystroke.
63    ///
64    /// Same contract as [`Self::dirty_gen`]: monotonic (wrapping) and
65    /// conservative — "if it changed, the folds **may** have changed".
66    pub(crate) fold_gen: u64,
67    /// Cached `rope.to_string()` keyed by the `dirty_gen` at build time.
68    /// Multiple per-tick consumers (syntax submit, LSP notify, git
69    /// signature, dirty hash) all need the joined document; rebuilding
70    /// per consumer was ~4× the line-clone + alloc cost per keystroke
71    /// on a 400-line file (visible as insert-mode lag).
72    pub(crate) cached_joined: Option<(u64, std::sync::Arc<String>)>,
73    /// Cached canonical byte length keyed by `dirty_gen` at compute time.
74    /// `Rope::len_bytes()` is O(1) but holding the cache avoids even that
75    /// small overhead on repeated callers within the same tick.
76    pub(crate) cached_byte_len: Option<(u64, usize)>,
77
78    // ── Per-buffer engine state (relocated from hjkl-engine::Editor) ──────
79    /// Undo history: an arena tree of O(1)-clone rope snapshots (Phase 2a).
80    /// Replaces the old `undo_stack` /
81    /// `redo_stack` `Vec<UndoEntry>` pair; the tree stays linear (each node
82    /// has ≤ 1 child) so its behaviour is byte-identical to the two stacks —
83    /// see [`crate::UndoTree`]'s module comment for the mapping.
84    pub(crate) undo: crate::UndoTree,
85    /// Undo-group nesting depth. `> 0` while an [`crate::UndoGroup`] guard is
86    /// live (see hjkl-engine). At depth `0` `push_undo` behaves exactly as it
87    /// always has (one entry per call); at depth `> 0` every mutation inside
88    /// the outermost group coalesces into a single undo entry.
89    pub(crate) undo_group_depth: u32,
90    /// Set once the outermost open group has taken its single pre-group
91    /// snapshot; every later `push_undo` in the group is then suppressed.
92    pub(crate) undo_group_armed: bool,
93    /// `dirty_gen` captured when the outermost group opened. If it is
94    /// unchanged when the group closes, the group mutated nothing and its
95    /// armed snapshot is popped so a no-op group leaves zero undo entries.
96    pub(crate) undo_group_open_gen: u64,
97    /// Set whenever the buffer content changes; cleared by the engine's
98    /// `take_dirty` accessor.
99    pub(crate) content_dirty: bool,
100    /// Cached `Arc<String>` of the joined document for the engine's
101    /// `content_arc` fast path. Invalidated by `mark_content_dirty`.
102    pub(crate) cached_editor_content: Option<std::sync::Arc<String>>,
103    /// Pending [`crate::FoldOp`]s raised by `z…` keystrokes, `:fold*` ex
104    /// commands, and the edit-pipeline's fold invalidation. Drained by
105    /// hosts via `Editor::take_fold_ops`.
106    pub(crate) pending_fold_ops: Vec<crate::FoldOp>,
107    /// Pending edit log drained by `Editor::take_changes`. Each entry is
108    /// a [`crate::EngineEdit`] mapped from the underlying buffer edit.
109    ///
110    /// Only recorded while [`Self::change_log_enabled`] is set. hjkl itself
111    /// has no consumer of this log (drain sites are tests), so recording
112    /// defaults OFF — every `mutate_edit` would otherwise pay a payload
113    /// clone plus a `Vec` build for an entry no host reads. Hosts that want
114    /// the log opt in via `View::set_change_log_enabled`.
115    pub(crate) change_log: Vec<crate::EngineEdit>,
116    /// Whether [`Self::change_log`] records entries. Default false — see
117    /// the field's doc.
118    pub(crate) change_log_enabled: bool,
119    /// Pending `ContentEdit` records emitted by `mutate_edit`. Drained by
120    /// hosts via `Editor::take_content_edits` for fan-in to a syntax tree.
121    pub(crate) pending_content_edits: Vec<crate::ContentEdit>,
122    /// Pending "reset" flag set when the entire buffer is replaced
123    /// (e.g. `set_content` / `restore`). Supersedes any queued
124    /// `pending_content_edits` on the same frame.
125    pub(crate) pending_content_reset: bool,
126    /// Named marks (`'a`–`'z`, `'A`–`'Z`) — buffer-scoped cursor positions
127    /// `(row, col)`. Shared across all window views of this buffer (#154).
128    pub(crate) marks: std::collections::BTreeMap<char, (usize, usize)>,
129    /// Cached syntax-derived foldable block ranges that `:foldsyntax`
130    /// consumes; a property of the buffer content, shared across views (#154).
131    pub(crate) syntax_fold_ranges: Vec<(usize, usize)>,
132    /// Last cursor `(row, col)` committed on this document by ANY view.
133    /// Every [`crate::View::set_cursor`] (the single choke point all engine
134    /// cursor moves route through) writes it, so with several windows onto
135    /// one buffer the most-recently-moved cursor wins by construction. Read
136    /// at write/close/exit to persist cross-session cursor memory.
137    /// In-memory only — no I/O on move.
138    pub(crate) last_cursor: (usize, usize),
139}
140
141impl Default for Buffer {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl Buffer {
148    /// New empty content with one empty row.
149    pub fn new() -> Self {
150        let text = ropey::Rope::new();
151        let undo = crate::UndoTree::new(text.clone());
152        Self {
153            text,
154            dirty_gen: 0,
155            folds: Vec::new(),
156            fold_gen: 0,
157            cached_joined: None,
158            cached_byte_len: None,
159            undo,
160            undo_group_depth: 0,
161            undo_group_armed: false,
162            undo_group_open_gen: 0,
163            content_dirty: false,
164            cached_editor_content: None,
165            pending_fold_ops: Vec::new(),
166            change_log: Vec::new(),
167            change_log_enabled: false,
168            pending_content_edits: Vec::new(),
169            pending_content_reset: false,
170            marks: std::collections::BTreeMap::new(),
171            syntax_fold_ranges: Vec::new(),
172            last_cursor: (0, 0),
173        }
174    }
175
176    /// Build content from a flat string. Splits on `\n`; a trailing
177    /// `\n` produces a trailing empty line (matches ropey's own convention).
178    #[allow(clippy::should_implement_trait)]
179    pub fn from_str(text: &str) -> Self {
180        let text = ropey::Rope::from_str(text);
181        let undo = crate::UndoTree::new(text.clone());
182        Self {
183            text,
184            dirty_gen: 0,
185            folds: Vec::new(),
186            fold_gen: 0,
187            cached_joined: None,
188            cached_byte_len: None,
189            undo,
190            undo_group_depth: 0,
191            undo_group_armed: false,
192            undo_group_open_gen: 0,
193            content_dirty: false,
194            cached_editor_content: None,
195            pending_fold_ops: Vec::new(),
196            change_log: Vec::new(),
197            change_log_enabled: false,
198            pending_content_edits: Vec::new(),
199            pending_content_reset: false,
200            marks: std::collections::BTreeMap::new(),
201            syntax_fold_ranges: Vec::new(),
202            last_cursor: (0, 0),
203        }
204    }
205
206    // ── Undo-group coalescing (Phase 1) ────────────────────────────────────
207    //
208    // A group makes a composed operation (`:g`, `:normal`, a macro replay)
209    // record ONE undo step instead of one per underlying `push_undo`. The
210    // depth counter is re-entrant: nested groups just nest, only the
211    // outermost close commits.
212
213    /// Open (nest into) an undo group. On the outermost open (depth `0→1`)
214    /// record the current `dirty_gen` and disarm, so the group's first
215    /// mutating `push_undo` takes exactly one snapshot.
216    pub fn undo_group_enter(&mut self) {
217        if self.undo_group_depth == 0 {
218            self.undo_group_armed = false;
219            self.undo_group_open_gen = self.dirty_gen;
220        }
221        self.undo_group_depth = self.undo_group_depth.saturating_add(1);
222    }
223
224    /// Close (unnest) an undo group. On the outermost close (depth `1→0`), if
225    /// the group armed a snapshot but `dirty_gen` is unchanged since it opened
226    /// (nothing was mutated), pop that snapshot so a no-op group leaves zero
227    /// undo entries. Resets the group flags.
228    pub fn undo_group_exit(&mut self) {
229        if self.undo_group_depth == 0 {
230            return;
231        }
232        self.undo_group_depth -= 1;
233        if self.undo_group_depth == 0 {
234            if self.undo_group_armed && self.dirty_gen == self.undo_group_open_gen {
235                // Drop the armed snapshot: splice out the just-committed
236                // boundary node, restoring the tree to its pre-group shape.
237                self.undo.pop_committed();
238            }
239            self.undo_group_armed = false;
240        }
241    }
242
243    /// Whether an undo group is currently open (depth `> 0`).
244    pub fn undo_group_active(&self) -> bool {
245        self.undo_group_depth > 0
246    }
247
248    /// Arm the open group's single snapshot. Returns `true` if this call armed
249    /// it (the first mutating `push_undo` in the group, which must take the
250    /// snapshot), `false` if it was already armed (a later push to suppress).
251    pub fn undo_group_arm(&mut self) -> bool {
252        if self.undo_group_armed {
253            false
254        } else {
255            self.undo_group_armed = true;
256            true
257        }
258    }
259}