Skip to main content

hjkl_engine/
types.rs

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