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