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