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}
253
254/// Soft-wrap mode for the renderer + scroll math + `gj` / `gk`.
255/// Engine-native equivalent of [`hjkl_buffer::Wrap`]; the engine
256/// converts at the boundary to the buffer's runtime wrap setting.
257#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
258#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
259pub enum WrapMode {
260    /// Long lines extend past the right edge; `top_col` clips the
261    /// left side. Matches vim's `:set nowrap`.
262    #[default]
263    None,
264    /// Break at the cell boundary regardless of word edges. Matches
265    /// `:set wrap`.
266    Char,
267    /// Break at the last whitespace inside the visible width when
268    /// possible; falls back to a char break for runs longer than the
269    /// width. Matches `:set linebreak`.
270    Word,
271}
272
273/// Typed value for [`Options::set_by_name`] / [`Options::get_by_name`].
274///
275/// `:set tabstop=4` parses as `OptionValue::Int(4)`;
276/// `:set noexpandtab` parses as `OptionValue::Bool(false)`;
277/// `:set iskeyword=...` as `OptionValue::String(...)`.
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum OptionValue {
280    Bool(bool),
281    Int(i64),
282    String(String),
283}
284
285impl Default for Options {
286    fn default() -> Self {
287        Options {
288            tabstop: 8,
289            shiftwidth: 8,
290            expandtab: false,
291            iskeyword: "@,48-57,_,192-255".to_string(),
292            ignorecase: false,
293            smartcase: false,
294            hlsearch: true,
295            incsearch: true,
296            wrapscan: true,
297            autoindent: true,
298            timeout_len: core::time::Duration::from_millis(1000),
299            undo_levels: 1000,
300            undo_break_on_motion: true,
301            readonly: false,
302            wrap: WrapMode::None,
303        }
304    }
305}
306
307impl Options {
308    /// Set an option by name. Vim-flavored option naming. Returns
309    /// [`EngineError::Ex`] for unknown names or type-mismatched values.
310    ///
311    /// Booleans accept `OptionValue::Bool(_)` directly or
312    /// `OptionValue::Int(0)`/`Int(non_zero)`. Integers accept only
313    /// `Int(_)`. Strings accept only `String(_)`.
314    pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
315        macro_rules! set_bool {
316            ($field:ident) => {{
317                self.$field = match val {
318                    OptionValue::Bool(b) => b,
319                    OptionValue::Int(n) => n != 0,
320                    other => {
321                        return Err(EngineError::Ex(format!(
322                            "option `{name}` expects bool, got {other:?}"
323                        )));
324                    }
325                };
326                Ok(())
327            }};
328        }
329        macro_rules! set_u32 {
330            ($field:ident) => {{
331                self.$field = match val {
332                    OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
333                    OptionValue::Int(n) => {
334                        return Err(EngineError::Ex(format!(
335                            "option `{name}` out of u32 range: {n}"
336                        )));
337                    }
338                    other => {
339                        return Err(EngineError::Ex(format!(
340                            "option `{name}` expects int, got {other:?}"
341                        )));
342                    }
343                };
344                Ok(())
345            }};
346        }
347        macro_rules! set_string {
348            ($field:ident) => {{
349                self.$field = match val {
350                    OptionValue::String(s) => s,
351                    other => {
352                        return Err(EngineError::Ex(format!(
353                            "option `{name}` expects string, got {other:?}"
354                        )));
355                    }
356                };
357                Ok(())
358            }};
359        }
360        match name {
361            "tabstop" | "ts" => set_u32!(tabstop),
362            "shiftwidth" | "sw" => set_u32!(shiftwidth),
363            "expandtab" | "et" => set_bool!(expandtab),
364            "iskeyword" | "isk" => set_string!(iskeyword),
365            "ignorecase" | "ic" => set_bool!(ignorecase),
366            "smartcase" | "scs" => set_bool!(smartcase),
367            "hlsearch" | "hls" => set_bool!(hlsearch),
368            "incsearch" | "is" => set_bool!(incsearch),
369            "wrapscan" | "ws" => set_bool!(wrapscan),
370            "autoindent" | "ai" => set_bool!(autoindent),
371            "timeoutlen" | "tm" => {
372                self.timeout_len = match val {
373                    OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
374                    other => {
375                        return Err(EngineError::Ex(format!(
376                            "option `{name}` expects non-negative int (millis), got {other:?}"
377                        )));
378                    }
379                };
380                Ok(())
381            }
382            "undolevels" | "ul" => set_u32!(undo_levels),
383            "undobreak" => set_bool!(undo_break_on_motion),
384            "readonly" | "ro" => set_bool!(readonly),
385            "wrap" => {
386                let on = match val {
387                    OptionValue::Bool(b) => b,
388                    OptionValue::Int(n) => n != 0,
389                    other => {
390                        return Err(EngineError::Ex(format!(
391                            "option `{name}` expects bool, got {other:?}"
392                        )));
393                    }
394                };
395                self.wrap = match (on, self.wrap) {
396                    (false, _) => WrapMode::None,
397                    (true, WrapMode::Word) => WrapMode::Word,
398                    (true, _) => WrapMode::Char,
399                };
400                Ok(())
401            }
402            "linebreak" | "lbr" => {
403                let on = match val {
404                    OptionValue::Bool(b) => b,
405                    OptionValue::Int(n) => n != 0,
406                    other => {
407                        return Err(EngineError::Ex(format!(
408                            "option `{name}` expects bool, got {other:?}"
409                        )));
410                    }
411                };
412                self.wrap = match (on, self.wrap) {
413                    (true, _) => WrapMode::Word,
414                    (false, WrapMode::Word) => WrapMode::Char,
415                    (false, other) => other,
416                };
417                Ok(())
418            }
419            other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
420        }
421    }
422
423    /// Read an option by name. `None` for unknown names.
424    pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
425        Some(match name {
426            "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
427            "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
428            "expandtab" | "et" => OptionValue::Bool(self.expandtab),
429            "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
430            "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
431            "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
432            "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
433            "incsearch" | "is" => OptionValue::Bool(self.incsearch),
434            "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
435            "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
436            "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
437            "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
438            "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
439            "readonly" | "ro" => OptionValue::Bool(self.readonly),
440            "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
441            "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
442            _ => return None,
443        })
444    }
445}
446
447/// Visible region of a buffer. The host writes `top_line` and `height`
448/// per render frame; the engine reads to decide where the cursor must
449/// land for visibility (cf. `scroll_off`).
450#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
451pub struct Viewport {
452    pub top_line: u32,
453    pub height: u32,
454    pub scroll_off: u32,
455}
456
457/// Opaque buffer identifier owned by the host. Engine echoes it back
458/// in [`Host::Intent`] variants for buffer-list operations
459/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
460#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
461pub struct BufferId(pub u64);
462
463/// Modifier bits accompanying every keystroke.
464#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
465pub struct Modifiers {
466    pub ctrl: bool,
467    pub shift: bool,
468    pub alt: bool,
469    pub super_: bool,
470}
471
472/// Special key codes — anything that isn't a printable character.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
474#[non_exhaustive]
475pub enum SpecialKey {
476    Esc,
477    Enter,
478    Backspace,
479    Tab,
480    BackTab,
481    Up,
482    Down,
483    Left,
484    Right,
485    Home,
486    End,
487    PageUp,
488    PageDown,
489    Insert,
490    Delete,
491    F(u8),
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
495pub enum MouseKind {
496    Press,
497    Release,
498    Drag,
499    ScrollUp,
500    ScrollDown,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
504pub struct MouseEvent {
505    pub kind: MouseKind,
506    pub pos: Pos,
507    pub mods: Modifiers,
508}
509
510/// Single input event handed to the engine.
511///
512/// `Paste` content bypasses insert-mode mappings, abbreviations, and
513/// autoindent; the engine inserts the bracketed-paste payload as-is.
514#[derive(Debug, Clone, PartialEq, Eq)]
515#[non_exhaustive]
516pub enum Input {
517    Char(char, Modifiers),
518    Key(SpecialKey, Modifiers),
519    Mouse(MouseEvent),
520    Paste(String),
521    FocusGained,
522    FocusLost,
523    Resize(u16, u16),
524}
525
526/// Host adapter consumed by the engine. Lives behind the planned
527/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
528/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
529/// align against.
530///
531/// Methods with default impls return safe no-ops so hosts that don't
532/// need a feature (cancellation, wrap-aware motion, syntax highlights)
533/// can ignore them.
534pub trait Host: Send {
535    /// Custom intent type. Hosts that don't fan out actions back to
536    /// themselves can use the unit type via the default impl approach
537    /// (set associated type explicitly).
538    type Intent;
539
540    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
541
542    /// Fire-and-forget clipboard write. Engine never blocks; the host
543    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
544    /// `pbcopy`, …).
545    fn write_clipboard(&mut self, text: String);
546
547    /// Returns the last-known cached clipboard value. May be stale —
548    /// matches the OSC52/wl-paste model neovim and helix both ship.
549    fn read_clipboard(&mut self) -> Option<String>;
550
551    // ── Time + cancellation ──
552
553    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
554    /// reads this; engine never reads `Instant::now()` directly so
555    /// macro replay stays deterministic.
556    fn now(&self) -> core::time::Duration;
557
558    /// Cooperative cancellation. Engine polls during long search /
559    /// regex / multi-cursor edit loops. Default returns `false`.
560    fn should_cancel(&self) -> bool {
561        false
562    }
563
564    // ── Search prompt ──
565
566    /// Synchronously prompt the user for a search pattern. Returning
567    /// `None` aborts the search.
568    fn prompt_search(&mut self) -> Option<String>;
569
570    // ── Wrap-aware motion (default: wrap is identity) ──
571
572    /// Map a logical position to its display line for `gj`/`gk`. Hosts
573    /// without wrapping may use the default identity impl.
574    fn display_line_for(&self, pos: Pos) -> u32 {
575        pos.line
576    }
577
578    /// Inverse of [`display_line_for`]. Default identity.
579    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
580        Pos { line, col }
581    }
582
583    // ── Syntax highlights (default: none) ──
584
585    /// Host-supplied syntax highlights for `range`. Empty by default;
586    /// hosts wire tree-sitter or LSP semantic tokens here.
587    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
588        let _ = range;
589        Vec::new()
590    }
591
592    // ── Cursor shape ──
593
594    /// Engine emits this on every mode transition. Hosts repaint the
595    /// cursor in the requested shape.
596    fn emit_cursor_shape(&mut self, shape: CursorShape);
597
598    // ── Custom intent fan-out ──
599
600    /// Host-defined event the engine raises (LSP request, fold op,
601    /// buffer switch, …).
602    fn emit_intent(&mut self, intent: Self::Intent);
603}
604
605/// Engine render frame consumed by the host once per redraw.
606///
607/// Borrow-style — the engine builds it on demand from its internal
608/// state without allocating clones of large fields. Hosts diff across
609/// frames to decide what to repaint.
610///
611/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
612/// a snapshot of the current line count (to size the gutter). The
613/// SPEC-target fields (`selections`, `highlights`, `command_line`,
614/// `search_prompt`, `status_line`) land once trait extraction wires
615/// the FSM through `SelectionSet` and the highlight pipeline.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
617pub struct RenderFrame {
618    pub mode: SnapshotMode,
619    pub cursor_row: u32,
620    pub cursor_col: u32,
621    pub cursor_shape: CursorShape,
622    pub viewport_top: u32,
623    pub line_count: u32,
624}
625
626/// Coarse editor snapshot suitable for serde round-tripping.
627///
628/// Today's shape is intentionally minimal — it carries only the bits
629/// the runtime [`crate::Editor`] knows how to round-trip without the
630/// trait extraction (mode, cursor, lines, viewport top, settings).
631/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
632/// grows to cover full SPEC state: registers, marks, jump list, change
633/// list, undo tree, full options.
634///
635/// Hosts that persist editor state between sessions should:
636///
637/// - Treat the snapshot as opaque. Don't manually mutate fields.
638/// - Always check `version` after deserialization; reject on
639///   mismatch rather than attempt migration. The 0.0.x churn drops
640///   compatibility freely.
641#[derive(Debug, Clone)]
642#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
643pub struct EditorSnapshot {
644    /// Format version. Bumped on every structural change. Hosts use
645    /// this to detect mismatched persisted state.
646    pub version: u32,
647    /// Mode at snapshot time (status-line granularity).
648    pub mode: SnapshotMode,
649    /// Cursor `(row, col)` in byte indexing.
650    pub cursor: (u32, u32),
651    /// Buffer lines. Trailing `\n` not included.
652    pub lines: Vec<String>,
653    /// Viewport top line at snapshot time.
654    pub viewport_top: u32,
655    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
656    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
657    /// doesn't derive them today.
658    pub registers: crate::Registers,
659    /// Uppercase / "file" marks (`'A`–`'Z`). Survive `set_content`
660    /// calls so they round-trip across tab swaps in the host.
661    /// Lowercase marks are buffer-local and live on the `VimState`.
662    pub file_marks: std::collections::HashMap<char, (u32, u32)>,
663}
664
665/// Status-line mode summary. Bridges to the legacy
666/// [`crate::VimMode`] without leaking the full FSM type into the
667/// snapshot wire format.
668#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
669#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
670pub enum SnapshotMode {
671    #[default]
672    Normal,
673    Insert,
674    Visual,
675    VisualLine,
676    VisualBlock,
677}
678
679impl EditorSnapshot {
680    /// Current snapshot format version.
681    ///
682    /// Bumped to 2 in v0.0.8: registers added.
683    /// Bumped to 3 in v0.0.9: file_marks added.
684    pub const VERSION: u32 = 3;
685}
686
687/// Errors surfaced from the engine to the host. Intentionally narrow —
688/// callsites that fail in user-facing ways return `Result<_,
689/// EngineError>`; internal invariant breaks use `debug_assert!`.
690#[derive(Debug, thiserror::Error)]
691pub enum EngineError {
692    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
693    /// regex error in the status line.
694    #[error("regex compile error: {0}")]
695    Regex(#[from] regex::Error),
696
697    /// `:[range]` parse failed.
698    #[error("invalid range: {0}")]
699    InvalidRange(String),
700
701    /// Ex command parse failed (unknown command, malformed args).
702    #[error("ex parse: {0}")]
703    Ex(String),
704
705    /// Edit attempted on a read-only buffer.
706    #[error("buffer is read-only")]
707    ReadOnly,
708
709    /// Position passed by the caller pointed outside the buffer.
710    #[error("position out of bounds: {0:?}")]
711    OutOfBounds(Pos),
712
713    /// Snapshot version mismatch. Host should treat as "abandon
714    /// snapshot" rather than attempt migration.
715    #[error("snapshot version mismatch: file={0}, expected={1}")]
716    SnapshotVersion(u32, u32),
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn caret_is_empty() {
725        let sel = Selection::caret(Pos::new(2, 4));
726        assert!(sel.is_empty());
727        assert_eq!(sel.anchor, sel.head);
728    }
729
730    #[test]
731    fn selection_set_default_has_one_caret() {
732        let set = SelectionSet::default();
733        assert_eq!(set.items.len(), 1);
734        assert_eq!(set.primary, 0);
735        assert_eq!(set.primary().anchor, Pos::ORIGIN);
736    }
737
738    #[test]
739    fn edit_constructors() {
740        let p = Pos::new(0, 5);
741        assert_eq!(Edit::insert(p, "x").range, p..p);
742        assert!(Edit::insert(p, "x").replacement == "x");
743        assert!(Edit::delete(p..p).replacement.is_empty());
744    }
745
746    #[test]
747    fn attrs_flags() {
748        let a = Attrs::BOLD | Attrs::UNDERLINE;
749        assert!(a.contains(Attrs::BOLD));
750        assert!(!a.contains(Attrs::ITALIC));
751    }
752
753    #[test]
754    fn options_set_get_roundtrip() {
755        let mut o = Options::default();
756        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
757        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
758        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
759        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
760        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
761            .unwrap();
762        match o.get_by_name("iskeyword") {
763            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
764            other => panic!("expected String, got {other:?}"),
765        }
766    }
767
768    #[test]
769    fn options_unknown_name_errors_on_set() {
770        let mut o = Options::default();
771        assert!(matches!(
772            o.set_by_name("frobnicate", OptionValue::Int(1)),
773            Err(EngineError::Ex(_))
774        ));
775        assert!(o.get_by_name("frobnicate").is_none());
776    }
777
778    #[test]
779    fn options_type_mismatch_errors() {
780        let mut o = Options::default();
781        assert!(matches!(
782            o.set_by_name("tabstop", OptionValue::String("nope".into())),
783            Err(EngineError::Ex(_))
784        ));
785        assert!(matches!(
786            o.set_by_name("iskeyword", OptionValue::Int(7)),
787            Err(EngineError::Ex(_))
788        ));
789    }
790
791    #[test]
792    fn options_int_to_bool_coercion() {
793        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
794        // Common vim spelling.
795        let mut o = Options::default();
796        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
797        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
798        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
799        assert!(matches!(
800            o.get_by_name("ic"),
801            Some(OptionValue::Bool(false))
802        ));
803    }
804
805    #[test]
806    fn options_wrap_linebreak_roundtrip() {
807        let mut o = Options::default();
808        assert_eq!(o.wrap, WrapMode::None);
809        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
810        assert_eq!(o.wrap, WrapMode::Char);
811        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
812        assert_eq!(o.wrap, WrapMode::Word);
813        assert!(matches!(
814            o.get_by_name("wrap"),
815            Some(OptionValue::Bool(true))
816        ));
817        assert!(matches!(
818            o.get_by_name("lbr"),
819            Some(OptionValue::Bool(true))
820        ));
821        o.set_by_name("linebreak", OptionValue::Bool(false))
822            .unwrap();
823        assert_eq!(o.wrap, WrapMode::Char);
824        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
825        assert_eq!(o.wrap, WrapMode::None);
826    }
827
828    #[test]
829    fn options_default_matches_vim() {
830        let o = Options::default();
831        assert_eq!(o.tabstop, 8);
832        assert!(!o.expandtab);
833        assert!(o.hlsearch);
834        assert!(o.wrapscan);
835        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
836    }
837
838    #[test]
839    fn editor_snapshot_version_const() {
840        assert_eq!(EditorSnapshot::VERSION, 3);
841    }
842
843    #[test]
844    fn editor_snapshot_default_shape() {
845        let s = EditorSnapshot {
846            version: EditorSnapshot::VERSION,
847            mode: SnapshotMode::Normal,
848            cursor: (0, 0),
849            lines: vec!["hello".to_string()],
850            viewport_top: 0,
851            registers: crate::Registers::default(),
852            file_marks: Default::default(),
853        };
854        assert_eq!(s.cursor, (0, 0));
855        assert_eq!(s.lines.len(), 1);
856    }
857
858    #[cfg(feature = "serde")]
859    #[test]
860    fn editor_snapshot_roundtrip() {
861        let mut file_marks = std::collections::HashMap::new();
862        file_marks.insert('A', (5u32, 2u32));
863        let s = EditorSnapshot {
864            version: EditorSnapshot::VERSION,
865            mode: SnapshotMode::Insert,
866            cursor: (3, 7),
867            lines: vec!["alpha".into(), "beta".into()],
868            viewport_top: 2,
869            registers: crate::Registers::default(),
870            file_marks,
871        };
872        let json = serde_json::to_string(&s).unwrap();
873        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
874        assert_eq!(s.cursor, back.cursor);
875        assert_eq!(s.lines, back.lines);
876        assert_eq!(s.viewport_top, back.viewport_top);
877    }
878
879    #[test]
880    fn engine_error_display() {
881        let e = EngineError::ReadOnly;
882        assert_eq!(e.to_string(), "buffer is read-only");
883        let e = EngineError::OutOfBounds(Pos::new(3, 7));
884        assert!(e.to_string().contains("out of bounds"));
885    }
886}