Skip to main content

hjkl_engine/
types.rs

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