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 host writes `top_line` and `height`
453/// per render frame; the engine reads to decide where the cursor must
454/// land for visibility (cf. `scroll_off`).
455#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
456pub struct Viewport {
457    pub top_line: u32,
458    pub height: u32,
459    pub scroll_off: u32,
460}
461
462/// Opaque buffer identifier owned by the host. Engine echoes it back
463/// in [`Host::Intent`] variants for buffer-list operations
464/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
465#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
466pub struct BufferId(pub u64);
467
468/// Modifier bits accompanying every keystroke.
469#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
470pub struct Modifiers {
471    pub ctrl: bool,
472    pub shift: bool,
473    pub alt: bool,
474    pub super_: bool,
475}
476
477/// Special key codes — anything that isn't a printable character.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
479#[non_exhaustive]
480pub enum SpecialKey {
481    Esc,
482    Enter,
483    Backspace,
484    Tab,
485    BackTab,
486    Up,
487    Down,
488    Left,
489    Right,
490    Home,
491    End,
492    PageUp,
493    PageDown,
494    Insert,
495    Delete,
496    F(u8),
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
500pub enum MouseKind {
501    Press,
502    Release,
503    Drag,
504    ScrollUp,
505    ScrollDown,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
509pub struct MouseEvent {
510    pub kind: MouseKind,
511    pub pos: Pos,
512    pub mods: Modifiers,
513}
514
515/// Single input event handed to the engine.
516///
517/// `Paste` content bypasses insert-mode mappings, abbreviations, and
518/// autoindent; the engine inserts the bracketed-paste payload as-is.
519#[derive(Debug, Clone, PartialEq, Eq)]
520#[non_exhaustive]
521pub enum Input {
522    Char(char, Modifiers),
523    Key(SpecialKey, Modifiers),
524    Mouse(MouseEvent),
525    Paste(String),
526    FocusGained,
527    FocusLost,
528    Resize(u16, u16),
529}
530
531/// Host adapter consumed by the engine. Lives behind the planned
532/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
533/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
534/// align against.
535///
536/// Methods with default impls return safe no-ops so hosts that don't
537/// need a feature (cancellation, wrap-aware motion, syntax highlights)
538/// can ignore them.
539pub trait Host: Send {
540    /// Custom intent type. Hosts that don't fan out actions back to
541    /// themselves can use the unit type via the default impl approach
542    /// (set associated type explicitly).
543    type Intent;
544
545    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
546
547    /// Fire-and-forget clipboard write. Engine never blocks; the host
548    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
549    /// `pbcopy`, …).
550    fn write_clipboard(&mut self, text: String);
551
552    /// Returns the last-known cached clipboard value. May be stale —
553    /// matches the OSC52/wl-paste model neovim and helix both ship.
554    fn read_clipboard(&mut self) -> Option<String>;
555
556    // ── Time + cancellation ──
557
558    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
559    /// reads this; engine never reads `Instant::now()` directly so
560    /// macro replay stays deterministic.
561    fn now(&self) -> core::time::Duration;
562
563    /// Cooperative cancellation. Engine polls during long search /
564    /// regex / multi-cursor edit loops. Default returns `false`.
565    fn should_cancel(&self) -> bool {
566        false
567    }
568
569    // ── Search prompt ──
570
571    /// Synchronously prompt the user for a search pattern. Returning
572    /// `None` aborts the search.
573    fn prompt_search(&mut self) -> Option<String>;
574
575    // ── Wrap-aware motion (default: wrap is identity) ──
576
577    /// Map a logical position to its display line for `gj`/`gk`. Hosts
578    /// without wrapping may use the default identity impl.
579    fn display_line_for(&self, pos: Pos) -> u32 {
580        pos.line
581    }
582
583    /// Inverse of [`display_line_for`]. Default identity.
584    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
585        Pos { line, col }
586    }
587
588    // ── Syntax highlights (default: none) ──
589
590    /// Host-supplied syntax highlights for `range`. Empty by default;
591    /// hosts wire tree-sitter or LSP semantic tokens here.
592    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
593        let _ = range;
594        Vec::new()
595    }
596
597    // ── Cursor shape ──
598
599    /// Engine emits this on every mode transition. Hosts repaint the
600    /// cursor in the requested shape.
601    fn emit_cursor_shape(&mut self, shape: CursorShape);
602
603    // ── Custom intent fan-out ──
604
605    /// Host-defined event the engine raises (LSP request, fold op,
606    /// buffer switch, …).
607    fn emit_intent(&mut self, intent: Self::Intent);
608}
609
610/// Engine render frame consumed by the host once per redraw.
611///
612/// Borrow-style — the engine builds it on demand from its internal
613/// state without allocating clones of large fields. Hosts diff across
614/// frames to decide what to repaint.
615///
616/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
617/// a snapshot of the current line count (to size the gutter). The
618/// SPEC-target fields (`selections`, `highlights`, `command_line`,
619/// `search_prompt`, `status_line`) land once trait extraction wires
620/// the FSM through `SelectionSet` and the highlight pipeline.
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
622pub struct RenderFrame {
623    pub mode: SnapshotMode,
624    pub cursor_row: u32,
625    pub cursor_col: u32,
626    pub cursor_shape: CursorShape,
627    pub viewport_top: u32,
628    pub line_count: u32,
629}
630
631/// Coarse editor snapshot suitable for serde round-tripping.
632///
633/// Today's shape is intentionally minimal — it carries only the bits
634/// the runtime [`crate::Editor`] knows how to round-trip without the
635/// trait extraction (mode, cursor, lines, viewport top, settings).
636/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
637/// grows to cover full SPEC state: registers, marks, jump list, change
638/// list, undo tree, full options.
639///
640/// Hosts that persist editor state between sessions should:
641///
642/// - Treat the snapshot as opaque. Don't manually mutate fields.
643/// - Always check `version` after deserialization; reject on
644///   mismatch rather than attempt migration. The 0.0.x churn drops
645///   compatibility freely.
646#[derive(Debug, Clone)]
647#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
648pub struct EditorSnapshot {
649    /// Format version. Bumped on every structural change. Hosts use
650    /// this to detect mismatched persisted state.
651    pub version: u32,
652    /// Mode at snapshot time (status-line granularity).
653    pub mode: SnapshotMode,
654    /// Cursor `(row, col)` in byte indexing.
655    pub cursor: (u32, u32),
656    /// Buffer lines. Trailing `\n` not included.
657    pub lines: Vec<String>,
658    /// Viewport top line at snapshot time.
659    pub viewport_top: u32,
660    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
661    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
662    /// doesn't derive them today.
663    pub registers: crate::Registers,
664    /// Uppercase / "file" marks (`'A`–`'Z`). Survive `set_content`
665    /// calls so they round-trip across tab swaps in the host.
666    /// Lowercase marks are buffer-local and live on the `VimState`.
667    pub file_marks: std::collections::HashMap<char, (u32, u32)>,
668}
669
670/// Status-line mode summary. Bridges to the legacy
671/// [`crate::VimMode`] without leaking the full FSM type into the
672/// snapshot wire format.
673#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
674#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
675pub enum SnapshotMode {
676    #[default]
677    Normal,
678    Insert,
679    Visual,
680    VisualLine,
681    VisualBlock,
682}
683
684impl EditorSnapshot {
685    /// Current snapshot format version.
686    ///
687    /// Bumped to 2 in v0.0.8: registers added.
688    /// Bumped to 3 in v0.0.9: file_marks added.
689    pub const VERSION: u32 = 3;
690}
691
692/// Errors surfaced from the engine to the host. Intentionally narrow —
693/// callsites that fail in user-facing ways return `Result<_,
694/// EngineError>`; internal invariant breaks use `debug_assert!`.
695#[derive(Debug, thiserror::Error)]
696pub enum EngineError {
697    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
698    /// regex error in the status line.
699    #[error("regex compile error: {0}")]
700    Regex(#[from] regex::Error),
701
702    /// `:[range]` parse failed.
703    #[error("invalid range: {0}")]
704    InvalidRange(String),
705
706    /// Ex command parse failed (unknown command, malformed args).
707    #[error("ex parse: {0}")]
708    Ex(String),
709
710    /// Edit attempted on a read-only buffer.
711    #[error("buffer is read-only")]
712    ReadOnly,
713
714    /// Position passed by the caller pointed outside the buffer.
715    #[error("position out of bounds: {0:?}")]
716    OutOfBounds(Pos),
717
718    /// Snapshot version mismatch. Host should treat as "abandon
719    /// snapshot" rather than attempt migration.
720    #[error("snapshot version mismatch: file={0}, expected={1}")]
721    SnapshotVersion(u32, u32),
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn caret_is_empty() {
730        let sel = Selection::caret(Pos::new(2, 4));
731        assert!(sel.is_empty());
732        assert_eq!(sel.anchor, sel.head);
733    }
734
735    #[test]
736    fn selection_set_default_has_one_caret() {
737        let set = SelectionSet::default();
738        assert_eq!(set.items.len(), 1);
739        assert_eq!(set.primary, 0);
740        assert_eq!(set.primary().anchor, Pos::ORIGIN);
741    }
742
743    #[test]
744    fn edit_constructors() {
745        let p = Pos::new(0, 5);
746        assert_eq!(Edit::insert(p, "x").range, p..p);
747        assert!(Edit::insert(p, "x").replacement == "x");
748        assert!(Edit::delete(p..p).replacement.is_empty());
749    }
750
751    #[test]
752    fn attrs_flags() {
753        let a = Attrs::BOLD | Attrs::UNDERLINE;
754        assert!(a.contains(Attrs::BOLD));
755        assert!(!a.contains(Attrs::ITALIC));
756    }
757
758    #[test]
759    fn options_set_get_roundtrip() {
760        let mut o = Options::default();
761        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
762        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
763        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
764        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
765        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
766            .unwrap();
767        match o.get_by_name("iskeyword") {
768            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
769            other => panic!("expected String, got {other:?}"),
770        }
771    }
772
773    #[test]
774    fn options_unknown_name_errors_on_set() {
775        let mut o = Options::default();
776        assert!(matches!(
777            o.set_by_name("frobnicate", OptionValue::Int(1)),
778            Err(EngineError::Ex(_))
779        ));
780        assert!(o.get_by_name("frobnicate").is_none());
781    }
782
783    #[test]
784    fn options_type_mismatch_errors() {
785        let mut o = Options::default();
786        assert!(matches!(
787            o.set_by_name("tabstop", OptionValue::String("nope".into())),
788            Err(EngineError::Ex(_))
789        ));
790        assert!(matches!(
791            o.set_by_name("iskeyword", OptionValue::Int(7)),
792            Err(EngineError::Ex(_))
793        ));
794    }
795
796    #[test]
797    fn options_int_to_bool_coercion() {
798        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
799        // Common vim spelling.
800        let mut o = Options::default();
801        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
802        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
803        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
804        assert!(matches!(
805            o.get_by_name("ic"),
806            Some(OptionValue::Bool(false))
807        ));
808    }
809
810    #[test]
811    fn options_wrap_linebreak_roundtrip() {
812        let mut o = Options::default();
813        assert_eq!(o.wrap, WrapMode::None);
814        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
815        assert_eq!(o.wrap, WrapMode::Char);
816        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
817        assert_eq!(o.wrap, WrapMode::Word);
818        assert!(matches!(
819            o.get_by_name("wrap"),
820            Some(OptionValue::Bool(true))
821        ));
822        assert!(matches!(
823            o.get_by_name("lbr"),
824            Some(OptionValue::Bool(true))
825        ));
826        o.set_by_name("linebreak", OptionValue::Bool(false))
827            .unwrap();
828        assert_eq!(o.wrap, WrapMode::Char);
829        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
830        assert_eq!(o.wrap, WrapMode::None);
831    }
832
833    #[test]
834    fn options_default_matches_vim() {
835        let o = Options::default();
836        assert_eq!(o.tabstop, 8);
837        assert!(!o.expandtab);
838        assert!(o.hlsearch);
839        assert!(o.wrapscan);
840        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
841    }
842
843    #[test]
844    fn editor_snapshot_version_const() {
845        assert_eq!(EditorSnapshot::VERSION, 3);
846    }
847
848    #[test]
849    fn editor_snapshot_default_shape() {
850        let s = EditorSnapshot {
851            version: EditorSnapshot::VERSION,
852            mode: SnapshotMode::Normal,
853            cursor: (0, 0),
854            lines: vec!["hello".to_string()],
855            viewport_top: 0,
856            registers: crate::Registers::default(),
857            file_marks: Default::default(),
858        };
859        assert_eq!(s.cursor, (0, 0));
860        assert_eq!(s.lines.len(), 1);
861    }
862
863    #[cfg(feature = "serde")]
864    #[test]
865    fn editor_snapshot_roundtrip() {
866        let mut file_marks = std::collections::HashMap::new();
867        file_marks.insert('A', (5u32, 2u32));
868        let s = EditorSnapshot {
869            version: EditorSnapshot::VERSION,
870            mode: SnapshotMode::Insert,
871            cursor: (3, 7),
872            lines: vec!["alpha".into(), "beta".into()],
873            viewport_top: 2,
874            registers: crate::Registers::default(),
875            file_marks,
876        };
877        let json = serde_json::to_string(&s).unwrap();
878        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
879        assert_eq!(s.cursor, back.cursor);
880        assert_eq!(s.lines, back.lines);
881        assert_eq!(s.viewport_top, back.viewport_top);
882    }
883
884    #[test]
885    fn engine_error_display() {
886        let e = EngineError::ReadOnly;
887        assert_eq!(e.to_string(), "buffer is read-only");
888        let e = EngineError::OutOfBounds(Pos::new(3, 7));
889        assert!(e.to_string().contains("out of bounds"));
890    }
891}