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