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