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