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, and
553/// a snapshot of the current line count (to size the gutter). The
554/// SPEC-target fields (`selections`, `highlights`, `command_line`,
555/// `search_prompt`, `status_line`) land once trait extraction wires
556/// the FSM through `SelectionSet` and the highlight pipeline.
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
558pub struct RenderFrame {
559    pub mode: SnapshotMode,
560    pub cursor_row: u32,
561    pub cursor_col: u32,
562    pub cursor_shape: CursorShape,
563    pub viewport_top: u32,
564    pub line_count: u32,
565}
566
567/// Coarse editor snapshot suitable for serde round-tripping.
568///
569/// Today's shape is intentionally minimal — it carries only the bits
570/// the runtime [`crate::Editor`] knows how to round-trip without the
571/// trait extraction (mode, cursor, lines, viewport top, settings).
572/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
573/// grows to cover full SPEC state: registers, marks, jump list, change
574/// list, undo tree, full options.
575///
576/// Hosts that persist editor state between sessions should:
577///
578/// - Treat the snapshot as opaque. Don't manually mutate fields.
579/// - Always check `version` after deserialization; reject on
580///   mismatch rather than attempt migration. The 0.0.x churn drops
581///   compatibility freely.
582#[derive(Debug, Clone)]
583#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
584pub struct EditorSnapshot {
585    /// Format version. Bumped on every structural change. Hosts use
586    /// this to detect mismatched persisted state.
587    pub version: u32,
588    /// Mode at snapshot time (status-line granularity).
589    pub mode: SnapshotMode,
590    /// Cursor `(row, col)` in byte indexing.
591    pub cursor: (u32, u32),
592    /// Buffer lines. Trailing `\n` not included.
593    pub lines: Vec<String>,
594    /// Viewport top line at snapshot time.
595    pub viewport_top: u32,
596    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
597    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
598    /// doesn't derive them today.
599    pub registers: crate::Registers,
600    /// Uppercase / "file" marks (`'A`–`'Z`). Survive `set_content`
601    /// calls so they round-trip across tab swaps in the host.
602    /// Lowercase marks are buffer-local and live on the `VimState`.
603    pub file_marks: std::collections::HashMap<char, (u32, u32)>,
604}
605
606/// Status-line mode summary. Bridges to the legacy
607/// [`crate::VimMode`] without leaking the full FSM type into the
608/// snapshot wire format.
609#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
610#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
611pub enum SnapshotMode {
612    #[default]
613    Normal,
614    Insert,
615    Visual,
616    VisualLine,
617    VisualBlock,
618}
619
620impl EditorSnapshot {
621    /// Current snapshot format version.
622    ///
623    /// Bumped to 2 in v0.0.8: registers added.
624    /// Bumped to 3 in v0.0.9: file_marks added.
625    pub const VERSION: u32 = 3;
626}
627
628/// Errors surfaced from the engine to the host. Intentionally narrow —
629/// callsites that fail in user-facing ways return `Result<_,
630/// EngineError>`; internal invariant breaks use `debug_assert!`.
631#[derive(Debug, thiserror::Error)]
632pub enum EngineError {
633    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
634    /// regex error in the status line.
635    #[error("regex compile error: {0}")]
636    Regex(#[from] regex::Error),
637
638    /// `:[range]` parse failed.
639    #[error("invalid range: {0}")]
640    InvalidRange(String),
641
642    /// Ex command parse failed (unknown command, malformed args).
643    #[error("ex parse: {0}")]
644    Ex(String),
645
646    /// Edit attempted on a read-only buffer.
647    #[error("buffer is read-only")]
648    ReadOnly,
649
650    /// Position passed by the caller pointed outside the buffer.
651    #[error("position out of bounds: {0:?}")]
652    OutOfBounds(Pos),
653
654    /// Snapshot version mismatch. Host should treat as "abandon
655    /// snapshot" rather than attempt migration.
656    #[error("snapshot version mismatch: file={0}, expected={1}")]
657    SnapshotVersion(u32, u32),
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn caret_is_empty() {
666        let sel = Selection::caret(Pos::new(2, 4));
667        assert!(sel.is_empty());
668        assert_eq!(sel.anchor, sel.head);
669    }
670
671    #[test]
672    fn selection_set_default_has_one_caret() {
673        let set = SelectionSet::default();
674        assert_eq!(set.items.len(), 1);
675        assert_eq!(set.primary, 0);
676        assert_eq!(set.primary().anchor, Pos::ORIGIN);
677    }
678
679    #[test]
680    fn edit_constructors() {
681        let p = Pos::new(0, 5);
682        assert_eq!(Edit::insert(p, "x").range, p..p);
683        assert!(Edit::insert(p, "x").replacement == "x");
684        assert!(Edit::delete(p..p).replacement.is_empty());
685    }
686
687    #[test]
688    fn attrs_flags() {
689        let a = Attrs::BOLD | Attrs::UNDERLINE;
690        assert!(a.contains(Attrs::BOLD));
691        assert!(!a.contains(Attrs::ITALIC));
692    }
693
694    #[test]
695    fn options_set_get_roundtrip() {
696        let mut o = Options::default();
697        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
698        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
699        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
700        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
701        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
702            .unwrap();
703        match o.get_by_name("iskeyword") {
704            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
705            other => panic!("expected String, got {other:?}"),
706        }
707    }
708
709    #[test]
710    fn options_unknown_name_errors_on_set() {
711        let mut o = Options::default();
712        assert!(matches!(
713            o.set_by_name("frobnicate", OptionValue::Int(1)),
714            Err(EngineError::Ex(_))
715        ));
716        assert!(o.get_by_name("frobnicate").is_none());
717    }
718
719    #[test]
720    fn options_type_mismatch_errors() {
721        let mut o = Options::default();
722        assert!(matches!(
723            o.set_by_name("tabstop", OptionValue::String("nope".into())),
724            Err(EngineError::Ex(_))
725        ));
726        assert!(matches!(
727            o.set_by_name("iskeyword", OptionValue::Int(7)),
728            Err(EngineError::Ex(_))
729        ));
730    }
731
732    #[test]
733    fn options_int_to_bool_coercion() {
734        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
735        // Common vim spelling.
736        let mut o = Options::default();
737        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
738        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
739        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
740        assert!(matches!(
741            o.get_by_name("ic"),
742            Some(OptionValue::Bool(false))
743        ));
744    }
745
746    #[test]
747    fn options_default_matches_vim() {
748        let o = Options::default();
749        assert_eq!(o.tabstop, 8);
750        assert!(!o.expandtab);
751        assert!(o.hlsearch);
752        assert!(o.wrapscan);
753        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
754    }
755
756    #[test]
757    fn editor_snapshot_version_const() {
758        assert_eq!(EditorSnapshot::VERSION, 3);
759    }
760
761    #[test]
762    fn editor_snapshot_default_shape() {
763        let s = EditorSnapshot {
764            version: EditorSnapshot::VERSION,
765            mode: SnapshotMode::Normal,
766            cursor: (0, 0),
767            lines: vec!["hello".to_string()],
768            viewport_top: 0,
769            registers: crate::Registers::default(),
770            file_marks: Default::default(),
771        };
772        assert_eq!(s.cursor, (0, 0));
773        assert_eq!(s.lines.len(), 1);
774    }
775
776    #[cfg(feature = "serde")]
777    #[test]
778    fn editor_snapshot_roundtrip() {
779        let mut file_marks = std::collections::HashMap::new();
780        file_marks.insert('A', (5u32, 2u32));
781        let s = EditorSnapshot {
782            version: EditorSnapshot::VERSION,
783            mode: SnapshotMode::Insert,
784            cursor: (3, 7),
785            lines: vec!["alpha".into(), "beta".into()],
786            viewport_top: 2,
787            registers: crate::Registers::default(),
788            file_marks,
789        };
790        let json = serde_json::to_string(&s).unwrap();
791        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
792        assert_eq!(s.cursor, back.cursor);
793        assert_eq!(s.lines, back.lines);
794        assert_eq!(s.viewport_top, back.viewport_top);
795    }
796
797    #[test]
798    fn engine_error_display() {
799        let e = EngineError::ReadOnly;
800        assert_eq!(e.to_string(), "buffer is read-only");
801        let e = EngineError::OutOfBounds(Pos::new(3, 7));
802        assert!(e.to_string().contains("out of bounds"));
803    }
804}