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