Skip to main content

hjkl_engine/
types.rs

1//! Core types for the engine trait surface.
2//!
3//! These are introduced alongside the legacy sqeel-vim public API. The
4//! trait extraction (phase 5) progressively rewires the existing FSM and
5//! Editor to operate on `Selection` / `SelectionSet` / `Edit` / `Pos`.
6//! Until that work lands, the legacy types in [`crate::editor`] and
7//! [`crate::vim`] remain authoritative.
8
9use std::ops::Range;
10
11/// Grapheme-indexed position. `line` is zero-based row; `col` is zero-based
12/// grapheme column within that line.
13///
14/// Note that `col` counts graphemes, not bytes or chars. Motions and
15/// rendering both honor grapheme boundaries.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
17pub struct Pos {
18    pub line: u32,
19    pub col: u32,
20}
21
22impl Pos {
23    pub const ORIGIN: Pos = Pos { line: 0, col: 0 };
24
25    pub const fn new(line: u32, col: u32) -> Self {
26        Pos { line, col }
27    }
28}
29
30/// What kind of region a [`Selection`] covers.
31///
32/// - `Char`: classic vim `v` selection — closed range on the inline character
33///   axis.
34/// - `Line`: linewise (`V`) — anchor/head columns ignored, full lines covered
35///   between `min(anchor.line, head.line)` and `max(...)`.
36/// - `Block`: blockwise (`Ctrl-V`) — rectangle from `min(col)` to `max(col)`,
37///   each line a sub-range. Falls out of multi-cursor model: implementations
38///   may expand a `Block` selection into N sub-selections during edit
39///   dispatch.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
41pub enum SelectionKind {
42    #[default]
43    Char,
44    Line,
45    Block,
46}
47
48/// A single anchored selection. Empty (caret-only) when `anchor == head`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct Selection {
51    pub anchor: Pos,
52    pub head: Pos,
53    pub kind: SelectionKind,
54}
55
56impl Selection {
57    /// Caret at `pos` with no extent.
58    pub const fn caret(pos: Pos) -> Self {
59        Selection {
60            anchor: pos,
61            head: pos,
62            kind: SelectionKind::Char,
63        }
64    }
65
66    /// Inclusive range `[anchor, head]` (or reversed) as a `Char` selection.
67    pub const fn char_range(anchor: Pos, head: Pos) -> Self {
68        Selection {
69            anchor,
70            head,
71            kind: SelectionKind::Char,
72        }
73    }
74
75    /// True if `anchor == head`.
76    pub fn is_empty(&self) -> bool {
77        self.anchor == self.head
78    }
79}
80
81/// Ordered set of selections. Always non-empty in valid states; `primary`
82/// indexes the cursor visible to vim mode.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SelectionSet {
85    pub items: Vec<Selection>,
86    pub primary: usize,
87}
88
89impl SelectionSet {
90    /// Single caret at `pos`.
91    pub fn caret(pos: Pos) -> Self {
92        SelectionSet {
93            items: vec![Selection::caret(pos)],
94            primary: 0,
95        }
96    }
97
98    /// Returns the primary selection, or the first if `primary` is out of
99    /// bounds.
100    pub fn primary(&self) -> &Selection {
101        self.items
102            .get(self.primary)
103            .or_else(|| self.items.first())
104            .expect("SelectionSet must contain at least one selection")
105    }
106}
107
108impl Default for SelectionSet {
109    fn default() -> Self {
110        SelectionSet::caret(Pos::ORIGIN)
111    }
112}
113
114/// A pending or applied edit. Multi-cursor edits fan out to `Vec<Edit>`
115/// ordered in **reverse byte offset** so each entry's positions remain valid
116/// after the prior entry applies.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Edit {
119    pub range: Range<Pos>,
120    pub replacement: String,
121}
122
123/// Engine-native representation of a single buffer mutation in the
124/// shape tree-sitter's `InputEdit` consumes. Emitted by
125/// [`crate::Editor::mutate_edit`] and drained by hosts via
126/// [`crate::Editor::take_content_edits`] so the syntax layer can fan
127/// edits into a retained tree without the engine taking a tree-sitter
128/// dependency.
129///
130/// Positions are `(row, col_byte)` — byte offsets within the row, not
131/// char counts. Multi-row inserts/deletes set `new_end_position.0` /
132/// `old_end_position.0` to the relevant row delta. Conversion to
133/// `tree_sitter::InputEdit` is mechanical (see `apps/hjkl/src/syntax.rs`).
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ContentEdit {
136    pub start_byte: usize,
137    pub old_end_byte: usize,
138    pub new_end_byte: usize,
139    pub start_position: (u32, u32),
140    pub old_end_position: (u32, u32),
141    pub new_end_position: (u32, u32),
142}
143
144impl Edit {
145    pub fn insert(at: Pos, text: impl Into<String>) -> Self {
146        Edit {
147            range: at..at,
148            replacement: text.into(),
149        }
150    }
151
152    pub fn delete(range: Range<Pos>) -> Self {
153        Edit {
154            range,
155            replacement: String::new(),
156        }
157    }
158
159    pub fn replace(range: Range<Pos>, text: impl Into<String>) -> Self {
160        Edit {
161            range,
162            replacement: text.into(),
163        }
164    }
165}
166
167/// Vim editor mode. Distinct from the legacy [`crate::VimMode`] — that one
168/// is the host-facing status-line summary; this is the engine's internal
169/// state machine.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum Mode {
172    #[default]
173    Normal,
174    Insert,
175    Visual,
176    Replace,
177    Command,
178    OperatorPending,
179}
180
181/// Cursor shape intent emitted on mode transitions. Hosts honor it via
182/// `Host::emit_cursor_shape` once the trait extraction lands.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
184pub enum CursorShape {
185    #[default]
186    Block,
187    Bar,
188    Underline,
189}
190
191/// Engine-native style. Replaces direct ratatui `Style` use in the public
192/// API once phase 5 trait extraction completes; until then both coexist.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub struct Style {
195    pub fg: Option<Color>,
196    pub bg: Option<Color>,
197    pub attrs: Attrs,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
201pub struct Color(pub u8, pub u8, pub u8);
202
203bitflags::bitflags! {
204    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
205    pub struct Attrs: u8 {
206        const BOLD       = 1 << 0;
207        const ITALIC     = 1 << 1;
208        const UNDERLINE  = 1 << 2;
209        const REVERSE    = 1 << 3;
210        const DIM        = 1 << 4;
211        const STRIKE     = 1 << 5;
212    }
213}
214
215/// Highlight kind emitted by the engine's render pass. The host's style
216/// resolver picks colors for `Selection`/`SearchMatch`/etc.; `Syntax(id)`
217/// carries an opaque host-supplied id whose styling lives in the host.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum HighlightKind {
220    Selection,
221    SearchMatch,
222    IncSearch,
223    MatchParen,
224    Syntax(u32),
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Highlight {
229    pub range: Range<Pos>,
230    pub kind: HighlightKind,
231}
232
233/// Editor settings surfaced via `:set`. Per SPEC. Consumed once trait
234/// extraction lands; today's legacy `Settings` (in [`crate::editor`])
235/// continues to drive runtime behaviour.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct Options {
238    /// Display width of `\t` for column math + render. Default 8.
239    pub tabstop: u32,
240    /// Spaces per shift step (`>>`, `<<`, `Ctrl-T`, `Ctrl-D`).
241    pub shiftwidth: u32,
242    /// Insert spaces (`true`) or literal `\t` (`false`) for the Tab key.
243    pub expandtab: bool,
244    /// Soft tab stop in spaces. When `> 0`, the Tab key (with `expandtab`)
245    /// inserts spaces to the next softtabstop boundary, and Backspace at
246    /// the end of a softtabstop-aligned space run deletes the whole run.
247    /// `0` disables softtabstop semantics. Matches vim's `:set softtabstop`.
248    pub softtabstop: u32,
249    /// Characters considered part of a "word" for `w`/`b`/`*`/`#`.
250    /// Default `"@,48-57,_,192-255"` (ASCII letters, digits, `_`, plus
251    /// extended Latin); host may override per language.
252    pub iskeyword: String,
253    /// Default `false`: search is case-sensitive.
254    pub ignorecase: bool,
255    /// When `true` and `ignorecase` is `true`, an uppercase letter in the
256    /// pattern flips back to case-sensitive for that search.
257    pub smartcase: bool,
258    /// Highlight all matches of the last search.
259    pub hlsearch: bool,
260    /// Incrementally highlight matches while typing the search pattern.
261    pub incsearch: bool,
262    /// Wrap searches around the buffer ends.
263    pub wrapscan: bool,
264    /// Copy previous line's leading whitespace on Enter in insert mode.
265    pub autoindent: bool,
266    /// When `true`, bump indent by one `shiftwidth` after a line ending in
267    /// `{` / `(` / `[`, and strip one indent unit when the user types the
268    /// matching `}` / `)` / `]` on an otherwise-whitespace-only line.
269    /// Supersedes autoindent's plain copy when on.  Future: a
270    /// tree-sitter `indents.scm` provider will replace the heuristic; see
271    /// `compute_enter_indent` in `vim.rs` for the plug-in point.
272    pub smartindent: bool,
273    /// Multi-key sequence timeout (e.g., `<C-w>v`). Vim's `timeoutlen`.
274    pub timeout_len: core::time::Duration,
275    /// Maximum undo-tree depth. Older entries pruned.
276    pub undo_levels: u32,
277    /// Break the current undo group on cursor motion in insert mode.
278    /// Matches vim default; turn off to merge multi-segment edits.
279    pub undo_break_on_motion: bool,
280    /// Reject every edit. `:set ro` sets this; `:w!` clears it.
281    pub readonly: bool,
282    /// Soft-wrap behavior for lines that exceed the viewport width.
283    /// Maps directly to `:set wrap` / `:set linebreak` / `:set nowrap`.
284    pub wrap: WrapMode,
285    /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
286    pub textwidth: u32,
287    /// Show absolute line numbers in the gutter. Matches `:set number`.
288    /// Default `true`.
289    pub number: bool,
290    /// Show relative line offsets in the gutter. Combined with `number`,
291    /// enables hybrid mode. Matches `:set relativenumber`. Default `false`.
292    pub relativenumber: bool,
293    /// Minimum gutter width in cells for the line-number column.
294    /// Width grows past this to fit the largest displayed number.
295    /// Matches vim's `:set numberwidth` / `:set nuw`. Default `4`. Range 1..=20.
296    pub numberwidth: usize,
297    /// Highlight the row where the cursor sits. Matches vim's `:set cursorline`.
298    /// Default `false`.
299    pub cursorline: bool,
300    /// Highlight the column where the cursor sits. Matches vim's `:set cursorcolumn`.
301    /// Default `false`.
302    pub cursorcolumn: bool,
303    /// Whether to reserve a 1-cell sign column for diagnostics and git signs.
304    /// Matches vim's `:set signcolumn`. Default [`SignColumnMode::Auto`].
305    pub signcolumn: SignColumnMode,
306    /// Number of cells reserved for a fold-marker gutter (0 = none, max 12).
307    /// Matches vim's `:set foldcolumn`. Default `0`.
308    pub foldcolumn: u32,
309    /// Comma-separated 1-based column indices for vertical rulers.
310    /// Empty string = no rulers. Matches vim's `:set colorcolumn`. Default `""`.
311    pub colorcolumn: String,
312}
313
314/// Sign-column display mode. Controls whether a 1-cell gutter is reserved
315/// for diagnostic and git signs. Matches vim's `:set signcolumn`.
316#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
317#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
318pub enum SignColumnMode {
319    /// Never reserve a sign column.
320    No,
321    /// Always reserve a sign column.
322    Yes,
323    /// Reserve only when at least one sign is visible (default).
324    #[default]
325    Auto,
326}
327
328/// Soft-wrap mode for the renderer + scroll math + `gj` / `gk`.
329/// Engine-native equivalent of [`hjkl_buffer::Wrap`]; the engine
330/// converts at the boundary to the buffer's runtime wrap setting.
331#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
332#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
333pub enum WrapMode {
334    /// Long lines extend past the right edge; `top_col` clips the
335    /// left side. Matches vim's `:set nowrap`.
336    #[default]
337    None,
338    /// Break at the cell boundary regardless of word edges. Matches
339    /// `:set wrap`.
340    Char,
341    /// Break at the last whitespace inside the visible width when
342    /// possible; falls back to a char break for runs longer than the
343    /// width. Matches `:set linebreak`.
344    Word,
345}
346
347/// Typed value for [`Options::set_by_name`] / [`Options::get_by_name`].
348///
349/// `:set tabstop=4` parses as `OptionValue::Int(4)`;
350/// `:set noexpandtab` parses as `OptionValue::Bool(false)`;
351/// `:set iskeyword=...` as `OptionValue::String(...)`.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum OptionValue {
354    Bool(bool),
355    Int(i64),
356    String(String),
357}
358
359impl Default for Options {
360    fn default() -> Self {
361        Options {
362            tabstop: 4,
363            shiftwidth: 4,
364            expandtab: true,
365            softtabstop: 4,
366            iskeyword: "@,48-57,_,192-255".to_string(),
367            ignorecase: false,
368            smartcase: false,
369            hlsearch: true,
370            incsearch: true,
371            wrapscan: true,
372            autoindent: true,
373            smartindent: true,
374            timeout_len: core::time::Duration::from_millis(1000),
375            undo_levels: 1000,
376            undo_break_on_motion: true,
377            readonly: false,
378            wrap: WrapMode::None,
379            textwidth: 79,
380            number: true,
381            relativenumber: false,
382            numberwidth: 4,
383            cursorline: false,
384            cursorcolumn: false,
385            signcolumn: SignColumnMode::Auto,
386            foldcolumn: 0,
387            colorcolumn: String::new(),
388        }
389    }
390}
391
392impl Options {
393    /// Set an option by name. Vim-flavored option naming. Returns
394    /// [`EngineError::Ex`] for unknown names or type-mismatched values.
395    ///
396    /// Booleans accept `OptionValue::Bool(_)` directly or
397    /// `OptionValue::Int(0)`/`Int(non_zero)`. Integers accept only
398    /// `Int(_)`. Strings accept only `String(_)`.
399    pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
400        macro_rules! set_bool {
401            ($field:ident) => {{
402                self.$field = match val {
403                    OptionValue::Bool(b) => b,
404                    OptionValue::Int(n) => n != 0,
405                    other => {
406                        return Err(EngineError::Ex(format!(
407                            "option `{name}` expects bool, got {other:?}"
408                        )));
409                    }
410                };
411                Ok(())
412            }};
413        }
414        macro_rules! set_u32 {
415            ($field:ident) => {{
416                self.$field = match val {
417                    OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
418                    OptionValue::Int(n) => {
419                        return Err(EngineError::Ex(format!(
420                            "option `{name}` out of u32 range: {n}"
421                        )));
422                    }
423                    other => {
424                        return Err(EngineError::Ex(format!(
425                            "option `{name}` expects int, got {other:?}"
426                        )));
427                    }
428                };
429                Ok(())
430            }};
431        }
432        macro_rules! set_string {
433            ($field:ident) => {{
434                self.$field = match val {
435                    OptionValue::String(s) => s,
436                    other => {
437                        return Err(EngineError::Ex(format!(
438                            "option `{name}` expects string, got {other:?}"
439                        )));
440                    }
441                };
442                Ok(())
443            }};
444        }
445        match name {
446            "tabstop" | "ts" => set_u32!(tabstop),
447            "shiftwidth" | "sw" => set_u32!(shiftwidth),
448            "softtabstop" | "sts" => set_u32!(softtabstop),
449            "textwidth" | "tw" => set_u32!(textwidth),
450            "expandtab" | "et" => set_bool!(expandtab),
451            "iskeyword" | "isk" => set_string!(iskeyword),
452            "ignorecase" | "ic" => set_bool!(ignorecase),
453            "smartcase" | "scs" => set_bool!(smartcase),
454            "hlsearch" | "hls" => set_bool!(hlsearch),
455            "incsearch" | "is" => set_bool!(incsearch),
456            "wrapscan" | "ws" => set_bool!(wrapscan),
457            "autoindent" | "ai" => set_bool!(autoindent),
458            "smartindent" | "si" => set_bool!(smartindent),
459            "timeoutlen" | "tm" => {
460                self.timeout_len = match val {
461                    OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
462                    other => {
463                        return Err(EngineError::Ex(format!(
464                            "option `{name}` expects non-negative int (millis), got {other:?}"
465                        )));
466                    }
467                };
468                Ok(())
469            }
470            "undolevels" | "ul" => set_u32!(undo_levels),
471            "undobreak" => set_bool!(undo_break_on_motion),
472            "readonly" | "ro" => set_bool!(readonly),
473            "wrap" => {
474                let on = match val {
475                    OptionValue::Bool(b) => b,
476                    OptionValue::Int(n) => n != 0,
477                    other => {
478                        return Err(EngineError::Ex(format!(
479                            "option `{name}` expects bool, got {other:?}"
480                        )));
481                    }
482                };
483                self.wrap = match (on, self.wrap) {
484                    (false, _) => WrapMode::None,
485                    (true, WrapMode::Word) => WrapMode::Word,
486                    (true, _) => WrapMode::Char,
487                };
488                Ok(())
489            }
490            "linebreak" | "lbr" => {
491                let on = match val {
492                    OptionValue::Bool(b) => b,
493                    OptionValue::Int(n) => n != 0,
494                    other => {
495                        return Err(EngineError::Ex(format!(
496                            "option `{name}` expects bool, got {other:?}"
497                        )));
498                    }
499                };
500                self.wrap = match (on, self.wrap) {
501                    (true, _) => WrapMode::Word,
502                    (false, WrapMode::Word) => WrapMode::Char,
503                    (false, other) => other,
504                };
505                Ok(())
506            }
507            "number" | "nu" => set_bool!(number),
508            "relativenumber" | "rnu" => set_bool!(relativenumber),
509            "numberwidth" | "nuw" => {
510                self.numberwidth = match val {
511                    OptionValue::Int(n) if (1..=20).contains(&n) => n as usize,
512                    OptionValue::Int(n) => {
513                        return Err(EngineError::Ex(format!(
514                            "option `{name}` must be in range 1..=20, got {n}"
515                        )));
516                    }
517                    other => {
518                        return Err(EngineError::Ex(format!(
519                            "option `{name}` expects int, got {other:?}"
520                        )));
521                    }
522                };
523                Ok(())
524            }
525            "cursorline" | "cul" => set_bool!(cursorline),
526            "cursorcolumn" | "cuc" => set_bool!(cursorcolumn),
527            "signcolumn" | "scl" => {
528                self.signcolumn = match val {
529                    OptionValue::String(ref s) => match s.as_str() {
530                        "yes" => SignColumnMode::Yes,
531                        "no" => SignColumnMode::No,
532                        "auto" => SignColumnMode::Auto,
533                        other => {
534                            return Err(EngineError::Ex(format!(
535                                "option `{name}` must be `yes`, `no`, or `auto`, got {other:?}"
536                            )));
537                        }
538                    },
539                    other => {
540                        return Err(EngineError::Ex(format!(
541                            "option `{name}` expects string (yes/no/auto), got {other:?}"
542                        )));
543                    }
544                };
545                Ok(())
546            }
547            "foldcolumn" | "fdc" => {
548                self.foldcolumn = match val {
549                    OptionValue::Int(n) if (0..=12).contains(&n) => n as u32,
550                    OptionValue::Int(n) => {
551                        return Err(EngineError::Ex(format!(
552                            "option `{name}` must be in range 0..=12, got {n}"
553                        )));
554                    }
555                    other => {
556                        return Err(EngineError::Ex(format!(
557                            "option `{name}` expects int (0-12), got {other:?}"
558                        )));
559                    }
560                };
561                Ok(())
562            }
563            "colorcolumn" | "cc" => set_string!(colorcolumn),
564            other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
565        }
566    }
567
568    /// Read an option by name. `None` for unknown names.
569    pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
570        Some(match name {
571            "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
572            "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
573            "softtabstop" | "sts" => OptionValue::Int(self.softtabstop as i64),
574            "textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
575            "expandtab" | "et" => OptionValue::Bool(self.expandtab),
576            "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
577            "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
578            "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
579            "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
580            "incsearch" | "is" => OptionValue::Bool(self.incsearch),
581            "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
582            "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
583            "smartindent" | "si" => OptionValue::Bool(self.smartindent),
584            "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
585            "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
586            "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
587            "readonly" | "ro" => OptionValue::Bool(self.readonly),
588            "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
589            "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
590            "number" | "nu" => OptionValue::Bool(self.number),
591            "relativenumber" | "rnu" => OptionValue::Bool(self.relativenumber),
592            "numberwidth" | "nuw" => OptionValue::Int(self.numberwidth as i64),
593            "cursorline" | "cul" => OptionValue::Bool(self.cursorline),
594            "cursorcolumn" | "cuc" => OptionValue::Bool(self.cursorcolumn),
595            "signcolumn" | "scl" => OptionValue::String(
596                match self.signcolumn {
597                    SignColumnMode::Yes => "yes",
598                    SignColumnMode::No => "no",
599                    SignColumnMode::Auto => "auto",
600                }
601                .to_string(),
602            ),
603            "foldcolumn" | "fdc" => OptionValue::Int(self.foldcolumn as i64),
604            "colorcolumn" | "cc" => OptionValue::String(self.colorcolumn.clone()),
605            _ => return None,
606        })
607    }
608}
609
610/// Visible region of a buffer — the runtime viewport state the host
611/// owns and mutates per render frame.
612///
613/// 0.0.34 (Patch C-δ.1): semantic ownership moved from
614/// [`hjkl_buffer::Buffer`] to [`Host`]. The struct still lives in
615/// `hjkl-buffer` (alongside [`hjkl_buffer::Wrap`] and the rope-walking
616/// `wrap_segments` math it depends on) so the dependency graph stays
617/// `engine → buffer`; the engine re-exports it as
618/// [`crate::types::Viewport`] (this alias) for hosts that program to
619/// the SPEC surface.
620///
621/// The architectural decision is "viewport lives on Host, not Buffer":
622/// vim logic must work in GUI hosts (variable-width fonts, pixel
623/// canvases, soft-wrap by pixel) as well as TUI hosts, so the runtime
624/// viewport state is expressed in cells/rows/cols and is owned by the
625/// host. `top_row` and `top_col` are the first visible row / column
626/// (`top_col` is a char index).
627///
628/// `wrap` and `text_width` together drive soft-wrap-aware scrolling
629/// and motion. `text_width` is the cell width of the text area
630/// (i.e., `width` minus any gutter the host renders).
631pub use hjkl_buffer::Viewport;
632
633/// Opaque buffer identifier owned by the host. Engine echoes it back
634/// in [`Host::Intent`] variants for buffer-list operations
635/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
636#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
637pub struct BufferId(pub u64);
638
639/// Modifier bits accompanying every keystroke.
640#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
641pub struct Modifiers {
642    pub ctrl: bool,
643    pub shift: bool,
644    pub alt: bool,
645    pub super_: bool,
646}
647
648/// Special key codes — anything that isn't a printable character.
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
650#[non_exhaustive]
651pub enum SpecialKey {
652    Esc,
653    Enter,
654    Backspace,
655    Tab,
656    BackTab,
657    Up,
658    Down,
659    Left,
660    Right,
661    Home,
662    End,
663    PageUp,
664    PageDown,
665    Insert,
666    Delete,
667    F(u8),
668}
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
671pub enum MouseKind {
672    Press,
673    Release,
674    Drag,
675    ScrollUp,
676    ScrollDown,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
680pub struct MouseEvent {
681    pub kind: MouseKind,
682    pub pos: Pos,
683    pub mods: Modifiers,
684}
685
686/// Single input event handed to the engine.
687///
688/// `Paste` content bypasses insert-mode mappings, abbreviations, and
689/// autoindent; the engine inserts the bracketed-paste payload as-is.
690#[derive(Debug, Clone, PartialEq, Eq)]
691#[non_exhaustive]
692pub enum Input {
693    Char(char, Modifiers),
694    Key(SpecialKey, Modifiers),
695    Mouse(MouseEvent),
696    Paste(String),
697    FocusGained,
698    FocusLost,
699    Resize(u16, u16),
700}
701
702/// Host adapter consumed by the engine. Lives behind the planned
703/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
704/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
705/// align against.
706///
707/// Methods with default impls return safe no-ops so hosts that don't
708/// need a feature (cancellation, wrap-aware motion, syntax highlights)
709/// can ignore them.
710pub trait Host: Send {
711    /// Custom intent type. Hosts that don't fan out actions back to
712    /// themselves can use the unit type via the default impl approach
713    /// (set associated type explicitly).
714    type Intent;
715
716    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
717
718    /// Fire-and-forget clipboard write. Engine never blocks; the host
719    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
720    /// `pbcopy`, …).
721    fn write_clipboard(&mut self, text: String);
722
723    /// Returns the last-known cached clipboard value. May be stale —
724    /// matches the OSC52/wl-paste model neovim and helix both ship.
725    fn read_clipboard(&mut self) -> Option<String>;
726
727    // ── Time + cancellation ──
728
729    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
730    /// reads this; engine never reads `Instant::now()` directly so
731    /// macro replay stays deterministic.
732    fn now(&self) -> core::time::Duration;
733
734    /// Cooperative cancellation. Engine polls during long search /
735    /// regex / multi-cursor edit loops. Default returns `false`.
736    fn should_cancel(&self) -> bool {
737        false
738    }
739
740    // ── Search prompt ──
741
742    /// Synchronously prompt the user for a search pattern. Returning
743    /// `None` aborts the search.
744    fn prompt_search(&mut self) -> Option<String>;
745
746    // ── Wrap-aware motion (default: wrap is identity) ──
747
748    /// Map a logical position to its display line for `gj`/`gk`. Hosts
749    /// without wrapping may use the default identity impl.
750    fn display_line_for(&self, pos: Pos) -> u32 {
751        pos.line
752    }
753
754    /// Inverse of [`display_line_for`]. Default identity.
755    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
756        Pos { line, col }
757    }
758
759    // ── Syntax highlights (default: none) ──
760
761    /// Host-supplied syntax highlights for `range`. Empty by default;
762    /// hosts wire tree-sitter or LSP semantic tokens here.
763    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
764        let _ = range;
765        Vec::new()
766    }
767
768    // ── Cursor shape ──
769
770    /// Engine emits this on every mode transition. Hosts repaint the
771    /// cursor in the requested shape.
772    fn emit_cursor_shape(&mut self, shape: CursorShape);
773
774    // ── Viewport (host owns runtime viewport state) ──
775
776    /// Borrow the host's viewport. The host writes `width`/`height`/
777    /// `text_width`/`wrap` per render frame; the engine reads/writes
778    /// `top_row` / `top_col` to scroll. 0.0.34 (Patch C-δ.1) moved
779    /// this off [`hjkl_buffer::Buffer`] onto `Host`.
780    fn viewport(&self) -> &Viewport;
781
782    /// Mutable viewport access. Engine motion + scroll code routes
783    /// here when scrolloff math advances `top_row`.
784    fn viewport_mut(&mut self) -> &mut Viewport;
785
786    // ── Custom intent fan-out ──
787
788    /// Host-defined event the engine raises (LSP request, fold op,
789    /// buffer switch, …).
790    fn emit_intent(&mut self, intent: Self::Intent);
791}
792
793/// Default no-op [`Host`] implementation. Suitable for tests, headless
794/// embedding, or any host that doesn't yet need clipboard / cursor-shape
795/// / cancellation plumbing.
796///
797/// Behaviour:
798/// - `write_clipboard` stores the most recent payload in an in-memory
799///   slot; `read_clipboard` returns it. Round-trip-only — no OS-level
800///   clipboard touched.
801/// - `now` returns wall-clock duration since construction.
802/// - `prompt_search` returns `None` (search is aborted).
803/// - `emit_cursor_shape` records the most recent shape; readable via
804///   [`DefaultHost::last_cursor_shape`].
805/// - `emit_intent` discards intents (intent type is `()`).
806#[derive(Debug)]
807pub struct DefaultHost {
808    clipboard: Option<String>,
809    last_cursor_shape: CursorShape,
810    started: std::time::Instant,
811    viewport: Viewport,
812}
813
814impl Default for DefaultHost {
815    fn default() -> Self {
816        Self::new()
817    }
818}
819
820impl DefaultHost {
821    /// Default viewport size for headless / test hosts: 80x24, no
822    /// soft-wrap. Matches the conventional terminal default.
823    pub const DEFAULT_VIEWPORT: Viewport = Viewport {
824        top_row: 0,
825        top_col: 0,
826        width: 80,
827        height: 24,
828        wrap: hjkl_buffer::Wrap::None,
829        text_width: 80,
830        tab_width: 0,
831    };
832
833    pub fn new() -> Self {
834        Self {
835            clipboard: None,
836            last_cursor_shape: CursorShape::Block,
837            started: std::time::Instant::now(),
838            viewport: Self::DEFAULT_VIEWPORT,
839        }
840    }
841
842    /// Construct a [`DefaultHost`] with a custom initial viewport.
843    /// Useful for tests that want to exercise scrolloff math at a
844    /// specific window size.
845    pub fn with_viewport(viewport: Viewport) -> Self {
846        Self {
847            clipboard: None,
848            last_cursor_shape: CursorShape::Block,
849            started: std::time::Instant::now(),
850            viewport,
851        }
852    }
853
854    /// Most recent cursor shape requested by the engine.
855    pub fn last_cursor_shape(&self) -> CursorShape {
856        self.last_cursor_shape
857    }
858}
859
860impl Host for DefaultHost {
861    type Intent = ();
862
863    fn write_clipboard(&mut self, text: String) {
864        self.clipboard = Some(text);
865    }
866
867    fn read_clipboard(&mut self) -> Option<String> {
868        self.clipboard.clone()
869    }
870
871    fn now(&self) -> core::time::Duration {
872        self.started.elapsed()
873    }
874
875    fn prompt_search(&mut self) -> Option<String> {
876        None
877    }
878
879    fn emit_cursor_shape(&mut self, shape: CursorShape) {
880        self.last_cursor_shape = shape;
881    }
882
883    fn viewport(&self) -> &Viewport {
884        &self.viewport
885    }
886
887    fn viewport_mut(&mut self) -> &mut Viewport {
888        &mut self.viewport
889    }
890
891    fn emit_intent(&mut self, _intent: Self::Intent) {}
892}
893
894/// Engine render frame consumed by the host once per redraw.
895///
896/// Borrow-style — the engine builds it on demand from its internal
897/// state without allocating clones of large fields. Hosts diff across
898/// frames to decide what to repaint.
899///
900/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
901/// a snapshot of the current line count (to size the gutter). The
902/// SPEC-target fields (`selections`, `highlights`, `command_line`,
903/// `search_prompt`, `status_line`) land once trait extraction wires
904/// the FSM through `SelectionSet` and the highlight pipeline.
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
906pub struct RenderFrame {
907    pub mode: SnapshotMode,
908    pub cursor_row: u32,
909    pub cursor_col: u32,
910    pub cursor_shape: CursorShape,
911    pub viewport_top: u32,
912    pub line_count: u32,
913}
914
915/// Coarse editor snapshot suitable for serde round-tripping.
916///
917/// Today's shape is intentionally minimal — it carries only the bits
918/// the runtime [`crate::Editor`] knows how to round-trip without the
919/// trait extraction (mode, cursor, lines, viewport top, settings).
920/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
921/// grows to cover full SPEC state: registers, marks, jump list, change
922/// list, undo tree, full options.
923///
924/// Hosts that persist editor state between sessions should:
925///
926/// - Treat the snapshot as opaque. Don't manually mutate fields.
927/// - Always check `version` after deserialization; reject on
928///   mismatch rather than attempt migration.
929///
930/// # Wire-format stability
931///
932/// - **0.0.x:** [`Self::VERSION`] bumps with every structural change to
933///   the snapshot. Hosts must reject mismatched persisted state — no
934///   migration path is offered.
935/// - **0.1.0:** [`Self::VERSION`] freezes. Hosts persisting editor state
936///   between sessions can rely on the wire format being stable for the
937///   entire 0.1.x line.
938/// - **0.2.0+:** any further structural change to this struct requires a
939///   `VERSION++` bump and is gated behind a major version bump of the
940///   crate.
941#[derive(Debug, Clone)]
942#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
943pub struct EditorSnapshot {
944    /// Format version. See [`Self::VERSION`] for the lock policy.
945    /// Hosts use this to detect mismatched persisted state.
946    pub version: u32,
947    /// Mode at snapshot time (status-line granularity).
948    pub mode: SnapshotMode,
949    /// Cursor `(row, col)` in byte indexing.
950    pub cursor: (u32, u32),
951    /// Buffer lines. Trailing `\n` not included.
952    pub lines: Vec<String>,
953    /// Viewport top line at snapshot time.
954    pub viewport_top: u32,
955    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
956    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
957    /// doesn't derive them today.
958    pub registers: crate::Registers,
959    /// Named marks — both lowercase (`'a`–`'z`, buffer-scope) and
960    /// uppercase (`'A`–`'Z`, file-scope). Round-trips across tab
961    /// swaps in the host.
962    ///
963    /// 0.0.36: consolidated from the prior `file_marks` field;
964    /// lowercase marks now persist as well since they live in the
965    /// same unified [`crate::Editor::marks`] map.
966    pub marks: std::collections::BTreeMap<char, (u32, u32)>,
967}
968
969/// Status-line mode summary. Bridges to the legacy
970/// [`crate::VimMode`] without leaking the full FSM type into the
971/// snapshot wire format.
972#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
973#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
974pub enum SnapshotMode {
975    #[default]
976    Normal,
977    Insert,
978    Visual,
979    VisualLine,
980    VisualBlock,
981}
982
983impl EditorSnapshot {
984    /// Current snapshot format version.
985    ///
986    /// Bumped to 2 in v0.0.8: registers added.
987    /// Bumped to 3 in v0.0.9: file_marks added.
988    /// Bumped to 4 in v0.0.36: file_marks → unified `marks` map
989    /// (lowercase + uppercase consolidated).
990    ///
991    /// # Lock policy
992    ///
993    /// - **0.0.x (today):** `VERSION` bumps freely with each structural
994    ///   change to [`EditorSnapshot`]. Persisted state from an older
995    ///   patch release will not round-trip; hosts must reject the
996    ///   snapshot rather than attempt a field-by-field migration.
997    /// - **0.1.0:** `VERSION` freezes. Hosts persisting editor state
998    ///   between sessions can rely on the wire format being stable for
999    ///   the entire 0.1.x line.
1000    /// - **0.2.0+:** any further structural change requires `VERSION++`
1001    ///   together with a major-version bump of `hjkl-engine`.
1002    pub const VERSION: u32 = 4;
1003}
1004
1005/// Errors surfaced from the engine to the host. Intentionally narrow —
1006/// callsites that fail in user-facing ways return `Result<_,
1007/// EngineError>`; internal invariant breaks use `debug_assert!`.
1008#[derive(Debug, thiserror::Error)]
1009pub enum EngineError {
1010    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
1011    /// regex error in the status line.
1012    #[error("regex compile error: {0}")]
1013    Regex(#[from] regex::Error),
1014
1015    /// `:[range]` parse failed.
1016    #[error("invalid range: {0}")]
1017    InvalidRange(String),
1018
1019    /// Ex command parse failed (unknown command, malformed args).
1020    #[error("ex parse: {0}")]
1021    Ex(String),
1022
1023    /// Edit attempted on a read-only buffer.
1024    #[error("buffer is read-only")]
1025    ReadOnly,
1026
1027    /// Position passed by the caller pointed outside the buffer.
1028    #[error("position out of bounds: {0:?}")]
1029    OutOfBounds(Pos),
1030
1031    /// Snapshot version mismatch. Host should treat as "abandon
1032    /// snapshot" rather than attempt migration.
1033    #[error("snapshot version mismatch: file={0}, expected={1}")]
1034    SnapshotVersion(u32, u32),
1035}
1036
1037pub(crate) mod sealed {
1038    /// Sealing trait for the planned 0.1.0 [`super::Buffer`] surface.
1039    /// Pre-1.0 the engine reserves the right to add methods to the
1040    /// `Buffer` super-trait without a major bump; downstream cannot
1041    /// `impl Buffer` from outside this family.
1042    ///
1043    /// The in-tree [`hjkl_buffer::Buffer`] is the canonical impl; the
1044    /// `Sealed` marker for it lives in `crate::buffer_impl`. The module
1045    /// itself stays `pub(crate)` so the sibling impl module can name
1046    /// the trait while keeping the seal closed to the outside world.
1047    pub trait Sealed {}
1048}
1049
1050/// Cursor sub-trait of [`Buffer`].
1051///
1052/// `Pos` here is the engine's grapheme-indexed [`Pos`] type. Buffer
1053/// implementations convert at the boundary if their internal indexing
1054/// differs (e.g., the rope's byte indexing).
1055pub trait Cursor: Send {
1056    /// Active primary cursor position.
1057    fn cursor(&self) -> Pos;
1058    /// Move the active primary cursor.
1059    fn set_cursor(&mut self, pos: Pos);
1060    /// Byte offset for `pos`. Used by regex search bridges.
1061    fn byte_offset(&self, pos: Pos) -> usize;
1062    /// Inverse of [`Self::byte_offset`].
1063    fn pos_at_byte(&self, byte: usize) -> Pos;
1064}
1065
1066/// Read-only query sub-trait of [`Buffer`].
1067pub trait Query: Send {
1068    /// Number of logical lines (excluding the implicit trailing line).
1069    fn line_count(&self) -> u32;
1070    /// Borrow line `idx` (0-based). Implementations should panic on
1071    /// out-of-bounds rather than silently return empty.
1072    fn line(&self, idx: u32) -> &str;
1073    /// Total buffer length in bytes.
1074    fn len_bytes(&self) -> usize;
1075    /// Slice for the half-open `range`. May allocate (rope joins)
1076    /// or borrow (contiguous storage). Returns
1077    /// [`std::borrow::Cow<'_, str>`] so contiguous backends can
1078    /// avoid the allocation.
1079    fn slice(&self, range: core::ops::Range<Pos>) -> std::borrow::Cow<'_, str>;
1080    /// Monotonic mutation generation counter. Increments on every
1081    /// content-changing call (insert / delete / replace / fold-touch
1082    /// edit / `set_content`). Read-only ops (cursor moves, queries,
1083    /// view changes) leave it untouched.
1084    ///
1085    /// Engine consumers cache per-row data (search-match positions,
1086    /// syntax spans, wrap layout) keyed off this counter — when it
1087    /// advances, the cache is invalidated.
1088    ///
1089    /// Implementations may return any monotonically non-decreasing
1090    /// value (zero is fine for non-canonical impls that don't have a
1091    /// caching story); the contract is "if `dirty_gen` changed, the
1092    /// content **may** have changed."
1093    fn dirty_gen(&self) -> u64 {
1094        0
1095    }
1096
1097    /// Byte offset of the first byte of `row` within the buffer's
1098    /// canonical `lines().join("\n")` rendering. Out-of-range rows
1099    /// clamp to `len_bytes()`.
1100    ///
1101    /// Default implementation walks every prior row's byte length and
1102    /// adds a separator byte per row gap. Backends with a faster path
1103    /// (rope position-of-line) should override.
1104    ///
1105    /// Pre-0.1.0 default-impl addition — does not extend the sealed
1106    /// surface for downstream impls.
1107    fn byte_of_row(&self, row: usize) -> usize {
1108        let n = self.line_count() as usize;
1109        let row = row.min(n);
1110        let mut acc = 0usize;
1111        for r in 0..row {
1112            acc += self.line(r as u32).len();
1113            // Separator newline between rows. The canonical engine
1114            // join uses `\n` between every pair of lines (no trailing
1115            // newline), so add one separator per row strictly before
1116            // the last buffer row.
1117            if r + 1 < n {
1118                acc += 1;
1119            }
1120        }
1121        acc
1122    }
1123}
1124
1125/// Mutating sub-trait of [`Buffer`]. Distinct trait name from the
1126/// crate-root [`Edit`] struct — this one carries methods, the other
1127/// is a value type.
1128pub trait BufferEdit: Send {
1129    /// Insert `text` at `pos`. Implementations clamp out-of-range
1130    /// positions to the document end.
1131    fn insert_at(&mut self, pos: Pos, text: &str);
1132    /// Delete the half-open `range`.
1133    fn delete_range(&mut self, range: core::ops::Range<Pos>);
1134    /// Replace the half-open `range` with `replacement`.
1135    fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str);
1136    /// Replace the entire buffer content with `text`. The cursor is
1137    /// clamped to the surviving content. Used by `:e!` / undo
1138    /// restore / snapshot replay where expressing "replace whole
1139    /// buffer" via [`replace_range`] would require knowing the end
1140    /// position. Default impl uses [`replace_range`] with a
1141    /// best-effort end (`u32::MAX` / `u32::MAX`); the canonical
1142    /// in-tree impl overrides it for a single-shot rebuild.
1143    fn replace_all(&mut self, text: &str) {
1144        self.replace_range(
1145            Pos::ORIGIN..Pos {
1146                line: u32::MAX,
1147                col: u32::MAX,
1148            },
1149            text,
1150        );
1151    }
1152}
1153
1154/// Search sub-trait of [`Buffer`]. The pattern is owned by the engine;
1155/// buffers do not cache compiled regexes.
1156pub trait Search: Send {
1157    /// First match at-or-after `from`. `None` when no match remains.
1158    fn find_next(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1159    /// Last match at-or-before `from`.
1160    fn find_prev(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1161}
1162
1163/// Buffer super-trait — the pre-1.0 contract every backend implements.
1164///
1165/// Sealed to the engine's own crate family (in-tree
1166/// `hjkl_buffer::Buffer` is the canonical impl). Pre-0.1.0 the engine
1167/// reserves the right to add methods on patch bumps; downstream
1168/// consumers depend on the full trait without naming
1169/// [`sealed::Sealed`].
1170pub trait Buffer: Cursor + Query + BufferEdit + Search + sealed::Sealed + Send {}
1171
1172/// Canonical fold-mutation op carried through [`FoldProvider::apply`].
1173///
1174/// Introduced in 0.0.38 (Patch C-δ.4). The engine raises one `FoldOp`
1175/// per `z…` keystroke / `:fold*` Ex command and dispatches it through
1176/// the [`FoldProvider::apply`] surface. Hosts that own the fold storage
1177/// (default in-tree wraps `&mut hjkl_buffer::Buffer`) decide how to
1178/// apply it — possibly batching, deduping, or vetoing. Hosts without
1179/// folds use [`NoopFoldProvider`] which silently discards every op.
1180///
1181/// `FoldOp` is engine-canonical (per the design doc's resolved
1182/// question 8.2): hosts don't invent their own fold-op enums. Each
1183/// host that exposes folds embeds a `FoldOp` variant in its `Intent`
1184/// enum (or simply observes the engine's pending-fold-op queue via
1185/// [`crate::Editor::take_fold_ops`]).
1186///
1187/// Row indices are zero-based and match the row coordinate space used
1188/// by [`hjkl_buffer::Buffer`]'s fold methods.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1190#[non_exhaustive]
1191pub enum FoldOp {
1192    /// `:fold {start,end}` / `zf{motion}` / visual-mode `zf` — register a
1193    /// new fold spanning `[start_row, end_row]` (inclusive). The `closed`
1194    /// flag matches the underlying [`hjkl_buffer::Fold::closed`].
1195    Add {
1196        start_row: usize,
1197        end_row: usize,
1198        closed: bool,
1199    },
1200    /// `zd` — drop the fold under `row` if any.
1201    RemoveAt(usize),
1202    /// `zo` — open the fold under `row` if any.
1203    OpenAt(usize),
1204    /// `zc` — close the fold under `row` if any.
1205    CloseAt(usize),
1206    /// `za` — flip the fold under `row` between open / closed.
1207    ToggleAt(usize),
1208    /// `zR` — open every fold in the buffer.
1209    OpenAll,
1210    /// `zM` — close every fold in the buffer.
1211    CloseAll,
1212    /// `zE` — eliminate every fold.
1213    ClearAll,
1214    /// Edit-driven fold invalidation. Drops every fold touching the
1215    /// row range `[start_row, end_row]`. Mirrors vim's "edits inside a
1216    /// fold open it" behaviour. Fired by the engine's edit pipeline,
1217    /// not bound to a `z…` keystroke.
1218    Invalidate { start_row: usize, end_row: usize },
1219}
1220
1221/// Fold-iteration + mutation trait. The engine asks "what's the next
1222/// visible row" / "is this row hidden" through this surface, and
1223/// dispatches fold mutations through [`FoldProvider::apply`], so fold
1224/// storage can live wherever the host pleases (on the buffer, in a
1225/// separate host-side fold tree, or absent entirely).
1226///
1227/// Introduced in 0.0.32 (Patch C-β) for read access; 0.0.38 (Patch
1228/// C-δ.4) added [`FoldProvider::apply`] + [`FoldProvider::invalidate_range`]
1229/// so engine call sites that used to call
1230/// `hjkl_buffer::Buffer::{open,close,toggle,…}_fold_at` directly route
1231/// through this trait now. The canonical read-only implementation
1232/// [`crate::buffer_impl::BufferFoldProvider`] wraps a
1233/// `&hjkl_buffer::Buffer`; the canonical mutable implementation
1234/// [`crate::buffer_impl::BufferFoldProviderMut`] wraps a
1235/// `&mut hjkl_buffer::Buffer`. Hosts that don't care about folds can
1236/// use [`NoopFoldProvider`].
1237///
1238/// The engine carries a `Box<dyn FoldProvider + 'a>` slot today and
1239/// looks up rows through it. Once `Editor<B, H>` flips generic
1240/// (Patch C, 0.1.0) the slot moves onto `Host` directly.
1241pub trait FoldProvider: Send {
1242    /// First visible row strictly after `row`, skipping hidden rows.
1243    /// `None` past the end of the buffer.
1244    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize>;
1245    /// First visible row strictly before `row`. `None` past the top.
1246    fn prev_visible_row(&self, row: usize) -> Option<usize>;
1247    /// Is `row` currently hidden by a closed fold?
1248    fn is_row_hidden(&self, row: usize) -> bool;
1249    /// Range `(start_row, end_row, closed)` of the fold containing
1250    /// `row`, if any. Lets `za` / `zo` / `zc` find their target
1251    /// without iterating the full fold list.
1252    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)>;
1253
1254    /// Apply a [`FoldOp`] to the underlying fold storage. Read-only
1255    /// providers (e.g. [`crate::buffer_impl::BufferFoldProvider`] which
1256    /// holds a `&Buffer`) and providers that don't track folds (e.g.
1257    /// [`NoopFoldProvider`]) implement this as a no-op.
1258    ///
1259    /// Default impl is a no-op so that read-only / host-stub providers
1260    /// don't need to override it; mutable providers
1261    /// (e.g. [`crate::buffer_impl::BufferFoldProviderMut`]) override
1262    /// this to dispatch to the underlying buffer's fold methods.
1263    fn apply(&mut self, op: FoldOp) {
1264        let _ = op;
1265    }
1266
1267    /// Drop every fold whose range overlaps `[start_row, end_row]`.
1268    /// Edit pipelines call this after a user edit so vim's "edits
1269    /// inside a fold open it" behaviour fires. Default impl forwards
1270    /// to [`FoldProvider::apply`] with a [`FoldOp::Invalidate`].
1271    fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
1272        self.apply(FoldOp::Invalidate { start_row, end_row });
1273    }
1274}
1275
1276/// No-op [`FoldProvider`] for hosts that don't expose folds. Every
1277/// row is visible; `is_row_hidden` always returns `false`.
1278#[derive(Debug, Default, Clone, Copy)]
1279pub struct NoopFoldProvider;
1280
1281impl FoldProvider for NoopFoldProvider {
1282    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize> {
1283        let last = row_count.saturating_sub(1);
1284        if last == 0 && row == 0 {
1285            return None;
1286        }
1287        let r = row.checked_add(1)?;
1288        (r <= last).then_some(r)
1289    }
1290
1291    fn prev_visible_row(&self, row: usize) -> Option<usize> {
1292        row.checked_sub(1)
1293    }
1294
1295    fn is_row_hidden(&self, _row: usize) -> bool {
1296        false
1297    }
1298
1299    fn fold_at_row(&self, _row: usize) -> Option<(usize, usize, bool)> {
1300        None
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    #[test]
1309    fn caret_is_empty() {
1310        let sel = Selection::caret(Pos::new(2, 4));
1311        assert!(sel.is_empty());
1312        assert_eq!(sel.anchor, sel.head);
1313    }
1314
1315    #[test]
1316    fn selection_set_default_has_one_caret() {
1317        let set = SelectionSet::default();
1318        assert_eq!(set.items.len(), 1);
1319        assert_eq!(set.primary, 0);
1320        assert_eq!(set.primary().anchor, Pos::ORIGIN);
1321    }
1322
1323    #[test]
1324    fn edit_constructors() {
1325        let p = Pos::new(0, 5);
1326        assert_eq!(Edit::insert(p, "x").range, p..p);
1327        assert!(Edit::insert(p, "x").replacement == "x");
1328        assert!(Edit::delete(p..p).replacement.is_empty());
1329    }
1330
1331    #[test]
1332    fn attrs_flags() {
1333        let a = Attrs::BOLD | Attrs::UNDERLINE;
1334        assert!(a.contains(Attrs::BOLD));
1335        assert!(!a.contains(Attrs::ITALIC));
1336    }
1337
1338    #[test]
1339    fn options_set_get_roundtrip() {
1340        let mut o = Options::default();
1341        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
1342        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
1343        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
1344        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
1345        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
1346            .unwrap();
1347        match o.get_by_name("iskeyword") {
1348            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
1349            other => panic!("expected String, got {other:?}"),
1350        }
1351    }
1352
1353    #[test]
1354    fn options_unknown_name_errors_on_set() {
1355        let mut o = Options::default();
1356        assert!(matches!(
1357            o.set_by_name("frobnicate", OptionValue::Int(1)),
1358            Err(EngineError::Ex(_))
1359        ));
1360        assert!(o.get_by_name("frobnicate").is_none());
1361    }
1362
1363    #[test]
1364    fn options_type_mismatch_errors() {
1365        let mut o = Options::default();
1366        assert!(matches!(
1367            o.set_by_name("tabstop", OptionValue::String("nope".into())),
1368            Err(EngineError::Ex(_))
1369        ));
1370        assert!(matches!(
1371            o.set_by_name("iskeyword", OptionValue::Int(7)),
1372            Err(EngineError::Ex(_))
1373        ));
1374    }
1375
1376    #[test]
1377    fn options_int_to_bool_coercion() {
1378        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
1379        // Common vim spelling.
1380        let mut o = Options::default();
1381        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
1382        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
1383        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
1384        assert!(matches!(
1385            o.get_by_name("ic"),
1386            Some(OptionValue::Bool(false))
1387        ));
1388    }
1389
1390    #[test]
1391    fn options_wrap_linebreak_roundtrip() {
1392        let mut o = Options::default();
1393        assert_eq!(o.wrap, WrapMode::None);
1394        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
1395        assert_eq!(o.wrap, WrapMode::Char);
1396        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
1397        assert_eq!(o.wrap, WrapMode::Word);
1398        assert!(matches!(
1399            o.get_by_name("wrap"),
1400            Some(OptionValue::Bool(true))
1401        ));
1402        assert!(matches!(
1403            o.get_by_name("lbr"),
1404            Some(OptionValue::Bool(true))
1405        ));
1406        o.set_by_name("linebreak", OptionValue::Bool(false))
1407            .unwrap();
1408        assert_eq!(o.wrap, WrapMode::Char);
1409        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
1410        assert_eq!(o.wrap, WrapMode::None);
1411    }
1412
1413    #[test]
1414    fn options_default_modern() {
1415        // 0.2.0: defaults flipped from vim's tabstop=8/expandtab=off to
1416        // modern editor defaults (4-space soft tabs).
1417        let o = Options::default();
1418        assert_eq!(o.tabstop, 4);
1419        assert_eq!(o.shiftwidth, 4);
1420        assert_eq!(o.softtabstop, 4);
1421        assert!(o.expandtab);
1422        assert!(o.hlsearch);
1423        assert!(o.wrapscan);
1424        assert!(o.smartindent);
1425        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
1426    }
1427
1428    #[test]
1429    fn editor_snapshot_version_const() {
1430        assert_eq!(EditorSnapshot::VERSION, 4);
1431    }
1432
1433    #[test]
1434    fn editor_snapshot_default_shape() {
1435        let s = EditorSnapshot {
1436            version: EditorSnapshot::VERSION,
1437            mode: SnapshotMode::Normal,
1438            cursor: (0, 0),
1439            lines: vec!["hello".to_string()],
1440            viewport_top: 0,
1441            registers: crate::Registers::default(),
1442            marks: Default::default(),
1443        };
1444        assert_eq!(s.cursor, (0, 0));
1445        assert_eq!(s.lines.len(), 1);
1446    }
1447
1448    #[cfg(feature = "serde")]
1449    #[test]
1450    fn editor_snapshot_roundtrip() {
1451        let mut marks = std::collections::BTreeMap::new();
1452        marks.insert('A', (5u32, 2u32));
1453        marks.insert('a', (1u32, 0u32));
1454        let s = EditorSnapshot {
1455            version: EditorSnapshot::VERSION,
1456            mode: SnapshotMode::Insert,
1457            cursor: (3, 7),
1458            lines: vec!["alpha".into(), "beta".into()],
1459            viewport_top: 2,
1460            registers: crate::Registers::default(),
1461            marks,
1462        };
1463        let json = serde_json::to_string(&s).unwrap();
1464        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
1465        assert_eq!(s.cursor, back.cursor);
1466        assert_eq!(s.lines, back.lines);
1467        assert_eq!(s.viewport_top, back.viewport_top);
1468    }
1469
1470    #[test]
1471    fn engine_error_display() {
1472        let e = EngineError::ReadOnly;
1473        assert_eq!(e.to_string(), "buffer is read-only");
1474        let e = EngineError::OutOfBounds(Pos::new(3, 7));
1475        assert!(e.to_string().contains("out of bounds"));
1476    }
1477
1478    // ── New render-level options ─────────────────────────────────────────────
1479
1480    #[test]
1481    fn options_cursorline_roundtrip() {
1482        let mut o = Options::default();
1483        assert!(!o.cursorline, "cursorline defaults to false");
1484        o.set_by_name("cursorline", OptionValue::Bool(true))
1485            .unwrap();
1486        assert!(matches!(
1487            o.get_by_name("cul"),
1488            Some(OptionValue::Bool(true))
1489        ));
1490        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1491        assert!(matches!(
1492            o.get_by_name("cursorline"),
1493            Some(OptionValue::Bool(false))
1494        ));
1495    }
1496
1497    #[test]
1498    fn options_cursorcolumn_roundtrip() {
1499        let mut o = Options::default();
1500        assert!(!o.cursorcolumn, "cursorcolumn defaults to false");
1501        o.set_by_name("cuc", OptionValue::Bool(true)).unwrap();
1502        assert!(matches!(
1503            o.get_by_name("cursorcolumn"),
1504            Some(OptionValue::Bool(true))
1505        ));
1506    }
1507
1508    #[test]
1509    fn options_signcolumn_roundtrip() {
1510        let mut o = Options::default();
1511        assert_eq!(
1512            o.signcolumn,
1513            SignColumnMode::Auto,
1514            "signcolumn defaults to auto"
1515        );
1516        o.set_by_name("signcolumn", OptionValue::String("yes".into()))
1517            .unwrap();
1518        assert_eq!(o.signcolumn, SignColumnMode::Yes);
1519        assert_eq!(
1520            o.get_by_name("scl"),
1521            Some(OptionValue::String("yes".into()))
1522        );
1523        o.set_by_name("scl", OptionValue::String("no".into()))
1524            .unwrap();
1525        assert_eq!(o.signcolumn, SignColumnMode::No);
1526        o.set_by_name("scl", OptionValue::String("auto".into()))
1527            .unwrap();
1528        assert_eq!(o.signcolumn, SignColumnMode::Auto);
1529    }
1530
1531    #[test]
1532    fn options_signcolumn_rejects_invalid() {
1533        let mut o = Options::default();
1534        assert!(matches!(
1535            o.set_by_name("signcolumn", OptionValue::String("maybe".into())),
1536            Err(EngineError::Ex(_))
1537        ));
1538        // Type mismatch
1539        assert!(matches!(
1540            o.set_by_name("signcolumn", OptionValue::Bool(true)),
1541            Err(EngineError::Ex(_))
1542        ));
1543    }
1544
1545    #[test]
1546    fn options_foldcolumn_roundtrip() {
1547        let mut o = Options::default();
1548        assert_eq!(o.foldcolumn, 0, "foldcolumn defaults to 0");
1549        o.set_by_name("fdc", OptionValue::Int(3)).unwrap();
1550        assert_eq!(o.foldcolumn, 3);
1551        assert_eq!(o.get_by_name("foldcolumn"), Some(OptionValue::Int(3)));
1552    }
1553
1554    #[test]
1555    fn options_foldcolumn_rejects_out_of_range() {
1556        let mut o = Options::default();
1557        assert!(matches!(
1558            o.set_by_name("foldcolumn", OptionValue::Int(13)),
1559            Err(EngineError::Ex(_))
1560        ));
1561        assert!(matches!(
1562            o.set_by_name("foldcolumn", OptionValue::Int(-1)),
1563            Err(EngineError::Ex(_))
1564        ));
1565    }
1566
1567    #[test]
1568    fn options_colorcolumn_roundtrip() {
1569        let mut o = Options::default();
1570        assert_eq!(o.colorcolumn, "", "colorcolumn defaults to empty string");
1571        o.set_by_name("cc", OptionValue::String("80,120".into()))
1572            .unwrap();
1573        assert_eq!(
1574            o.get_by_name("colorcolumn"),
1575            Some(OptionValue::String("80,120".into()))
1576        );
1577        o.set_by_name("colorcolumn", OptionValue::String(String::new()))
1578            .unwrap();
1579        assert_eq!(
1580            o.get_by_name("cc"),
1581            Some(OptionValue::String(String::new()))
1582        );
1583    }
1584
1585    #[test]
1586    fn options_cursorline_alias_cul() {
1587        let mut o = Options::default();
1588        // `:set cul` — bare name turns bool on
1589        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1590        assert!(o.cursorline);
1591        // `:set nocul` → Bool(false)
1592        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1593        assert!(!o.cursorline);
1594    }
1595
1596    #[test]
1597    fn sign_column_mode_default_is_auto() {
1598        assert_eq!(SignColumnMode::default(), SignColumnMode::Auto);
1599    }
1600}