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