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