Skip to main content

hjkl_engine/
types.rs

1//! Core types for the engine trait surface.
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`] remain
7//! authoritative.
8
9// `Pos`, `Edit` (as `EngineEdit`), `ContentEdit`, and `FoldOp` now live in
10// `hjkl-buffer` so `Buffer` can own per-buffer engine state without a
11// circular dependency. Re-exported here so all existing call sites compile
12// without change.
13pub use hjkl_buffer::ContentEdit;
14pub use hjkl_buffer::EngineEdit as Edit;
15pub use hjkl_buffer::FoldOp;
16pub use hjkl_buffer::Pos;
17
18use std::ops::Range;
19
20/// What kind of region a [`Selection`] covers.
21///
22/// - `Char`: classic vim `v` selection — closed range on the inline character
23///   axis.
24/// - `Line`: linewise (`V`) — anchor/head columns ignored, full lines covered
25///   between `min(anchor.line, head.line)` and `max(...)`.
26/// - `Block`: blockwise (`Ctrl-V`) — rectangle from `min(col)` to `max(col)`,
27///   each line a sub-range. Falls out of multi-cursor model: implementations
28///   may expand a `Block` selection into N sub-selections during edit
29///   dispatch.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
31pub enum SelectionKind {
32    #[default]
33    Char,
34    Line,
35    Block,
36}
37
38/// A single anchored selection. Empty (caret-only) when `anchor == head`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct Selection {
41    pub anchor: Pos,
42    pub head: Pos,
43    pub kind: SelectionKind,
44}
45
46impl Selection {
47    /// Caret at `pos` with no extent.
48    pub const fn caret(pos: Pos) -> Self {
49        Self {
50            anchor: pos,
51            head: pos,
52            kind: SelectionKind::Char,
53        }
54    }
55
56    /// Inclusive range `[anchor, head]` (or reversed) as a `Char` selection.
57    pub const fn char_range(anchor: Pos, head: Pos) -> Self {
58        Self {
59            anchor,
60            head,
61            kind: SelectionKind::Char,
62        }
63    }
64
65    /// True if `anchor == head`.
66    pub fn is_empty(&self) -> bool {
67        self.anchor == self.head
68    }
69}
70
71/// Ordered set of selections. Always non-empty in valid states; `primary`
72/// indexes the cursor visible to vim mode.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct SelectionSet {
75    pub items: Vec<Selection>,
76    pub primary: usize,
77}
78
79impl SelectionSet {
80    /// Single caret at `pos`.
81    pub fn caret(pos: Pos) -> Self {
82        Self {
83            items: vec![Selection::caret(pos)],
84            primary: 0,
85        }
86    }
87
88    /// Returns the primary selection, or the first if `primary` is out of
89    /// bounds.
90    pub fn primary(&self) -> &Selection {
91        self.items
92            .get(self.primary)
93            .or_else(|| self.items.first())
94            .expect("SelectionSet must contain at least one selection")
95    }
96}
97
98impl Default for SelectionSet {
99    fn default() -> Self {
100        Self::caret(Pos::ORIGIN)
101    }
102}
103
104/// Vim editor mode. Distinct from the legacy [`crate::VimMode`] — that one
105/// is the host-facing status-line summary; this is the engine's internal
106/// state machine.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub enum Mode {
109    #[default]
110    Normal,
111    Insert,
112    Visual,
113    Replace,
114    Command,
115    OperatorPending,
116}
117
118/// Cursor shape intent emitted on mode transitions. Hosts honor it via
119/// `Host::emit_cursor_shape` once the trait extraction lands.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
121pub enum CursorShape {
122    #[default]
123    Block,
124    Bar,
125    Underline,
126}
127
128/// Engine-native style. Replaces direct ratatui `Style` use in the public
129/// API once phase 5 trait extraction completes; until then both coexist.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub struct Style {
132    pub fg: Option<Color>,
133    pub bg: Option<Color>,
134    pub attrs: Attrs,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
138pub struct Color(pub u8, pub u8, pub u8);
139
140bitflags::bitflags! {
141    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
142    pub struct Attrs: u8 {
143        const BOLD       = 1 << 0;
144        const ITALIC     = 1 << 1;
145        const UNDERLINE  = 1 << 2;
146        const REVERSE    = 1 << 3;
147        const DIM        = 1 << 4;
148        const STRIKE     = 1 << 5;
149    }
150}
151
152/// Highlight kind emitted by the engine's render pass. The host's style
153/// resolver picks colors for `Selection`/`SearchMatch`/etc.; `Syntax(id)`
154/// carries an opaque host-supplied id whose styling lives in the host.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum HighlightKind {
157    Selection,
158    SearchMatch,
159    IncSearch,
160    MatchParen,
161    Syntax(u32),
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Highlight {
166    pub range: Range<Pos>,
167    pub kind: HighlightKind,
168}
169
170/// Editor settings surfaced via `:set`. Per SPEC. Consumed once trait
171/// extraction lands; today's legacy `Settings` (in [`crate::editor`])
172/// continues to drive runtime behaviour.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct Options {
175    /// Display width of `\t` for column math + render. Default 8.
176    pub tabstop: u32,
177    /// Spaces per shift step (`>>`, `<<`, `Ctrl-T`, `Ctrl-D`).
178    pub shiftwidth: u32,
179    /// Insert spaces (`true`) or literal `\t` (`false`) for the Tab key.
180    pub expandtab: bool,
181    /// Soft tab stop in spaces. When `> 0`, the Tab key (with `expandtab`)
182    /// inserts spaces to the next softtabstop boundary, and Backspace at
183    /// the end of a softtabstop-aligned space run deletes the whole run.
184    /// `0` disables softtabstop semantics. Matches vim's `:set softtabstop`.
185    pub softtabstop: u32,
186    /// Characters considered part of a "word" for `w`/`b`/`*`/`#`.
187    /// Default `"@,48-57,_,192-255"` (ASCII letters, digits, `_`, plus
188    /// extended Latin); host may override per language.
189    pub iskeyword: String,
190    /// Default `false`: search is case-sensitive.
191    pub ignorecase: bool,
192    /// When `true` and `ignorecase` is `true`, an uppercase letter in the
193    /// pattern flips back to case-sensitive for that search.
194    pub smartcase: bool,
195    /// Highlight all matches of the last search.
196    pub hlsearch: bool,
197    /// Incrementally highlight matches while typing the search pattern.
198    pub incsearch: bool,
199    /// Wrap searches around the buffer ends.
200    pub wrapscan: bool,
201    /// Copy previous line's leading whitespace on Enter in insert mode.
202    pub autoindent: bool,
203    /// When `true`, bump indent by one `shiftwidth` after a line ending in
204    /// `{` / `(` / `[`, and strip one indent unit when the user types the
205    /// matching `}` / `)` / `]` on an otherwise-whitespace-only line.
206    /// Supersedes autoindent's plain copy when on.  Future: a
207    /// tree-sitter `indents.scm` provider will replace the heuristic; see
208    /// `compute_enter_indent` in `vim.rs` for the plug-in point.
209    pub smartindent: bool,
210    /// Multi-key sequence timeout (e.g., `<C-w>v`). Vim's `timeoutlen`.
211    pub timeout_len: core::time::Duration,
212    /// Maximum undo-tree depth. Older entries pruned.
213    pub undo_levels: u32,
214    /// Break the current undo group on cursor motion in insert mode.
215    /// Matches vim default; turn off to merge multi-segment edits.
216    pub undo_break_on_motion: bool,
217    /// Reject every edit. `:set ro` sets this; `:w!` clears it.
218    pub readonly: bool,
219    /// When `false`, block ALL buffer modifications including entering insert/replace
220    /// mode. Used for special buffers (explorer). Matches vim's `:set nomodifiable` /
221    /// `:set noma`. Default `true`.
222    pub modifiable: bool,
223    /// Soft-wrap behavior for lines that exceed the viewport width.
224    /// Maps directly to `:set wrap` / `:set linebreak` / `:set nowrap`.
225    pub wrap: WrapMode,
226    /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
227    pub textwidth: u32,
228    /// Show absolute line numbers in the gutter. Matches `:set number`.
229    /// Default `true`.
230    pub number: bool,
231    /// Show relative line offsets in the gutter. Combined with `number`,
232    /// enables hybrid mode. Matches `:set relativenumber`. Default `false`.
233    pub relativenumber: bool,
234    /// Minimum gutter width in cells for the line-number column.
235    /// Width grows past this to fit the largest displayed number.
236    /// Matches vim's `:set numberwidth` / `:set nuw`. Default `4`. Range 1..=20.
237    pub numberwidth: usize,
238    /// Highlight the row where the cursor sits. Matches vim's `:set cursorline`.
239    /// Default `false` — vim parity (`nocursorline`), and the same value
240    /// [`crate::editor::Settings::default`] carries. The two defaults used to
241    /// disagree (`Options` said `true`); they must stay in lockstep, which
242    /// `settings_default_matches_options_default` pins.
243    pub cursorline: bool,
244    /// Highlight the column where the cursor sits. Matches vim's `:set cursorcolumn`.
245    /// Default `false`.
246    pub cursorcolumn: bool,
247    /// Whether to reserve a 1-cell sign column for diagnostics and git signs.
248    /// Matches vim's `:set signcolumn`. Default [`SignColumnMode::Auto`].
249    pub signcolumn: SignColumnMode,
250    /// Number of cells reserved for a fold-marker gutter (0 = none, max 12).
251    /// Matches vim's `:set foldcolumn`. Default `0`.
252    pub foldcolumn: u32,
253    /// How folds are automatically generated. Matches vim's `:set foldmethod`.
254    /// Default [`FoldMethod::Expr`] (tree-sitter) — diverges from vim's
255    /// `manual` default; functions/if/match/blocks fold when `folds.scm` ships.
256    /// Alias `fdm`.
257    pub foldmethod: FoldMethod,
258    /// Enable auto-folds. When `false`, no folds are generated regardless
259    /// of `foldmethod`. Matches vim's `:set foldenable`. Alias `fen`.
260    /// Default `true`.
261    pub foldenable: bool,
262    /// Level at which folds start open. `99` (default) means all folds open;
263    /// `0` means all closed. Matches vim's `:set foldlevelstart`. Alias `fls`.
264    pub foldlevelstart: u32,
265    /// Open/close markers for [`FoldMethod::Marker`], as a comma-separated
266    /// pair `open,close`. Matches vim's `:set foldmarker` / `fmr`.
267    /// Default `"{{{,}}}"`. An invalid value (no comma, or an empty side)
268    /// falls back to the default at fold-extraction time.
269    pub foldmarker: String,
270    /// Comma-separated 1-based column indices for vertical rulers.
271    /// Empty string = no rulers. Matches vim's `:set colorcolumn`. Default `""`.
272    pub colorcolumn: String,
273    /// Format-options flags (subset of vim's `formatoptions` / `fo`).
274    /// `r` — auto-continue line comments on `<Enter>` in insert mode.
275    /// `o` — auto-continue line comments on `o` / `O` in normal mode.
276    /// Default `"ro"` (both on).
277    pub formatoptions: String,
278    /// Active filetype for the current buffer (e.g. `"rust"`, `"python"`).
279    /// Matches vim's `:set filetype` / `:set ft`. Default `""` (plain text).
280    pub filetype: String,
281    /// Minimum number of context rows kept visible above and below the cursor
282    /// when scrolling. `999` (or any value ≥ half the viewport height) keeps
283    /// the cursor centred. `0` disables the margin. Matches vim's
284    /// `:set scrolloff` / `:set so`. Default `5`.
285    pub scrolloff: usize,
286    /// Minimum number of context columns kept visible left and right of the
287    /// cursor when scrolling horizontally (no-wrap mode only). `0` disables.
288    /// Matches vim's `:set sidescrolloff` / `:set siso`. Default `0`.
289    pub sidescrolloff: usize,
290    /// Enable vim modeline parsing on file open. When `true`, hjkl scans
291    /// the first/last `modelines` lines for `vim:` / `ex:` / `vi:` markers
292    /// and applies per-buffer option overrides. Matches vim's `:set modeline`.
293    /// Default `true`.
294    pub modeline: bool,
295    /// Number of lines from each end to scan for vim modelines.
296    /// Matches vim's `:set modelines`. Default `5`.
297    pub modelines: u32,
298    /// Auto-reload a clean (non-dirty) buffer when its file changes on disk
299    /// (detected by `:checktime` / focus-regain). When `false`, an external
300    /// change is reported as a warning and the buffer is left untouched.
301    /// Matches vim's `:set autoread`. Default `true`.
302    pub autoreload: bool,
303    /// Enable vim-sneak style two-char digraph jump on `s` / `S` in normal
304    /// mode. When `true` (default), `s`/`S` operate as sneak jumps rather
305    /// than vim's built-in substitute-char / substitute-line.
306    /// `:set nomotion_sneak` reverts to standard vim behavior.
307    /// Default `true` — **BREAKING** for users relying on `s` = substitute-char.
308    pub motion_sneak: bool,
309    /// Render invisible characters (tabs, trailing spaces, EOL markers).
310    /// Matches vim's `:set list` / `:set nolist`. Default `false`.
311    pub list: bool,
312    /// Characters used to represent invisibles when `list` is on.
313    /// Matches vim's `:set listchars` / `:set lcs`.
314    /// Default matches vim: `tab:^I,eol:$`.
315    pub listchars: ListChars,
316    /// Render thin vertical indent guides at every `shiftwidth`-aligned
317    /// column in the viewport. hjkl-specific option. Default `true`.
318    /// `:set noindent_guides` / `:set noig` disables.
319    pub indent_guides: bool,
320    /// Character painted as the indent guide. Default `'│'`.
321    /// `:set indent_guide_char=<char>` / `:set igc=<char>` to customize.
322    pub indent_guide_char: char,
323    /// Enable inline color-literal preview (hex, rgb, hsl, named CSS colors).
324    /// hjkl-specific. Default `true`.
325    /// `:set nocolorizer` disables globally regardless of filetype.
326    pub colorizer: bool,
327    /// Allowlist of filetypes for which the colorizer runs.
328    /// Comma-separated in `:set colorizer_filetypes=css,scss,toml`.
329    /// Default: `["css","scss","sass","less","html","vue","svelte","tailwindcss","toml","lua","vim"]`.
330    pub colorizer_filetypes: Vec<String>,
331    /// Run the registered hjkl-mangler formatter for the buffer's path before
332    /// each `:w` save. On formatter error the save is aborted. When no formatter
333    /// is registered for the file extension, or the tool is not installed, the
334    /// save proceeds without formatting (warn-and-fall-through for missing tool).
335    /// hjkl-specific. Alias `fos`. Default `true`.
336    pub format_on_save: bool,
337    /// Strip trailing `[ \t]` from every line in the buffer before each `:w`
338    /// save. Applied in-place so post-save `:e` reflects the trimmed content.
339    /// hjkl-specific. Alias `tts`. Default `false`.
340    pub trim_trailing_whitespace: bool,
341    /// Enable helix-style rainbow bracket coloring via tree-sitter.
342    /// hjkl-specific. Alias `rb`. Default `true`.
343    pub rainbow_brackets: bool,
344    /// Milliseconds of inactivity after which the swap file is written.
345    /// Matches Vim's `:set updatetime` / `:set ut`. Default `4000`.
346    /// hjkl-specific swap-file write cadence; does NOT affect CursorHold.
347    pub updatetime: u32,
348    /// Highlight matching bracket pair under the cursor (vim matchparen).
349    /// When `true` (default), both the bracket under the cursor and its
350    /// matching partner are highlighted with the `match_paren` theme style.
351    /// C-style brackets only: `()[]{}` and `<>`. Alias `mps`.
352    /// `:set nomatchparen` disables. hjkl-specific.
353    pub matchparen: bool,
354    /// Vim `'fixendofline'` / `'fixeol'`. When `true` (the vim default), a
355    /// buffer whose last line has no terminating newline gets one added on
356    /// write. When `false`, the file's original end-of-line state
357    /// (`'endofline'`) is preserved byte-for-byte.
358    ///
359    /// This is a plain user option — unlike `'endofline'` it is NEVER
360    /// derived from the file. `'endofline'` is buffer-local state owned by
361    /// the host (it is set from the bytes actually read), so it has no
362    /// `Options` field; see `hjkl`'s `save::EolState`.
363    pub fixendofline: bool,
364}
365
366/// Invisibles rendering configuration for `:set list` / `:set listchars`.
367///
368/// Re-exported from [`hjkl_buffer::ListChars`] so callers programming to
369/// the engine surface don't need to import `hjkl-buffer` directly.
370pub use hjkl_buffer::ListChars;
371
372/// Fold method. Controls how folds are automatically generated.
373/// Matches vim's `:set foldmethod`.
374#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
375
376pub enum FoldMethod {
377    /// No automatic folds; only manual `zf` folds. Matches vim's `manual`.
378    Manual,
379    /// Automatically generate folds from the tree-sitter parse tree using
380    /// per-grammar `folds.scm` queries. Matches vim's `expr`/`syntax`.
381    /// **Default** — hjkl diverges from vim's `manual` default; functions /
382    /// if / match / blocks fold automatically when a `folds.scm` ships.
383    #[default]
384    Expr,
385    /// Marker folds via `{{{` / `}}}` comment delimiters. Matches vim's
386    /// `marker`. Parsed but not yet active (P4). Accepted without error.
387    Marker,
388}
389
390/// Sign-column display mode. Controls whether a 1-cell gutter is reserved
391/// for diagnostic and git signs. Matches vim's `:set signcolumn`.
392#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
393
394pub enum SignColumnMode {
395    /// Never reserve a sign column.
396    No,
397    /// Always reserve a sign column.
398    Yes,
399    /// Reserve only when at least one sign is visible (default).
400    #[default]
401    Auto,
402}
403
404/// Inline diagnostic ghost-text mode. Controls where the end-of-line `// …`
405/// diagnostic message is shown (Error-Lens style). Matches
406/// `:set diagnostics_inline=off|current|all`.
407#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
408
409pub enum DiagInlineMode {
410    /// Never show inline diagnostic ghost text.
411    Off,
412    /// Show only on the cursor's current line.
413    Current,
414    /// Show on every line that has a diagnostic (default).
415    #[default]
416    All,
417}
418
419/// Soft-wrap mode for the renderer + scroll math + `gj` / `gk`.
420/// Engine-native equivalent of [`hjkl_buffer::Wrap`]; the engine
421/// converts at the boundary to the buffer's runtime wrap setting.
422#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
423
424pub enum WrapMode {
425    /// Long lines extend past the right edge; `top_col` clips the
426    /// left side. Matches vim's `:set nowrap`.
427    #[default]
428    None,
429    /// Break at the cell boundary regardless of word edges. Matches
430    /// `:set wrap`.
431    Char,
432    /// Break at the last whitespace inside the visible width when
433    /// possible; falls back to a char break for runs longer than the
434    /// width. Matches `:set linebreak`.
435    Word,
436}
437
438/// Typed value for [`Options::set_by_name`] / [`Options::get_by_name`].
439///
440/// `:set tabstop=4` parses as `OptionValue::Int(4)`;
441/// `:set noexpandtab` parses as `OptionValue::Bool(false)`;
442/// `:set iskeyword=...` as `OptionValue::String(...)`.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub enum OptionValue {
445    Bool(bool),
446    Int(i64),
447    String(String),
448}
449
450impl Default for Options {
451    fn default() -> Self {
452        Self {
453            tabstop: 4,
454            shiftwidth: 4,
455            expandtab: true,
456            softtabstop: 4,
457            iskeyword: "@,48-57,_,192-255".to_string(),
458            ignorecase: true,
459            smartcase: true,
460            hlsearch: true,
461            incsearch: true,
462            wrapscan: true,
463            autoindent: true,
464            smartindent: true,
465            timeout_len: core::time::Duration::from_millis(1000),
466            undo_levels: 1000,
467            undo_break_on_motion: true,
468            readonly: false,
469            modifiable: true,
470            wrap: WrapMode::None,
471            textwidth: 79,
472            number: true,
473            relativenumber: false,
474            numberwidth: 4,
475            cursorline: false,
476            cursorcolumn: false,
477            signcolumn: SignColumnMode::Auto,
478            foldcolumn: 0,
479            foldmethod: FoldMethod::Expr,
480            foldenable: true,
481            foldlevelstart: 99,
482            foldmarker: "{{{,}}}".to_string(),
483            colorcolumn: String::new(),
484            formatoptions: "ro".to_string(),
485            filetype: String::new(),
486            scrolloff: 5,
487            sidescrolloff: 0,
488            modeline: true,
489            modelines: 5,
490            autoreload: true,
491            motion_sneak: true,
492            list: false,
493            listchars: ListChars::default(),
494            indent_guides: true,
495            indent_guide_char: '│',
496            colorizer: true,
497            colorizer_filetypes: vec![
498                "css".to_string(),
499                "scss".to_string(),
500                "sass".to_string(),
501                "less".to_string(),
502                "html".to_string(),
503                "vue".to_string(),
504                "svelte".to_string(),
505                "tailwindcss".to_string(),
506                "toml".to_string(),
507                "lua".to_string(),
508                "vim".to_string(),
509            ],
510            format_on_save: true,
511            trim_trailing_whitespace: false,
512            rainbow_brackets: true,
513            updatetime: 4000,
514            matchparen: true,
515            fixendofline: true,
516        }
517    }
518}
519
520impl Options {
521    /// Set an option by name. Vim-flavored option naming. Returns
522    /// [`EngineError::Ex`] for unknown names or type-mismatched values.
523    ///
524    /// Booleans accept `OptionValue::Bool(_)` directly or
525    /// `OptionValue::Int(0)`/`Int(non_zero)`. Integers accept only
526    /// `Int(_)`. Strings accept only `String(_)`.
527    pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
528        macro_rules! set_bool {
529            ($field:ident) => {{
530                self.$field = match val {
531                    OptionValue::Bool(b) => b,
532                    OptionValue::Int(n) => n != 0,
533                    other => {
534                        return Err(EngineError::Ex(format!(
535                            "option `{name}` expects bool, got {other:?}"
536                        )));
537                    }
538                };
539                Ok(())
540            }};
541        }
542        macro_rules! set_u32 {
543            ($field:ident) => {{
544                self.$field = match val {
545                    OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
546                    OptionValue::Int(n) => {
547                        return Err(EngineError::Ex(format!(
548                            "option `{name}` out of u32 range: {n}"
549                        )));
550                    }
551                    other => {
552                        return Err(EngineError::Ex(format!(
553                            "option `{name}` expects int, got {other:?}"
554                        )));
555                    }
556                };
557                Ok(())
558            }};
559        }
560        macro_rules! set_string {
561            ($field:ident) => {{
562                self.$field = match val {
563                    OptionValue::String(s) => s,
564                    other => {
565                        return Err(EngineError::Ex(format!(
566                            "option `{name}` expects string, got {other:?}"
567                        )));
568                    }
569                };
570                Ok(())
571            }};
572        }
573        match name {
574            "tabstop" | "ts" => set_u32!(tabstop),
575            "shiftwidth" | "sw" => set_u32!(shiftwidth),
576            "softtabstop" | "sts" => set_u32!(softtabstop),
577            "textwidth" | "tw" => set_u32!(textwidth),
578            "expandtab" | "et" => set_bool!(expandtab),
579            "iskeyword" | "isk" => set_string!(iskeyword),
580            "ignorecase" | "ic" => set_bool!(ignorecase),
581            "smartcase" | "scs" => set_bool!(smartcase),
582            "hlsearch" | "hls" => set_bool!(hlsearch),
583            "incsearch" | "is" => set_bool!(incsearch),
584            "wrapscan" | "ws" => set_bool!(wrapscan),
585            "autoindent" | "ai" => set_bool!(autoindent),
586            "smartindent" | "si" => set_bool!(smartindent),
587            "timeoutlen" | "tm" => {
588                self.timeout_len = match val {
589                    OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
590                    other => {
591                        return Err(EngineError::Ex(format!(
592                            "option `{name}` expects non-negative int (millis), got {other:?}"
593                        )));
594                    }
595                };
596                Ok(())
597            }
598            "undolevels" | "ul" => set_u32!(undo_levels),
599            "undobreak" => set_bool!(undo_break_on_motion),
600            "readonly" | "ro" => set_bool!(readonly),
601            "modifiable" | "ma" => set_bool!(modifiable),
602            "wrap" => {
603                let on = match val {
604                    OptionValue::Bool(b) => b,
605                    OptionValue::Int(n) => n != 0,
606                    other => {
607                        return Err(EngineError::Ex(format!(
608                            "option `{name}` expects bool, got {other:?}"
609                        )));
610                    }
611                };
612                self.wrap = match (on, self.wrap) {
613                    (false, _) => WrapMode::None,
614                    (true, WrapMode::Word) => WrapMode::Word,
615                    (true, _) => WrapMode::Char,
616                };
617                Ok(())
618            }
619            "linebreak" | "lbr" => {
620                let on = match val {
621                    OptionValue::Bool(b) => b,
622                    OptionValue::Int(n) => n != 0,
623                    other => {
624                        return Err(EngineError::Ex(format!(
625                            "option `{name}` expects bool, got {other:?}"
626                        )));
627                    }
628                };
629                self.wrap = match (on, self.wrap) {
630                    (true, _) => WrapMode::Word,
631                    (false, WrapMode::Word) => WrapMode::Char,
632                    (false, other) => other,
633                };
634                Ok(())
635            }
636            "number" | "nu" => set_bool!(number),
637            "relativenumber" | "rnu" => set_bool!(relativenumber),
638            "numberwidth" | "nuw" => {
639                self.numberwidth = match val {
640                    OptionValue::Int(n) if (1..=20).contains(&n) => n as usize,
641                    OptionValue::Int(n) => {
642                        return Err(EngineError::Ex(format!(
643                            "option `{name}` must be in range 1..=20, got {n}"
644                        )));
645                    }
646                    other => {
647                        return Err(EngineError::Ex(format!(
648                            "option `{name}` expects int, got {other:?}"
649                        )));
650                    }
651                };
652                Ok(())
653            }
654            "cursorline" | "cul" => set_bool!(cursorline),
655            "cursorcolumn" | "cuc" => set_bool!(cursorcolumn),
656            "signcolumn" | "scl" => {
657                self.signcolumn = match val {
658                    OptionValue::String(ref s) => match s.as_str() {
659                        "yes" => SignColumnMode::Yes,
660                        "no" => SignColumnMode::No,
661                        "auto" => SignColumnMode::Auto,
662                        other => {
663                            return Err(EngineError::Ex(format!(
664                                "option `{name}` must be `yes`, `no`, or `auto`, got {other:?}"
665                            )));
666                        }
667                    },
668                    other => {
669                        return Err(EngineError::Ex(format!(
670                            "option `{name}` expects string (yes/no/auto), got {other:?}"
671                        )));
672                    }
673                };
674                Ok(())
675            }
676            "foldcolumn" | "fdc" => {
677                self.foldcolumn = match val {
678                    OptionValue::Int(n) if (0..=12).contains(&n) => n as u32,
679                    OptionValue::Int(n) => {
680                        return Err(EngineError::Ex(format!(
681                            "option `{name}` must be in range 0..=12, got {n}"
682                        )));
683                    }
684                    other => {
685                        return Err(EngineError::Ex(format!(
686                            "option `{name}` expects int (0-12), got {other:?}"
687                        )));
688                    }
689                };
690                Ok(())
691            }
692            "foldmethod" | "fdm" => {
693                self.foldmethod = match val {
694                    OptionValue::String(ref s) => match s.as_str() {
695                        "manual" => FoldMethod::Manual,
696                        "expr" | "syntax" => FoldMethod::Expr,
697                        "marker" => FoldMethod::Marker,
698                        other => {
699                            return Err(EngineError::Ex(format!(
700                                "option `{name}` must be `manual`, `expr`, `syntax`, or `marker`, got `{other}`"
701                            )));
702                        }
703                    },
704                    other => {
705                        return Err(EngineError::Ex(format!(
706                            "option `{name}` expects string, got {other:?}"
707                        )));
708                    }
709                };
710                Ok(())
711            }
712            "foldenable" | "fen" => set_bool!(foldenable),
713            "foldlevelstart" | "fls" => set_u32!(foldlevelstart),
714            "colorcolumn" | "cc" => set_string!(colorcolumn),
715            "formatoptions" | "fo" => set_string!(formatoptions),
716            "filetype" | "ft" => set_string!(filetype),
717            "scrolloff" | "so" => {
718                self.scrolloff = match val {
719                    OptionValue::Int(n) if n >= 0 => n as usize,
720                    OptionValue::Int(n) => {
721                        return Err(EngineError::Ex(format!(
722                            "option `{name}` must be >= 0, got {n}"
723                        )));
724                    }
725                    other => {
726                        return Err(EngineError::Ex(format!(
727                            "option `{name}` expects int, got {other:?}"
728                        )));
729                    }
730                };
731                Ok(())
732            }
733            "sidescrolloff" | "siso" => {
734                self.sidescrolloff = match val {
735                    OptionValue::Int(n) if n >= 0 => n as usize,
736                    OptionValue::Int(n) => {
737                        return Err(EngineError::Ex(format!(
738                            "option `{name}` must be >= 0, got {n}"
739                        )));
740                    }
741                    other => {
742                        return Err(EngineError::Ex(format!(
743                            "option `{name}` expects int, got {other:?}"
744                        )));
745                    }
746                };
747                Ok(())
748            }
749            "modeline" | "ml" => set_bool!(modeline),
750            "autoreload" | "ar" => set_bool!(autoreload),
751            "modelines" | "mls" => set_u32!(modelines),
752            "motion_sneak" | "snk" => set_bool!(motion_sneak),
753            "list" => set_bool!(list),
754            "listchars" | "lcs" => {
755                let s = match val {
756                    OptionValue::String(s) => s,
757                    other => {
758                        return Err(EngineError::Ex(format!(
759                            "option `{name}` expects string, got {other:?}"
760                        )));
761                    }
762                };
763                self.listchars = ListChars::parse(&s).map_err(EngineError::Ex)?;
764                Ok(())
765            }
766            "indent_guides" | "ig" => set_bool!(indent_guides),
767            "colorizer" | "clz" => set_bool!(colorizer),
768            "colorizer_filetypes" | "clzft" => {
769                let s = match val {
770                    OptionValue::String(s) => s,
771                    other => {
772                        return Err(EngineError::Ex(format!(
773                            "option `{name}` expects string, got {other:?}"
774                        )));
775                    }
776                };
777                self.colorizer_filetypes = s
778                    .split(',')
779                    .map(|p| p.trim().to_string())
780                    .filter(|p| !p.is_empty())
781                    .collect();
782                Ok(())
783            }
784            "indent_guide_char" | "igc" => {
785                let s = match val {
786                    OptionValue::String(s) => s,
787                    other => {
788                        return Err(EngineError::Ex(format!(
789                            "option `{name}` expects a single-char string, got {other:?}"
790                        )));
791                    }
792                };
793                let mut chars = s.chars();
794                let (Some(ch), None) = (chars.next(), chars.next()) else {
795                    return Err(EngineError::Ex(format!(
796                        "option `{name}` expects exactly one character, got {s:?}"
797                    )));
798                };
799                self.indent_guide_char = ch;
800                Ok(())
801            }
802            "format_on_save" | "fos" => set_bool!(format_on_save),
803            "trim_trailing_whitespace" | "tts" => set_bool!(trim_trailing_whitespace),
804            "rainbow_brackets" | "rb" => set_bool!(rainbow_brackets),
805            "updatetime" | "ut" => set_u32!(updatetime),
806            "matchparen" | "mps" => set_bool!(matchparen),
807            "fixendofline" | "fixeol" => set_bool!(fixendofline),
808            other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
809        }
810    }
811
812    /// Read an option by name. `None` for unknown names.
813    pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
814        Some(match name {
815            "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
816            "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
817            "softtabstop" | "sts" => OptionValue::Int(self.softtabstop as i64),
818            "textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
819            "expandtab" | "et" => OptionValue::Bool(self.expandtab),
820            "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
821            "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
822            "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
823            "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
824            "incsearch" | "is" => OptionValue::Bool(self.incsearch),
825            "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
826            "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
827            "smartindent" | "si" => OptionValue::Bool(self.smartindent),
828            "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
829            "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
830            "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
831            "readonly" | "ro" => OptionValue::Bool(self.readonly),
832            "modifiable" | "ma" => OptionValue::Bool(self.modifiable),
833            "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
834            "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
835            "number" | "nu" => OptionValue::Bool(self.number),
836            "relativenumber" | "rnu" => OptionValue::Bool(self.relativenumber),
837            "numberwidth" | "nuw" => OptionValue::Int(self.numberwidth as i64),
838            "cursorline" | "cul" => OptionValue::Bool(self.cursorline),
839            "cursorcolumn" | "cuc" => OptionValue::Bool(self.cursorcolumn),
840            "signcolumn" | "scl" => OptionValue::String(
841                match self.signcolumn {
842                    SignColumnMode::Yes => "yes",
843                    SignColumnMode::No => "no",
844                    SignColumnMode::Auto => "auto",
845                }
846                .to_string(),
847            ),
848            "foldcolumn" | "fdc" => OptionValue::Int(self.foldcolumn as i64),
849            "foldmethod" | "fdm" => OptionValue::String(
850                match self.foldmethod {
851                    FoldMethod::Manual => "manual",
852                    FoldMethod::Expr => "expr",
853                    FoldMethod::Marker => "marker",
854                }
855                .to_string(),
856            ),
857            "foldenable" | "fen" => OptionValue::Bool(self.foldenable),
858            "foldlevelstart" | "fls" => OptionValue::Int(self.foldlevelstart as i64),
859            "colorcolumn" | "cc" => OptionValue::String(self.colorcolumn.clone()),
860            "formatoptions" | "fo" => OptionValue::String(self.formatoptions.clone()),
861            "filetype" | "ft" => OptionValue::String(self.filetype.clone()),
862            "scrolloff" | "so" => OptionValue::Int(self.scrolloff as i64),
863            "sidescrolloff" | "siso" => OptionValue::Int(self.sidescrolloff as i64),
864            "modeline" | "ml" => OptionValue::Bool(self.modeline),
865            "autoreload" | "ar" => OptionValue::Bool(self.autoreload),
866            "modelines" | "mls" => OptionValue::Int(self.modelines as i64),
867            "motion_sneak" | "snk" => OptionValue::Bool(self.motion_sneak),
868            "list" => OptionValue::Bool(self.list),
869            "listchars" | "lcs" => OptionValue::String(self.listchars.to_canonical_string()),
870            "indent_guides" | "ig" => OptionValue::Bool(self.indent_guides),
871            "indent_guide_char" | "igc" => OptionValue::String(self.indent_guide_char.to_string()),
872            "colorizer" | "clz" => OptionValue::Bool(self.colorizer),
873            "colorizer_filetypes" | "clzft" => {
874                OptionValue::String(self.colorizer_filetypes.join(","))
875            }
876            "format_on_save" | "fos" => OptionValue::Bool(self.format_on_save),
877            "trim_trailing_whitespace" | "tts" => OptionValue::Bool(self.trim_trailing_whitespace),
878            "rainbow_brackets" | "rb" => OptionValue::Bool(self.rainbow_brackets),
879            "updatetime" | "ut" => OptionValue::Int(self.updatetime as i64),
880            "matchparen" | "mps" => OptionValue::Bool(self.matchparen),
881            "fixendofline" | "fixeol" => OptionValue::Bool(self.fixendofline),
882            _ => return None,
883        })
884    }
885}
886
887/// Visible region of a buffer — the runtime viewport state the host
888/// owns and mutates per render frame.
889///
890/// 0.0.34 (Patch C-δ.1): semantic ownership moved from
891/// [`hjkl_buffer::View`] to [`Host`]. The struct still lives in
892/// `hjkl-buffer` (alongside [`hjkl_buffer::Wrap`] and the rope-walking
893/// `wrap_segments` math it depends on) so the dependency graph stays
894/// `engine → buffer`; the engine re-exports it as
895/// [`crate::types::Viewport`] (this alias) for hosts that program to
896/// the SPEC surface.
897///
898/// The architectural decision is "viewport lives on Host, not View":
899/// vim logic must work in GUI hosts (variable-width fonts, pixel
900/// canvases, soft-wrap by pixel) as well as TUI hosts, so the runtime
901/// viewport state is expressed in cells/rows/cols and is owned by the
902/// host. `top_row` and `top_col` are the first visible row / column
903/// (`top_col` is a char index).
904///
905/// `wrap` and `text_width` together drive soft-wrap-aware scrolling
906/// and motion. `text_width` is the cell width of the text area
907/// (i.e., `width` minus any gutter the host renders).
908pub use hjkl_buffer::Viewport;
909
910/// Opaque buffer identifier owned by the host. Engine echoes it back
911/// in [`Host::Intent`] variants for buffer-list operations
912/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
913#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
914pub struct BufferId(pub u64);
915
916/// Modifier bits accompanying every keystroke.
917#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
918pub struct Modifiers {
919    pub ctrl: bool,
920    pub shift: bool,
921    pub alt: bool,
922    pub super_: bool,
923}
924
925/// Special key codes — anything that isn't a printable character.
926#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
927#[non_exhaustive]
928pub enum SpecialKey {
929    Esc,
930    Enter,
931    Backspace,
932    Tab,
933    BackTab,
934    Up,
935    Down,
936    Left,
937    Right,
938    Home,
939    End,
940    PageUp,
941    PageDown,
942    Insert,
943    Delete,
944    F(u8),
945}
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
948pub enum MouseKind {
949    Press,
950    Release,
951    Drag,
952    ScrollUp,
953    ScrollDown,
954}
955
956#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
957pub struct MouseEvent {
958    pub kind: MouseKind,
959    pub pos: Pos,
960    pub mods: Modifiers,
961}
962
963/// Single input event handed to the engine.
964///
965/// `Paste` content bypasses insert-mode mappings, abbreviations, and
966/// autoindent; the engine inserts the bracketed-paste payload as-is.
967#[derive(Debug, Clone, PartialEq, Eq)]
968#[non_exhaustive]
969pub enum Input {
970    Char(char, Modifiers),
971    Key(SpecialKey, Modifiers),
972    Mouse(MouseEvent),
973    Paste(String),
974    FocusGained,
975    FocusLost,
976    Resize(u16, u16),
977}
978
979/// Host adapter consumed by the engine. Lives behind the planned
980/// `Editor<B: View, H: Host>` generic; today it's the contract that
981/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
982/// align against.
983///
984/// Methods with default impls return safe no-ops so hosts that don't
985/// need a feature (cancellation, wrap-aware motion, syntax highlights)
986/// can ignore them.
987pub trait Host: Send {
988    /// Custom intent type. Hosts that don't fan out actions back to
989    /// themselves can use the unit type via the default impl approach
990    /// (set associated type explicitly).
991    type Intent;
992
993    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
994
995    /// Fire-and-forget clipboard write. Engine never blocks; the host
996    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
997    /// `pbcopy`, …).
998    fn write_clipboard(&mut self, text: String);
999
1000    /// Returns the last-known cached clipboard value. May be stale —
1001    /// matches the OSC52/wl-paste model neovim and helix both ship.
1002    fn read_clipboard(&mut self) -> Option<String>;
1003
1004    // ── Time + cancellation ──
1005
1006    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
1007    /// reads this; engine never reads `Instant::now()` directly so
1008    /// macro replay stays deterministic.
1009    fn now(&self) -> core::time::Duration;
1010
1011    /// Cooperative cancellation. Engine polls during long search /
1012    /// regex / multi-cursor edit loops. Default returns `false`.
1013    fn should_cancel(&self) -> bool {
1014        false
1015    }
1016
1017    // ── Search prompt ──
1018
1019    /// Synchronously prompt the user for a search pattern. Returning
1020    /// `None` aborts the search.
1021    fn prompt_search(&mut self) -> Option<String>;
1022
1023    // ── Wrap-aware motion (default: wrap is identity) ──
1024
1025    /// Map a logical position to its display line for `gj`/`gk`. Hosts
1026    /// without wrapping may use the default identity impl.
1027    fn display_line_for(&self, pos: Pos) -> u32 {
1028        pos.line
1029    }
1030
1031    /// Inverse of [`display_line_for`]. Default identity.
1032    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
1033        Pos { line, col }
1034    }
1035
1036    // ── Syntax highlights (default: none) ──
1037
1038    /// Host-supplied syntax highlights for `range`. Empty by default;
1039    /// hosts wire tree-sitter or LSP semantic tokens here.
1040    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
1041        let _ = range;
1042        Vec::new()
1043    }
1044
1045    // ── Cursor shape ──
1046
1047    /// Engine emits this on every mode transition. Hosts repaint the
1048    /// cursor in the requested shape.
1049    fn emit_cursor_shape(&mut self, shape: CursorShape);
1050
1051    // ── Viewport (host owns runtime viewport state) ──
1052
1053    /// Borrow the host's viewport. The host writes `width`/`height`/
1054    /// `text_width`/`wrap` per render frame; the engine reads/writes
1055    /// `top_row` / `top_col` to scroll. 0.0.34 (Patch C-δ.1) moved
1056    /// this off [`hjkl_buffer::View`] onto `Host`.
1057    fn viewport(&self) -> &Viewport;
1058
1059    /// Mutable viewport access. Engine motion + scroll code routes
1060    /// here when scrolloff math advances `top_row`.
1061    fn viewport_mut(&mut self) -> &mut Viewport;
1062
1063    // ── Custom intent fan-out ──
1064
1065    /// Host-defined event the engine raises (LSP request, fold op,
1066    /// buffer switch, …).
1067    fn emit_intent(&mut self, intent: Self::Intent);
1068}
1069
1070/// Default no-op [`Host`] implementation. Suitable for tests, headless
1071/// embedding, or any host that doesn't yet need clipboard / cursor-shape
1072/// / cancellation plumbing.
1073///
1074/// Behaviour:
1075/// - `write_clipboard` stores the most recent payload in an in-memory
1076///   slot; `read_clipboard` returns it. Round-trip-only — no OS-level
1077///   clipboard touched.
1078/// - `now` returns wall-clock duration since construction.
1079/// - `prompt_search` returns `None` (search is aborted).
1080/// - `emit_cursor_shape` records the most recent shape; readable via
1081///   [`DefaultHost::last_cursor_shape`].
1082/// - `emit_intent` discards intents (intent type is `()`).
1083#[derive(Debug)]
1084pub struct DefaultHost {
1085    clipboard: Option<String>,
1086    last_cursor_shape: CursorShape,
1087    started: std::time::Instant,
1088    viewport: Viewport,
1089}
1090
1091impl Default for DefaultHost {
1092    fn default() -> Self {
1093        Self::new()
1094    }
1095}
1096
1097impl DefaultHost {
1098    /// Default viewport size for headless / test hosts: 80x24, no
1099    /// soft-wrap. Matches the conventional terminal default.
1100    pub const DEFAULT_VIEWPORT: Viewport = Viewport {
1101        top_row: 0,
1102        top_col: 0,
1103        width: 80,
1104        height: 24,
1105        wrap: hjkl_buffer::Wrap::None,
1106        text_width: 80,
1107        tab_width: 0,
1108    };
1109
1110    pub fn new() -> Self {
1111        Self {
1112            clipboard: None,
1113            last_cursor_shape: CursorShape::Block,
1114            started: std::time::Instant::now(),
1115            viewport: Self::DEFAULT_VIEWPORT,
1116        }
1117    }
1118
1119    /// Construct a [`DefaultHost`] with a custom initial viewport.
1120    /// Useful for tests that want to exercise scrolloff math at a
1121    /// specific window size.
1122    pub fn with_viewport(viewport: Viewport) -> Self {
1123        Self {
1124            clipboard: None,
1125            last_cursor_shape: CursorShape::Block,
1126            started: std::time::Instant::now(),
1127            viewport,
1128        }
1129    }
1130
1131    /// Most recent cursor shape requested by the engine.
1132    pub fn last_cursor_shape(&self) -> CursorShape {
1133        self.last_cursor_shape
1134    }
1135}
1136
1137impl Host for DefaultHost {
1138    type Intent = ();
1139
1140    fn write_clipboard(&mut self, text: String) {
1141        self.clipboard = Some(text);
1142    }
1143
1144    fn read_clipboard(&mut self) -> Option<String> {
1145        self.clipboard.clone()
1146    }
1147
1148    fn now(&self) -> core::time::Duration {
1149        self.started.elapsed()
1150    }
1151
1152    fn prompt_search(&mut self) -> Option<String> {
1153        None
1154    }
1155
1156    fn emit_cursor_shape(&mut self, shape: CursorShape) {
1157        self.last_cursor_shape = shape;
1158    }
1159
1160    fn viewport(&self) -> &Viewport {
1161        &self.viewport
1162    }
1163
1164    fn viewport_mut(&mut self) -> &mut Viewport {
1165        &mut self.viewport
1166    }
1167
1168    fn emit_intent(&mut self, _intent: Self::Intent) {}
1169}
1170
1171/// Engine render frame consumed by the host once per redraw.
1172///
1173/// Borrow-style — the engine builds it on demand from its internal
1174/// state without allocating clones of large fields. Hosts diff across
1175/// frames to decide what to repaint.
1176///
1177/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
1178/// a snapshot of the current line count (to size the gutter). The
1179/// SPEC-target fields (`selections`, `highlights`, `command_line`,
1180/// `search_prompt`, `status_line`) land once trait extraction wires
1181/// the FSM through `SelectionSet` and the highlight pipeline.
1182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1183pub struct RenderFrame {
1184    pub mode: SnapshotMode,
1185    pub cursor_row: u32,
1186    pub cursor_col: u32,
1187    pub cursor_shape: CursorShape,
1188    pub viewport_top: u32,
1189    pub line_count: u32,
1190}
1191
1192/// Coarse editor snapshot suitable for serde round-tripping.
1193///
1194/// Today's shape is intentionally minimal — it carries only the bits
1195/// the runtime [`crate::Editor`] knows how to round-trip without the
1196/// trait extraction (mode, cursor, lines, viewport top, settings).
1197/// Once `Editor<B: View, H: Host>` ships under phase 5, this struct
1198/// grows to cover full SPEC state: registers, marks, jump list, change
1199/// list, undo tree, full options.
1200///
1201/// Hosts that persist editor state between sessions should:
1202///
1203/// - Treat the snapshot as opaque. Don't manually mutate fields.
1204/// - Always check `version` after deserialization; reject on
1205///   mismatch rather than attempt migration.
1206///
1207/// # Wire-format stability
1208///
1209/// - **0.0.x:** [`Self::VERSION`] bumps with every structural change to
1210///   the snapshot. Hosts must reject mismatched persisted state — no
1211///   migration path is offered.
1212/// - **0.1.0:** [`Self::VERSION`] freezes. Hosts persisting editor state
1213///   between sessions can rely on the wire format being stable for the
1214///   entire 0.1.x line.
1215/// - **0.2.0+:** any further structural change to this struct requires a
1216///   `VERSION++` bump and is gated behind a major version bump of the
1217///   crate.
1218#[derive(Debug, Clone)]
1219
1220pub struct EditorSnapshot {
1221    /// Format version. See [`Self::VERSION`] for the lock policy.
1222    /// Hosts use this to detect mismatched persisted state.
1223    pub version: u32,
1224    /// Mode at snapshot time (status-line granularity).
1225    pub mode: SnapshotMode,
1226    /// Cursor `(row, col)` in byte indexing.
1227    pub cursor: (u32, u32),
1228    /// View lines. Trailing `\n` not included.
1229    pub lines: Vec<String>,
1230    /// Viewport top line at snapshot time.
1231    pub viewport_top: u32,
1232    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
1233    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
1234    /// doesn't derive them today.
1235    pub registers: crate::Registers,
1236    /// Named marks — lowercase (`'a`–`'z`, buffer-scope). Round-trips
1237    /// across tab swaps in the host.
1238    ///
1239    /// 0.0.36: consolidated from the prior `file_marks` field;
1240    /// lowercase marks now persist as well since they live in the
1241    /// same unified [`crate::Editor::marks`] map.
1242    pub marks: std::collections::BTreeMap<char, (u32, u32)>,
1243    /// Global (file) marks — uppercase (`'A`–`'Z`). Each entry records
1244    /// `(buffer_id, row, col)` so cross-buffer jumps can switch to the
1245    /// correct slot. Added in VERSION 5.
1246    pub global_marks: std::collections::BTreeMap<char, (u64, u32, u32)>,
1247}
1248
1249/// Status-line mode summary. Bridges to the legacy
1250/// [`crate::VimMode`] without leaking the full FSM type into the
1251/// snapshot wire format.
1252#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
1253
1254pub enum SnapshotMode {
1255    #[default]
1256    Normal,
1257    Insert,
1258    Visual,
1259    VisualLine,
1260    VisualBlock,
1261}
1262
1263impl EditorSnapshot {
1264    /// Current snapshot format version.
1265    ///
1266    /// Bumped to 2 in v0.0.8: registers added.
1267    /// Bumped to 3 in v0.0.9: file_marks added.
1268    /// Bumped to 4 in v0.0.36: file_marks → unified `marks` map
1269    /// (lowercase + uppercase consolidated).
1270    /// Bumped to 5: `global_marks` field added for cross-buffer uppercase
1271    /// marks (closes #175).
1272    ///
1273    /// # Lock policy
1274    ///
1275    /// - **0.0.x (today):** `VERSION` bumps freely with each structural
1276    ///   change to [`EditorSnapshot`]. Persisted state from an older
1277    ///   patch release will not round-trip; hosts must reject the
1278    ///   snapshot rather than attempt a field-by-field migration.
1279    /// - **0.1.0:** `VERSION` freezes. Hosts persisting editor state
1280    ///   between sessions can rely on the wire format being stable for
1281    ///   the entire 0.1.x line.
1282    /// - **0.2.0+:** any further structural change requires `VERSION++`
1283    ///   together with a major-version bump of `hjkl-engine`.
1284    pub const VERSION: u32 = 5;
1285}
1286
1287/// Errors surfaced from the engine to the host. Intentionally narrow —
1288/// callsites that fail in user-facing ways return `Result<_,
1289/// EngineError>`; internal invariant breaks use `debug_assert!`.
1290#[derive(Debug, thiserror::Error)]
1291pub enum EngineError {
1292    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
1293    /// regex error in the status line.
1294    #[error("regex compile error: {0}")]
1295    Regex(#[from] regex::Error),
1296
1297    /// `:[range]` parse failed.
1298    #[error("invalid range: {0}")]
1299    InvalidRange(String),
1300
1301    /// Ex command parse failed (unknown command, malformed args).
1302    #[error("ex parse: {0}")]
1303    Ex(String),
1304
1305    /// Edit attempted on a read-only buffer.
1306    #[error("buffer is read-only")]
1307    ReadOnly,
1308
1309    /// Position passed by the caller pointed outside the buffer.
1310    #[error("position out of bounds: {0:?}")]
1311    OutOfBounds(Pos),
1312
1313    /// Snapshot version mismatch. Host should treat as "abandon
1314    /// snapshot" rather than attempt migration.
1315    #[error("snapshot version mismatch: file={0}, expected={1}")]
1316    SnapshotVersion(u32, u32),
1317}
1318
1319pub(crate) mod sealed {
1320    /// Sealing trait for the planned 0.1.0 [`super::View`] surface.
1321    /// Pre-1.0 the engine reserves the right to add methods to the
1322    /// `View` super-trait without a major bump; downstream cannot
1323    /// `impl View` from outside this family.
1324    ///
1325    /// The in-tree [`hjkl_buffer::View`] is the canonical impl; the
1326    /// `Sealed` marker for it lives in `crate::buffer_impl`. The module
1327    /// itself stays `pub(crate)` so the sibling impl module can name
1328    /// the trait while keeping the seal closed to the outside world.
1329    pub trait Sealed {}
1330}
1331
1332/// Cursor sub-trait of [`View`].
1333///
1334/// `Pos` here is the engine's grapheme-indexed [`Pos`] type. View
1335/// implementations convert at the boundary if their internal indexing
1336/// differs (e.g., the rope's byte indexing).
1337pub trait Cursor: Send {
1338    /// Active primary cursor position.
1339    fn cursor(&self) -> Pos;
1340    /// Move the active primary cursor.
1341    fn set_cursor(&mut self, pos: Pos);
1342    /// Byte offset for `pos`. Used by regex search bridges.
1343    fn byte_offset(&self, pos: Pos) -> usize;
1344    /// Inverse of [`Self::byte_offset`].
1345    fn pos_at_byte(&self, byte: usize) -> Pos;
1346}
1347
1348/// Read-only query sub-trait of [`View`].
1349pub trait Query: Send {
1350    /// Number of logical lines (excluding the implicit trailing line).
1351    fn line_count(&self) -> u32;
1352    /// Return an owned copy of line `idx` (0-based). Implementations should
1353    /// panic on out-of-bounds rather than silently return empty.
1354    fn line(&self, idx: u32) -> String;
1355    /// Total buffer length in bytes.
1356    fn len_bytes(&self) -> usize;
1357    /// Slice for the half-open `range`. May allocate (rope joins)
1358    /// or borrow (contiguous storage). Returns
1359    /// [`std::borrow::Cow<'_, str>`] so contiguous backends can
1360    /// avoid the allocation.
1361    fn slice(&self, range: core::ops::Range<Pos>) -> std::borrow::Cow<'_, str>;
1362    /// Monotonic mutation generation counter. Increments on every
1363    /// content-changing call (insert / delete / replace / fold-touch
1364    /// edit / `set_content`). Read-only ops (cursor moves, queries,
1365    /// view changes) leave it untouched.
1366    ///
1367    /// Engine consumers cache per-row data (search-match positions,
1368    /// syntax spans, wrap layout) keyed off this counter — when it
1369    /// advances, the cache is invalidated.
1370    ///
1371    /// Implementations may return any monotonically non-decreasing
1372    /// value (zero is fine for non-canonical impls that don't have a
1373    /// caching story); the contract is "if `dirty_gen` changed, the
1374    /// content **may** have changed."
1375    fn dirty_gen(&self) -> u64 {
1376        0
1377    }
1378
1379    /// Byte offset of the first byte of `row` within the buffer's
1380    /// canonical `lines().join("\n")` rendering. Out-of-range rows
1381    /// clamp to `len_bytes()`.
1382    ///
1383    /// Default implementation walks every prior row's byte length and
1384    /// adds a separator byte per row gap. Backends with a faster path
1385    /// (rope position-of-line) should override.
1386    ///
1387    /// Pre-0.1.0 default-impl addition — does not extend the sealed
1388    /// surface for downstream impls.
1389    fn byte_of_row(&self, row: usize) -> usize {
1390        let n = self.line_count() as usize;
1391        let row = row.min(n);
1392        let mut acc = 0usize;
1393        for r in 0..row {
1394            acc += self.line(r as u32).len();
1395            // Separator newline between rows. The canonical engine
1396            // join uses `\n` between every pair of lines (no trailing
1397            // newline), so add one separator per row strictly before
1398            // the last buffer row.
1399            if r + 1 < n {
1400                acc += 1;
1401            }
1402        }
1403        acc
1404    }
1405
1406    /// Return the canonical `lines().join("\n")` rendering of the
1407    /// document as an `Arc<String>`. Multiple per-tick consumers (syntax
1408    /// pipeline, LSP notify, git signature, dirty hash) need this; the
1409    /// `View` impl caches against `dirty_gen` so they share one
1410    /// allocation per generation.
1411    ///
1412    /// Default impl walks `line(r)` for every row — slow but correct.
1413    /// Backends with cheaper paths (rope contiguous view) should override.
1414    fn content_joined(&self) -> std::sync::Arc<String> {
1415        let n = self.line_count() as usize;
1416        let mut acc = String::with_capacity(self.len_bytes());
1417        for r in 0..n {
1418            if r > 0 {
1419                acc.push('\n');
1420            }
1421            acc.push_str(&self.line(r as u32));
1422        }
1423        std::sync::Arc::new(acc)
1424    }
1425
1426    /// Byte length of `row`. Out-of-range rows return 0.
1427    ///
1428    /// Default impl pays a full `line(row)` clone just to read its length.
1429    /// Backends with row-indexed storage (canonical `hjkl_buffer::View`)
1430    /// should override to read the byte length under one lock with no
1431    /// allocation — `Editor::restore_text` calls this on every undo/redo
1432    /// to recompute the inverse `ContentEdit`.
1433    fn line_bytes(&self, row: usize) -> usize {
1434        let n = self.line_count() as usize;
1435        if row >= n {
1436            return 0;
1437        }
1438        self.line(row as u32).len()
1439    }
1440
1441    /// Return a cheaply-cloned rope snapshot of the buffer. O(1) for the
1442    /// canonical `hjkl_buffer::View` (Arc-backed B-tree clone). Used by
1443    /// the syntax pipeline's `parse_initial_rope` / `parse_incremental_rope`
1444    /// to stream bytes into tree-sitter without materializing a contiguous
1445    /// `String`.
1446    ///
1447    /// Default impl builds a rope from `content_joined()` — correct but
1448    /// O(N). Backends that own a rope internally should override.
1449    fn rope(&self) -> ropey::Rope {
1450        ropey::Rope::from_str(&self.content_joined())
1451    }
1452}
1453
1454/// Mutating sub-trait of [`View`]. Distinct trait name from the
1455/// crate-root [`Edit`] struct — this one carries methods, the other
1456/// is a value type.
1457pub trait BufferEdit: Send {
1458    /// Insert `text` at `pos`. Implementations clamp out-of-range
1459    /// positions to the document end.
1460    fn insert_at(&mut self, pos: Pos, text: &str);
1461    /// Delete the half-open `range`.
1462    fn delete_range(&mut self, range: core::ops::Range<Pos>);
1463    /// Replace the half-open `range` with `replacement`.
1464    fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str);
1465    /// Replace the entire buffer content with `text`. The cursor is
1466    /// clamped to the surviving content. Used by `:e!` / undo
1467    /// restore / snapshot replay where expressing "replace whole
1468    /// buffer" via [`replace_range`] would require knowing the end
1469    /// position. Default impl uses [`replace_range`] with a
1470    /// best-effort end (`u32::MAX` / `u32::MAX`); the canonical
1471    /// in-tree impl overrides it for a single-shot rebuild.
1472    fn replace_all(&mut self, text: &str) {
1473        self.replace_range(
1474            Pos::ORIGIN..Pos {
1475                line: u32::MAX,
1476                col: u32::MAX,
1477            },
1478            text,
1479        );
1480    }
1481}
1482
1483/// Search sub-trait of [`View`]. The pattern is owned by the engine;
1484/// buffers do not cache compiled regexes.
1485pub trait Search: Send {
1486    /// First match at-or-after `from`. `None` when no match remains.
1487    fn find_next(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1488    /// Last match at-or-before `from`.
1489    fn find_prev(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1490}
1491
1492/// View super-trait — the pre-1.0 contract every backend implements.
1493///
1494/// Sealed to the engine's own crate family (in-tree
1495/// `hjkl_buffer::View` is the canonical impl). Pre-0.1.0 the engine
1496/// reserves the right to add methods on patch bumps; downstream
1497/// consumers depend on the full trait without naming
1498/// [`sealed::Sealed`].
1499pub trait View: Cursor + Query + BufferEdit + Search + sealed::Sealed + Send {}
1500
1501/// Fold-iteration + mutation trait. The engine asks "what's the next
1502/// visible row" / "is this row hidden" through this surface, and
1503/// dispatches fold mutations through [`FoldProvider::apply`], so fold
1504/// storage can live wherever the host pleases (on the buffer, in a
1505/// separate host-side fold tree, or absent entirely).
1506///
1507/// Introduced in 0.0.32 (Patch C-β) for read access; 0.0.38 (Patch
1508/// C-δ.4) added [`FoldProvider::apply`] + [`FoldProvider::invalidate_range`]
1509/// so engine call sites that used to call
1510/// `hjkl_buffer::View::{open,close,toggle,…}_fold_at` directly route
1511/// through this trait now. The canonical read-only implementation
1512/// [`crate::buffer_impl::BufferFoldProvider`] wraps a
1513/// `&hjkl_buffer::View`; the canonical mutable implementation
1514/// [`crate::buffer_impl::BufferFoldProviderMut`] wraps a
1515/// `&mut hjkl_buffer::View`. Hosts that don't care about folds can
1516/// use [`NoopFoldProvider`].
1517///
1518/// The engine carries a `Box<dyn FoldProvider + 'a>` slot today and
1519/// looks up rows through it. Once `Editor<B, H>` flips generic
1520/// (Patch C, 0.1.0) the slot moves onto `Host` directly.
1521pub trait FoldProvider: Send {
1522    /// First visible row strictly after `row`, skipping hidden rows.
1523    /// `None` past the end of the buffer.
1524    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize>;
1525    /// First visible row strictly before `row`. `None` past the top.
1526    fn prev_visible_row(&self, row: usize) -> Option<usize>;
1527    /// Is `row` currently hidden by a closed fold?
1528    fn is_row_hidden(&self, row: usize) -> bool;
1529    /// Range `(start_row, end_row, closed)` of the fold containing
1530    /// `row`, if any. Lets `za` / `zo` / `zc` find their target
1531    /// without iterating the full fold list.
1532    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)>;
1533
1534    /// Apply a [`FoldOp`] to the underlying fold storage. Read-only
1535    /// providers (e.g. [`crate::buffer_impl::BufferFoldProvider`] which
1536    /// holds a `&View`) and providers that don't track folds (e.g.
1537    /// [`NoopFoldProvider`]) implement this as a no-op.
1538    ///
1539    /// Default impl is a no-op so that read-only / host-stub providers
1540    /// don't need to override it; mutable providers
1541    /// (e.g. [`crate::buffer_impl::BufferFoldProviderMut`]) override
1542    /// this to dispatch to the underlying buffer's fold methods.
1543    fn apply(&mut self, op: FoldOp) {
1544        let _ = op;
1545    }
1546
1547    /// Drop every fold whose range overlaps `[start_row, end_row]`.
1548    /// Edit pipelines call this after a user edit so vim's "edits
1549    /// inside a fold open it" behaviour fires. Default impl forwards
1550    /// to [`FoldProvider::apply`] with a [`FoldOp::Invalidate`].
1551    fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
1552        self.apply(FoldOp::Invalidate { start_row, end_row });
1553    }
1554}
1555
1556/// No-op [`FoldProvider`] for hosts that don't expose folds. Every
1557/// row is visible; `is_row_hidden` always returns `false`.
1558#[derive(Debug, Default, Clone, Copy)]
1559pub struct NoopFoldProvider;
1560
1561impl FoldProvider for NoopFoldProvider {
1562    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize> {
1563        let last = row_count.saturating_sub(1);
1564        if last == 0 && row == 0 {
1565            return None;
1566        }
1567        let r = row.checked_add(1)?;
1568        (r <= last).then_some(r)
1569    }
1570
1571    fn prev_visible_row(&self, row: usize) -> Option<usize> {
1572        row.checked_sub(1)
1573    }
1574
1575    fn is_row_hidden(&self, _row: usize) -> bool {
1576        false
1577    }
1578
1579    fn fold_at_row(&self, _row: usize) -> Option<(usize, usize, bool)> {
1580        None
1581    }
1582}
1583
1584/// Direction for insert-mode arrow movement.
1585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1586pub enum InsertDir {
1587    Left,
1588    Right,
1589    Up,
1590    Down,
1591}
1592
1593/// Scroll direction for `scroll_full_page`, `scroll_half_page`, and
1594/// `scroll_line` controller methods.
1595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1596pub enum ScrollDir {
1597    /// Move forward / downward (toward end of buffer).
1598    Down,
1599    /// Move backward / upward (toward start of buffer).
1600    Up,
1601}
1602
1603pub const SEARCH_HISTORY_MAX: usize = 100;
1604pub const CHANGE_LIST_MAX: usize = 100;
1605
1606/// Max jumplist depth. Matches vim default.
1607pub const JUMPLIST_MAX: usize = 100;
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612
1613    #[test]
1614    fn caret_is_empty() {
1615        let sel = Selection::caret(Pos::new(2, 4));
1616        assert!(sel.is_empty());
1617        assert_eq!(sel.anchor, sel.head);
1618    }
1619
1620    #[test]
1621    fn selection_set_default_has_one_caret() {
1622        let set = SelectionSet::default();
1623        assert_eq!(set.items.len(), 1);
1624        assert_eq!(set.primary, 0);
1625        assert_eq!(set.primary().anchor, Pos::ORIGIN);
1626    }
1627
1628    #[test]
1629    fn edit_constructors() {
1630        let p = Pos::new(0, 5);
1631        assert_eq!(Edit::insert(p, "x").range, p..p);
1632        assert!(Edit::insert(p, "x").replacement == "x");
1633        assert!(Edit::delete(p..p).replacement.is_empty());
1634    }
1635
1636    #[test]
1637    fn attrs_flags() {
1638        let a = Attrs::BOLD | Attrs::UNDERLINE;
1639        assert!(a.contains(Attrs::BOLD));
1640        assert!(!a.contains(Attrs::ITALIC));
1641    }
1642
1643    #[test]
1644    fn options_set_get_roundtrip() {
1645        let mut o = Options::default();
1646        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
1647        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
1648        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
1649        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
1650        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
1651            .unwrap();
1652        match o.get_by_name("iskeyword") {
1653            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
1654            other => panic!("expected String, got {other:?}"),
1655        }
1656    }
1657
1658    #[test]
1659    fn options_unknown_name_errors_on_set() {
1660        let mut o = Options::default();
1661        assert!(matches!(
1662            o.set_by_name("frobnicate", OptionValue::Int(1)),
1663            Err(EngineError::Ex(_))
1664        ));
1665        assert!(o.get_by_name("frobnicate").is_none());
1666    }
1667
1668    /// Security regression (CVE-2019-12735 class): vim's `:set makeprg=...`
1669    /// / `errorformat` can be abused from a modeline to run an arbitrary
1670    /// shell command on `:make`. `hjkl`'s modeline path
1671    /// (`hjkl_app::modeline`) only ever applies an option if
1672    /// `Options::set_by_name` accepts it first — so as long as `makeprg` /
1673    /// `errorformat` are never recognized names, a modeline can never set
1674    /// them, full stop. This pins that: if a future change ever adds these
1675    /// fields to `Options` without deliberately excluding them from the
1676    /// modeline-settable allowlist, this test catches it.
1677    #[test]
1678    fn set_by_name_rejects_makeprg_and_errorformat() {
1679        let mut o = Options::default();
1680        assert!(
1681            matches!(
1682                o.set_by_name("makeprg", OptionValue::String("rm -rf /".into())),
1683                Err(EngineError::Ex(_))
1684            ),
1685            "`makeprg` must never be a settable option — a modeline must \
1686             never be able to smuggle an arbitrary shell command into `:make`"
1687        );
1688        assert!(o.get_by_name("makeprg").is_none());
1689        assert!(
1690            matches!(
1691                o.set_by_name("errorformat", OptionValue::String("%f:%l:%m".into())),
1692                Err(EngineError::Ex(_))
1693            ),
1694            "`errorformat` rides the same `:make`/`:grep` shell-out surface \
1695             as `makeprg` and must stay unsettable too"
1696        );
1697        assert!(o.get_by_name("errorformat").is_none());
1698    }
1699
1700    #[test]
1701    fn options_type_mismatch_errors() {
1702        let mut o = Options::default();
1703        assert!(matches!(
1704            o.set_by_name("tabstop", OptionValue::String("nope".into())),
1705            Err(EngineError::Ex(_))
1706        ));
1707        assert!(matches!(
1708            o.set_by_name("iskeyword", OptionValue::Int(7)),
1709            Err(EngineError::Ex(_))
1710        ));
1711    }
1712
1713    /// Verify that `Options::default()` ships with the recommended vim
1714    /// settings: `ignorecase=true` and `smartcase=true`.
1715    #[test]
1716    fn default_options_ignorecase_and_smartcase_are_true() {
1717        let o = Options::default();
1718        assert!(o.ignorecase, "ignorecase must default to true");
1719        assert!(o.smartcase, "smartcase must default to true");
1720    }
1721
1722    #[test]
1723    fn options_int_to_bool_coercion() {
1724        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
1725        // Common vim spelling.
1726        let mut o = Options::default();
1727        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
1728        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
1729        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
1730        assert!(matches!(
1731            o.get_by_name("ic"),
1732            Some(OptionValue::Bool(false))
1733        ));
1734    }
1735
1736    #[test]
1737    fn options_wrap_linebreak_roundtrip() {
1738        let mut o = Options::default();
1739        assert_eq!(o.wrap, WrapMode::None);
1740        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
1741        assert_eq!(o.wrap, WrapMode::Char);
1742        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
1743        assert_eq!(o.wrap, WrapMode::Word);
1744        assert!(matches!(
1745            o.get_by_name("wrap"),
1746            Some(OptionValue::Bool(true))
1747        ));
1748        assert!(matches!(
1749            o.get_by_name("lbr"),
1750            Some(OptionValue::Bool(true))
1751        ));
1752        o.set_by_name("linebreak", OptionValue::Bool(false))
1753            .unwrap();
1754        assert_eq!(o.wrap, WrapMode::Char);
1755        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
1756        assert_eq!(o.wrap, WrapMode::None);
1757    }
1758
1759    #[test]
1760    fn options_default_modern() {
1761        // 0.2.0: defaults flipped from vim's tabstop=8/expandtab=off to
1762        // modern editor defaults (4-space soft tabs).
1763        let o = Options::default();
1764        assert_eq!(o.tabstop, 4);
1765        assert_eq!(o.shiftwidth, 4);
1766        assert_eq!(o.softtabstop, 4);
1767        assert!(o.expandtab);
1768        assert!(o.hlsearch);
1769        assert!(o.wrapscan);
1770        assert!(o.smartindent);
1771        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
1772    }
1773
1774    #[test]
1775    fn editor_snapshot_version_const() {
1776        assert_eq!(EditorSnapshot::VERSION, 5);
1777    }
1778
1779    #[test]
1780    fn editor_snapshot_default_shape() {
1781        let s = EditorSnapshot {
1782            version: EditorSnapshot::VERSION,
1783            mode: SnapshotMode::Normal,
1784            cursor: (0, 0),
1785            lines: vec!["hello".to_string()],
1786            viewport_top: 0,
1787            registers: crate::Registers::default(),
1788            marks: Default::default(),
1789            global_marks: Default::default(),
1790        };
1791        assert_eq!(s.cursor, (0, 0));
1792        assert_eq!(s.lines.len(), 1);
1793    }
1794
1795    #[test]
1796    fn engine_error_display() {
1797        let e = EngineError::ReadOnly;
1798        assert_eq!(e.to_string(), "buffer is read-only");
1799        let e = EngineError::OutOfBounds(Pos::new(3, 7));
1800        assert!(e.to_string().contains("out of bounds"));
1801    }
1802
1803    // ── New render-level options ─────────────────────────────────────────────
1804
1805    #[test]
1806    fn options_cursorline_roundtrip() {
1807        let mut o = Options::default();
1808        assert!(!o.cursorline, "cursorline defaults to false (vim parity)");
1809        o.set_by_name("cursorline", OptionValue::Bool(true))
1810            .unwrap();
1811        assert!(matches!(
1812            o.get_by_name("cul"),
1813            Some(OptionValue::Bool(true))
1814        ));
1815        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1816        assert!(matches!(
1817            o.get_by_name("cursorline"),
1818            Some(OptionValue::Bool(false))
1819        ));
1820    }
1821
1822    #[test]
1823    fn options_cursorcolumn_roundtrip() {
1824        let mut o = Options::default();
1825        assert!(!o.cursorcolumn, "cursorcolumn defaults to false");
1826        o.set_by_name("cuc", OptionValue::Bool(true)).unwrap();
1827        assert!(matches!(
1828            o.get_by_name("cursorcolumn"),
1829            Some(OptionValue::Bool(true))
1830        ));
1831    }
1832
1833    #[test]
1834    fn options_signcolumn_roundtrip() {
1835        let mut o = Options::default();
1836        assert_eq!(
1837            o.signcolumn,
1838            SignColumnMode::Auto,
1839            "signcolumn defaults to auto"
1840        );
1841        o.set_by_name("signcolumn", OptionValue::String("yes".into()))
1842            .unwrap();
1843        assert_eq!(o.signcolumn, SignColumnMode::Yes);
1844        assert_eq!(
1845            o.get_by_name("scl"),
1846            Some(OptionValue::String("yes".into()))
1847        );
1848        o.set_by_name("scl", OptionValue::String("no".into()))
1849            .unwrap();
1850        assert_eq!(o.signcolumn, SignColumnMode::No);
1851        o.set_by_name("scl", OptionValue::String("auto".into()))
1852            .unwrap();
1853        assert_eq!(o.signcolumn, SignColumnMode::Auto);
1854    }
1855
1856    #[test]
1857    fn options_signcolumn_rejects_invalid() {
1858        let mut o = Options::default();
1859        assert!(matches!(
1860            o.set_by_name("signcolumn", OptionValue::String("maybe".into())),
1861            Err(EngineError::Ex(_))
1862        ));
1863        // Type mismatch
1864        assert!(matches!(
1865            o.set_by_name("signcolumn", OptionValue::Bool(true)),
1866            Err(EngineError::Ex(_))
1867        ));
1868    }
1869
1870    #[test]
1871    fn options_foldcolumn_roundtrip() {
1872        let mut o = Options::default();
1873        assert_eq!(o.foldcolumn, 0, "foldcolumn defaults to 0");
1874        o.set_by_name("fdc", OptionValue::Int(3)).unwrap();
1875        assert_eq!(o.foldcolumn, 3);
1876        assert_eq!(o.get_by_name("foldcolumn"), Some(OptionValue::Int(3)));
1877    }
1878
1879    #[test]
1880    fn options_foldcolumn_rejects_out_of_range() {
1881        let mut o = Options::default();
1882        assert!(matches!(
1883            o.set_by_name("foldcolumn", OptionValue::Int(13)),
1884            Err(EngineError::Ex(_))
1885        ));
1886        assert!(matches!(
1887            o.set_by_name("foldcolumn", OptionValue::Int(-1)),
1888            Err(EngineError::Ex(_))
1889        ));
1890    }
1891
1892    #[test]
1893    fn options_colorcolumn_roundtrip() {
1894        let mut o = Options::default();
1895        assert_eq!(o.colorcolumn, "", "colorcolumn defaults to empty string");
1896        o.set_by_name("cc", OptionValue::String("80,120".into()))
1897            .unwrap();
1898        assert_eq!(
1899            o.get_by_name("colorcolumn"),
1900            Some(OptionValue::String("80,120".into()))
1901        );
1902        o.set_by_name("colorcolumn", OptionValue::String(String::new()))
1903            .unwrap();
1904        assert_eq!(
1905            o.get_by_name("cc"),
1906            Some(OptionValue::String(String::new()))
1907        );
1908    }
1909
1910    #[test]
1911    fn options_cursorline_alias_cul() {
1912        let mut o = Options::default();
1913        // `:set cul` — bare name turns bool on
1914        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1915        assert!(o.cursorline);
1916        // `:set nocul` → Bool(false)
1917        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1918        assert!(!o.cursorline);
1919    }
1920
1921    #[test]
1922    fn sign_column_mode_default_is_auto() {
1923        assert_eq!(SignColumnMode::default(), SignColumnMode::Auto);
1924    }
1925
1926    #[test]
1927    fn options_scrolloff_default_and_set() {
1928        let mut o = Options::default();
1929        assert_eq!(o.scrolloff, 5, "scrolloff defaults to 5");
1930        o.set_by_name("scrolloff", OptionValue::Int(0)).unwrap();
1931        assert_eq!(o.scrolloff, 0);
1932        o.set_by_name("scrolloff", OptionValue::Int(999)).unwrap();
1933        assert_eq!(o.scrolloff, 999);
1934        assert_eq!(o.get_by_name("scrolloff"), Some(OptionValue::Int(999)));
1935    }
1936
1937    #[test]
1938    fn options_sidescrolloff_default_and_set() {
1939        let mut o = Options::default();
1940        assert_eq!(o.sidescrolloff, 0, "sidescrolloff defaults to 0");
1941        o.set_by_name("sidescrolloff", OptionValue::Int(5)).unwrap();
1942        assert_eq!(o.sidescrolloff, 5);
1943        assert_eq!(o.get_by_name("sidescrolloff"), Some(OptionValue::Int(5)));
1944    }
1945
1946    #[test]
1947    fn options_alias_so_siso() {
1948        let mut o = Options::default();
1949        // `so` sets scrolloff
1950        o.set_by_name("so", OptionValue::Int(3)).unwrap();
1951        assert_eq!(o.scrolloff, 3);
1952        assert_eq!(o.get_by_name("so"), Some(OptionValue::Int(3)));
1953        // `siso` sets sidescrolloff
1954        o.set_by_name("siso", OptionValue::Int(2)).unwrap();
1955        assert_eq!(o.sidescrolloff, 2);
1956        assert_eq!(o.get_by_name("siso"), Some(OptionValue::Int(2)));
1957    }
1958
1959    // ---- list / listchars options -----------------------------------------------
1960
1961    #[test]
1962    fn options_list_default_false_and_set() {
1963        let mut o = Options::default();
1964        assert!(!o.list, "list default is false");
1965        o.set_by_name("list", OptionValue::Bool(true)).unwrap();
1966        assert!(o.list);
1967        assert_eq!(o.get_by_name("list"), Some(OptionValue::Bool(true)));
1968        o.set_by_name("list", OptionValue::Bool(false)).unwrap();
1969        assert!(!o.list);
1970    }
1971
1972    #[test]
1973    fn options_listchars_default_matches_vim() {
1974        let o = Options::default();
1975        let lc = &o.listchars;
1976        assert_eq!(lc.tab_lead, '^');
1977        assert_eq!(lc.tab_fill, Some('I'));
1978        assert_eq!(lc.eol, Some('$'));
1979        assert_eq!(lc.space, None);
1980        assert_eq!(lc.trail, None);
1981        assert_eq!(lc.nbsp, None);
1982    }
1983
1984    #[test]
1985    fn options_listchars_set_and_get() {
1986        let mut o = Options::default();
1987        o.set_by_name("listchars", OptionValue::String("tab:>-,eol:$".to_string()))
1988            .unwrap();
1989        assert_eq!(o.listchars.tab_lead, '>');
1990        assert_eq!(o.listchars.tab_fill, Some('-'));
1991        assert_eq!(o.listchars.eol, Some('$'));
1992    }
1993
1994    #[test]
1995    fn options_lcs_alias_sets_listchars() {
1996        let mut o = Options::default();
1997        o.set_by_name("lcs", OptionValue::String("tab:>-,trail:~".to_string()))
1998            .unwrap();
1999        assert_eq!(o.listchars.tab_lead, '>');
2000        assert_eq!(o.listchars.trail, Some('~'));
2001    }
2002
2003    #[test]
2004    fn options_listchars_get_by_name_returns_string() {
2005        let o = Options::default();
2006        match o.get_by_name("listchars") {
2007            Some(OptionValue::String(s)) => {
2008                assert!(s.contains("tab:"), "canonical string should contain tab:");
2009            }
2010            other => panic!("expected String, got {other:?}"),
2011        }
2012    }
2013
2014    #[test]
2015    fn options_listchars_invalid_value_returns_err() {
2016        let mut o = Options::default();
2017        assert!(
2018            o.set_by_name("listchars", OptionValue::String("bogus:x".to_string()))
2019                .is_err()
2020        );
2021    }
2022
2023    // ── indent_guides / indent_guide_char option tests ──────────────────────
2024
2025    #[test]
2026    fn indent_guides_default_true() {
2027        assert!(
2028            Options::default().indent_guides,
2029            "indent_guides must default to true"
2030        );
2031    }
2032
2033    #[test]
2034    fn options_indent_guides_set_and_get() {
2035        let mut opts = Options::default();
2036        // Disable via full name.
2037        opts.set_by_name("indent_guides", OptionValue::Bool(false))
2038            .unwrap();
2039        assert!(!opts.indent_guides);
2040        // Re-enable via alias.
2041        opts.set_by_name("ig", OptionValue::Bool(true)).unwrap();
2042        assert!(opts.indent_guides);
2043        // Read back via both names.
2044        assert_eq!(opts.get_by_name("ig"), Some(OptionValue::Bool(true)));
2045        assert_eq!(
2046            opts.get_by_name("indent_guides"),
2047            Some(OptionValue::Bool(true))
2048        );
2049    }
2050
2051    #[test]
2052    fn options_indent_guide_char_set_and_get() {
2053        let mut opts = Options::default();
2054        opts.set_by_name("indent_guide_char", OptionValue::String(":".to_string()))
2055            .unwrap();
2056        assert_eq!(opts.indent_guide_char, ':');
2057        // Alias.
2058        opts.set_by_name("igc", OptionValue::String("┊".to_string()))
2059            .unwrap();
2060        assert_eq!(opts.indent_guide_char, '┊');
2061        // Read back via alias.
2062        assert_eq!(
2063            opts.get_by_name("igc"),
2064            Some(OptionValue::String("┊".to_string()))
2065        );
2066        assert_eq!(
2067            opts.get_by_name("indent_guide_char"),
2068            Some(OptionValue::String("┊".to_string()))
2069        );
2070    }
2071
2072    #[test]
2073    fn options_indent_guide_char_rejects_multi_char() {
2074        let mut opts = Options::default();
2075        assert!(
2076            opts.set_by_name("indent_guide_char", OptionValue::String("ab".to_string()))
2077                .is_err(),
2078            "multi-char value must be rejected"
2079        );
2080    }
2081
2082    #[test]
2083    fn options_indent_guide_char_rejects_empty() {
2084        let mut opts = Options::default();
2085        assert!(
2086            opts.set_by_name("indent_guide_char", OptionValue::String(String::new()))
2087                .is_err(),
2088            "empty string must be rejected"
2089        );
2090    }
2091
2092    // ── colorizer option tests ───────────────────────────────────────────────
2093
2094    #[test]
2095    fn colorizer_default_true() {
2096        assert!(
2097            Options::default().colorizer,
2098            "colorizer must default to true"
2099        );
2100    }
2101
2102    #[test]
2103    fn colorizer_filetypes_includes_css() {
2104        let o = Options::default();
2105        assert!(
2106            o.colorizer_filetypes.iter().any(|f| f == "css"),
2107            "default colorizer_filetypes must include 'css'"
2108        );
2109    }
2110
2111    #[test]
2112    fn options_colorizer_set_and_get() {
2113        let mut o = Options::default();
2114        o.set_by_name("colorizer", OptionValue::Bool(false))
2115            .unwrap();
2116        assert_eq!(o.get_by_name("colorizer"), Some(OptionValue::Bool(false)));
2117        o.set_by_name("clz", OptionValue::Bool(true)).unwrap();
2118        assert_eq!(o.get_by_name("clz"), Some(OptionValue::Bool(true)));
2119    }
2120
2121    #[test]
2122    fn options_colorizer_filetypes_set_and_get() {
2123        let mut o = Options::default();
2124        o.set_by_name(
2125            "colorizer_filetypes",
2126            OptionValue::String("css,scss,toml".into()),
2127        )
2128        .unwrap();
2129        assert_eq!(o.colorizer_filetypes, vec!["css", "scss", "toml"]);
2130        assert_eq!(
2131            o.get_by_name("clzft"),
2132            Some(OptionValue::String("css,scss,toml".into()))
2133        );
2134    }
2135
2136    // ── format_on_save / trim_trailing_whitespace ─────────────────────────────
2137
2138    #[test]
2139    fn format_on_save_default_true() {
2140        let o = Options::default();
2141        assert!(o.format_on_save, "format_on_save must default to true");
2142    }
2143
2144    #[test]
2145    fn trim_trailing_whitespace_default_false() {
2146        let o = Options::default();
2147        assert!(
2148            !o.trim_trailing_whitespace,
2149            "trim_trailing_whitespace must default to false"
2150        );
2151    }
2152
2153    #[test]
2154    fn options_fos_alias_sets_format_on_save() {
2155        let mut o = Options::default();
2156        o.set_by_name("fos", OptionValue::Bool(true)).unwrap();
2157        assert!(o.format_on_save, "fos alias must set format_on_save");
2158        assert_eq!(
2159            o.get_by_name("fos"),
2160            Some(OptionValue::Bool(true)),
2161            "get_by_name(fos) must reflect the new value"
2162        );
2163        assert_eq!(
2164            o.get_by_name("format_on_save"),
2165            Some(OptionValue::Bool(true)),
2166            "get_by_name(format_on_save) must also reflect the new value"
2167        );
2168    }
2169
2170    #[test]
2171    fn options_tts_alias_sets_trim_trailing_whitespace() {
2172        let mut o = Options::default();
2173        o.set_by_name("tts", OptionValue::Bool(true)).unwrap();
2174        assert!(
2175            o.trim_trailing_whitespace,
2176            "tts alias must set trim_trailing_whitespace"
2177        );
2178        assert_eq!(
2179            o.get_by_name("tts"),
2180            Some(OptionValue::Bool(true)),
2181            "get_by_name(tts) must reflect the new value"
2182        );
2183        assert_eq!(
2184            o.get_by_name("trim_trailing_whitespace"),
2185            Some(OptionValue::Bool(true)),
2186            "get_by_name(trim_trailing_whitespace) must also reflect the new value"
2187        );
2188    }
2189
2190    // ── rainbow_brackets ──────────────────────────────────────────────────────
2191
2192    #[test]
2193    fn rainbow_brackets_default_true() {
2194        let o = Options::default();
2195        assert!(o.rainbow_brackets, "rainbow_brackets must default to true");
2196    }
2197
2198    #[test]
2199    fn options_rb_alias_sets_rainbow_brackets() {
2200        let mut o = Options::default();
2201        o.set_by_name("rb", OptionValue::Bool(false)).unwrap();
2202        assert!(
2203            !o.rainbow_brackets,
2204            "rb alias must set rainbow_brackets to false"
2205        );
2206        assert_eq!(
2207            o.get_by_name("rb"),
2208            Some(OptionValue::Bool(false)),
2209            "get_by_name(rb) must reflect the new value"
2210        );
2211        assert_eq!(
2212            o.get_by_name("rainbow_brackets"),
2213            Some(OptionValue::Bool(false)),
2214            "get_by_name(rainbow_brackets) must also reflect the new value"
2215        );
2216    }
2217
2218    #[test]
2219    fn autoreload_default_true() {
2220        assert!(
2221            Options::default().autoreload,
2222            "autoreload must default true"
2223        );
2224    }
2225
2226    #[test]
2227    fn options_ar_alias_sets_autoreload() {
2228        let mut o = Options::default();
2229        o.set_by_name("ar", OptionValue::Bool(false)).unwrap();
2230        assert!(!o.autoreload, "ar alias must set autoreload");
2231        assert_eq!(o.get_by_name("autoreload"), Some(OptionValue::Bool(false)));
2232    }
2233
2234    // ── updatetime ────────────────────────────────────────────────────────────
2235
2236    #[test]
2237    fn updatetime_default_4000() {
2238        let o = Options::default();
2239        assert_eq!(o.updatetime, 4000, "updatetime must default to 4000 ms");
2240        assert_eq!(
2241            o.get_by_name("updatetime"),
2242            Some(OptionValue::Int(4000)),
2243            "get_by_name(updatetime) must return Int(4000)"
2244        );
2245    }
2246
2247    #[test]
2248    fn options_ut_alias_sets_updatetime() {
2249        let mut o = Options::default();
2250        o.set_by_name("ut", OptionValue::Int(1000)).unwrap();
2251        assert_eq!(o.updatetime, 1000, "ut alias must set updatetime");
2252        assert_eq!(
2253            o.get_by_name("ut"),
2254            Some(OptionValue::Int(1000)),
2255            "get_by_name(ut) must reflect the new value"
2256        );
2257        assert_eq!(
2258            o.get_by_name("updatetime"),
2259            Some(OptionValue::Int(1000)),
2260            "get_by_name(updatetime) must also reflect the new value"
2261        );
2262    }
2263
2264    // ── matchparen ────────────────────────────────────────────────────────────
2265
2266    #[test]
2267    fn matchparen_default_true() {
2268        let o = Options::default();
2269        assert!(o.matchparen, "matchparen must default to true");
2270        assert_eq!(
2271            o.get_by_name("matchparen"),
2272            Some(OptionValue::Bool(true)),
2273            "get_by_name(matchparen) must return Bool(true)"
2274        );
2275    }
2276
2277    #[test]
2278    fn options_matchparen_set_and_get() {
2279        let mut o = Options::default();
2280        o.set_by_name("matchparen", OptionValue::Bool(false))
2281            .unwrap();
2282        assert!(!o.matchparen, "matchparen must be false after set");
2283        assert_eq!(
2284            o.get_by_name("matchparen"),
2285            Some(OptionValue::Bool(false)),
2286            "get_by_name(matchparen) must reflect false"
2287        );
2288        // Alias mps
2289        o.set_by_name("mps", OptionValue::Bool(true)).unwrap();
2290        assert!(o.matchparen, "mps alias must set matchparen to true");
2291        assert_eq!(
2292            o.get_by_name("mps"),
2293            Some(OptionValue::Bool(true)),
2294            "get_by_name(mps) must reflect true"
2295        );
2296    }
2297
2298    // ── fixendofline ──────────────────────────────────────────────────────────
2299
2300    /// vim's default is `fixendofline` ON — a missing final newline is added
2301    /// on write. Confirmed against nvim 0.12 (`"abc"` saves as `"abc\n"`).
2302    #[test]
2303    fn fixendofline_default_true() {
2304        let o = Options::default();
2305        assert!(o.fixendofline, "fixendofline must default to true");
2306        assert_eq!(
2307            o.get_by_name("fixendofline"),
2308            Some(OptionValue::Bool(true)),
2309            "get_by_name(fixendofline) must return Bool(true)"
2310        );
2311    }
2312
2313    #[test]
2314    fn options_fixendofline_set_and_get() {
2315        let mut o = Options::default();
2316        o.set_by_name("fixendofline", OptionValue::Bool(false))
2317            .unwrap();
2318        assert!(!o.fixendofline, "fixendofline must be false after set");
2319        assert_eq!(
2320            o.get_by_name("fixendofline"),
2321            Some(OptionValue::Bool(false)),
2322            "get_by_name(fixendofline) must reflect false"
2323        );
2324        // Alias fixeol
2325        o.set_by_name("fixeol", OptionValue::Bool(true)).unwrap();
2326        assert!(o.fixendofline, "fixeol alias must set fixendofline to true");
2327        assert_eq!(
2328            o.get_by_name("fixeol"),
2329            Some(OptionValue::Bool(true)),
2330            "get_by_name(fixeol) must reflect true"
2331        );
2332    }
2333
2334    // ── foldmethod / foldenable / foldlevelstart ──────────────────────────────
2335
2336    #[test]
2337    fn foldmethod_default_expr() {
2338        let o = Options::default();
2339        assert_eq!(
2340            o.foldmethod,
2341            FoldMethod::Expr,
2342            "foldmethod must default to Expr (tree-sitter)"
2343        );
2344        assert_eq!(
2345            o.get_by_name("foldmethod"),
2346            Some(OptionValue::String("expr".into())),
2347            "get_by_name(foldmethod) must return \"expr\""
2348        );
2349    }
2350
2351    #[test]
2352    fn foldmethod_fdm_alias_roundtrip() {
2353        let mut o = Options::default();
2354        o.set_by_name("fdm", OptionValue::String("manual".into()))
2355            .unwrap();
2356        assert_eq!(o.foldmethod, FoldMethod::Manual);
2357        assert_eq!(
2358            o.get_by_name("fdm"),
2359            Some(OptionValue::String("manual".into()))
2360        );
2361        o.set_by_name("foldmethod", OptionValue::String("expr".into()))
2362            .unwrap();
2363        assert_eq!(o.foldmethod, FoldMethod::Expr);
2364        o.set_by_name("foldmethod", OptionValue::String("marker".into()))
2365            .unwrap();
2366        assert_eq!(o.foldmethod, FoldMethod::Marker);
2367        // "syntax" is an alias for "expr"
2368        o.set_by_name("foldmethod", OptionValue::String("syntax".into()))
2369            .unwrap();
2370        assert_eq!(o.foldmethod, FoldMethod::Expr);
2371    }
2372
2373    #[test]
2374    fn foldmethod_rejects_invalid_value() {
2375        let mut o = Options::default();
2376        let err = o
2377            .set_by_name("foldmethod", OptionValue::String("bogus".into()))
2378            .unwrap_err();
2379        assert!(
2380            err.to_string().contains("must be"),
2381            "expected error about valid values, got: {err}"
2382        );
2383    }
2384
2385    #[test]
2386    fn foldenable_default_true() {
2387        let o = Options::default();
2388        assert!(o.foldenable, "foldenable must default to true");
2389        assert_eq!(
2390            o.get_by_name("foldenable"),
2391            Some(OptionValue::Bool(true)),
2392            "get_by_name(foldenable) must return Bool(true)"
2393        );
2394    }
2395
2396    #[test]
2397    fn foldenable_fen_alias_roundtrip() {
2398        let mut o = Options::default();
2399        o.set_by_name("fen", OptionValue::Bool(false)).unwrap();
2400        assert!(!o.foldenable, "fen alias must disable foldenable");
2401        assert_eq!(o.get_by_name("fen"), Some(OptionValue::Bool(false)));
2402        o.set_by_name("foldenable", OptionValue::Bool(true))
2403            .unwrap();
2404        assert!(o.foldenable);
2405    }
2406
2407    #[test]
2408    fn foldlevelstart_default_99() {
2409        let o = Options::default();
2410        assert_eq!(o.foldlevelstart, 99, "foldlevelstart must default to 99");
2411        assert_eq!(
2412            o.get_by_name("foldlevelstart"),
2413            Some(OptionValue::Int(99)),
2414            "get_by_name(foldlevelstart) must return Int(99)"
2415        );
2416    }
2417
2418    #[test]
2419    fn foldlevelstart_fls_alias_roundtrip() {
2420        let mut o = Options::default();
2421        o.set_by_name("fls", OptionValue::Int(0)).unwrap();
2422        assert_eq!(
2423            o.foldlevelstart, 0,
2424            "fls alias must set foldlevelstart to 0"
2425        );
2426        assert_eq!(o.get_by_name("fls"), Some(OptionValue::Int(0)));
2427        o.set_by_name("foldlevelstart", OptionValue::Int(5))
2428            .unwrap();
2429        assert_eq!(o.foldlevelstart, 5);
2430    }
2431}