Skip to main content

hjkl_engine/
types.rs

1//! Core types for the engine trait surface.
2//!
3//! These are introduced alongside the legacy sqeel-vim public API. The
4//! trait extraction (phase 5) progressively rewires the existing FSM and
5//! Editor to operate on `Selection` / `SelectionSet` / `Edit` / `Pos`.
6//! Until that work lands, the legacy types in [`crate::editor`] and
7//! [`crate::vim`] remain authoritative.
8
9// `Pos`, `Edit` (as `EngineEdit`), `ContentEdit`, and `FoldOp` now live in
10// `hjkl-buffer` so `Content` 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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::Buffer`] 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 Buffer":
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: Buffer, 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::Buffer`] 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: Buffer, 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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    /// Buffer 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#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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::Buffer`] surface.
1309    /// Pre-1.0 the engine reserves the right to add methods to the
1310    /// `Buffer` super-trait without a major bump; downstream cannot
1311    /// `impl Buffer` from outside this family.
1312    ///
1313    /// The in-tree [`hjkl_buffer::Buffer`] 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 [`Buffer`].
1321///
1322/// `Pos` here is the engine's grapheme-indexed [`Pos`] type. Buffer
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 [`Buffer`].
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    /// `Buffer` 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::Buffer`)
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::Buffer` (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 [`Buffer`]. 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 [`Buffer`]. 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/// Buffer 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::Buffer` 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 Buffer: 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::Buffer::{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::Buffer`; the canonical mutable implementation
1502/// [`crate::buffer_impl::BufferFoldProviderMut`] wraps a
1503/// `&mut hjkl_buffer::Buffer`. 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 `&Buffer`) 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#[cfg(test)]
1573mod tests {
1574    use super::*;
1575
1576    #[test]
1577    fn caret_is_empty() {
1578        let sel = Selection::caret(Pos::new(2, 4));
1579        assert!(sel.is_empty());
1580        assert_eq!(sel.anchor, sel.head);
1581    }
1582
1583    #[test]
1584    fn selection_set_default_has_one_caret() {
1585        let set = SelectionSet::default();
1586        assert_eq!(set.items.len(), 1);
1587        assert_eq!(set.primary, 0);
1588        assert_eq!(set.primary().anchor, Pos::ORIGIN);
1589    }
1590
1591    #[test]
1592    fn edit_constructors() {
1593        let p = Pos::new(0, 5);
1594        assert_eq!(Edit::insert(p, "x").range, p..p);
1595        assert!(Edit::insert(p, "x").replacement == "x");
1596        assert!(Edit::delete(p..p).replacement.is_empty());
1597    }
1598
1599    #[test]
1600    fn attrs_flags() {
1601        let a = Attrs::BOLD | Attrs::UNDERLINE;
1602        assert!(a.contains(Attrs::BOLD));
1603        assert!(!a.contains(Attrs::ITALIC));
1604    }
1605
1606    #[test]
1607    fn options_set_get_roundtrip() {
1608        let mut o = Options::default();
1609        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
1610        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
1611        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
1612        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
1613        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
1614            .unwrap();
1615        match o.get_by_name("iskeyword") {
1616            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
1617            other => panic!("expected String, got {other:?}"),
1618        }
1619    }
1620
1621    #[test]
1622    fn options_unknown_name_errors_on_set() {
1623        let mut o = Options::default();
1624        assert!(matches!(
1625            o.set_by_name("frobnicate", OptionValue::Int(1)),
1626            Err(EngineError::Ex(_))
1627        ));
1628        assert!(o.get_by_name("frobnicate").is_none());
1629    }
1630
1631    #[test]
1632    fn options_type_mismatch_errors() {
1633        let mut o = Options::default();
1634        assert!(matches!(
1635            o.set_by_name("tabstop", OptionValue::String("nope".into())),
1636            Err(EngineError::Ex(_))
1637        ));
1638        assert!(matches!(
1639            o.set_by_name("iskeyword", OptionValue::Int(7)),
1640            Err(EngineError::Ex(_))
1641        ));
1642    }
1643
1644    /// Verify that `Options::default()` ships with the recommended vim
1645    /// settings: `ignorecase=true` and `smartcase=true`.
1646    #[test]
1647    fn default_options_ignorecase_and_smartcase_are_true() {
1648        let o = Options::default();
1649        assert!(o.ignorecase, "ignorecase must default to true");
1650        assert!(o.smartcase, "smartcase must default to true");
1651    }
1652
1653    #[test]
1654    fn options_int_to_bool_coercion() {
1655        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
1656        // Common vim spelling.
1657        let mut o = Options::default();
1658        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
1659        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
1660        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
1661        assert!(matches!(
1662            o.get_by_name("ic"),
1663            Some(OptionValue::Bool(false))
1664        ));
1665    }
1666
1667    #[test]
1668    fn options_wrap_linebreak_roundtrip() {
1669        let mut o = Options::default();
1670        assert_eq!(o.wrap, WrapMode::None);
1671        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
1672        assert_eq!(o.wrap, WrapMode::Char);
1673        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
1674        assert_eq!(o.wrap, WrapMode::Word);
1675        assert!(matches!(
1676            o.get_by_name("wrap"),
1677            Some(OptionValue::Bool(true))
1678        ));
1679        assert!(matches!(
1680            o.get_by_name("lbr"),
1681            Some(OptionValue::Bool(true))
1682        ));
1683        o.set_by_name("linebreak", OptionValue::Bool(false))
1684            .unwrap();
1685        assert_eq!(o.wrap, WrapMode::Char);
1686        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
1687        assert_eq!(o.wrap, WrapMode::None);
1688    }
1689
1690    #[test]
1691    fn options_default_modern() {
1692        // 0.2.0: defaults flipped from vim's tabstop=8/expandtab=off to
1693        // modern editor defaults (4-space soft tabs).
1694        let o = Options::default();
1695        assert_eq!(o.tabstop, 4);
1696        assert_eq!(o.shiftwidth, 4);
1697        assert_eq!(o.softtabstop, 4);
1698        assert!(o.expandtab);
1699        assert!(o.hlsearch);
1700        assert!(o.wrapscan);
1701        assert!(o.smartindent);
1702        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
1703    }
1704
1705    #[test]
1706    fn editor_snapshot_version_const() {
1707        assert_eq!(EditorSnapshot::VERSION, 5);
1708    }
1709
1710    #[test]
1711    fn editor_snapshot_default_shape() {
1712        let s = EditorSnapshot {
1713            version: EditorSnapshot::VERSION,
1714            mode: SnapshotMode::Normal,
1715            cursor: (0, 0),
1716            lines: vec!["hello".to_string()],
1717            viewport_top: 0,
1718            registers: crate::Registers::default(),
1719            marks: Default::default(),
1720            global_marks: Default::default(),
1721        };
1722        assert_eq!(s.cursor, (0, 0));
1723        assert_eq!(s.lines.len(), 1);
1724    }
1725
1726    #[cfg(feature = "serde")]
1727    #[test]
1728    fn editor_snapshot_roundtrip() {
1729        let mut marks = std::collections::BTreeMap::new();
1730        marks.insert('a', (1u32, 0u32));
1731        let mut global_marks = std::collections::BTreeMap::new();
1732        global_marks.insert('A', (42u64, 5u32, 2u32));
1733        let s = EditorSnapshot {
1734            version: EditorSnapshot::VERSION,
1735            mode: SnapshotMode::Insert,
1736            cursor: (3, 7),
1737            lines: vec!["alpha".into(), "beta".into()],
1738            viewport_top: 2,
1739            registers: crate::Registers::default(),
1740            marks,
1741            global_marks,
1742        };
1743        let json = serde_json::to_string(&s).unwrap();
1744        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
1745        assert_eq!(s.cursor, back.cursor);
1746        assert_eq!(s.lines, back.lines);
1747        assert_eq!(s.viewport_top, back.viewport_top);
1748        assert_eq!(s.global_marks, back.global_marks);
1749    }
1750
1751    #[test]
1752    fn engine_error_display() {
1753        let e = EngineError::ReadOnly;
1754        assert_eq!(e.to_string(), "buffer is read-only");
1755        let e = EngineError::OutOfBounds(Pos::new(3, 7));
1756        assert!(e.to_string().contains("out of bounds"));
1757    }
1758
1759    // ── New render-level options ─────────────────────────────────────────────
1760
1761    #[test]
1762    fn options_cursorline_roundtrip() {
1763        let mut o = Options::default();
1764        assert!(o.cursorline, "cursorline defaults to true");
1765        o.set_by_name("cursorline", OptionValue::Bool(false))
1766            .unwrap();
1767        assert!(matches!(
1768            o.get_by_name("cul"),
1769            Some(OptionValue::Bool(false))
1770        ));
1771        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1772        assert!(matches!(
1773            o.get_by_name("cursorline"),
1774            Some(OptionValue::Bool(true))
1775        ));
1776    }
1777
1778    #[test]
1779    fn options_cursorcolumn_roundtrip() {
1780        let mut o = Options::default();
1781        assert!(!o.cursorcolumn, "cursorcolumn defaults to false");
1782        o.set_by_name("cuc", OptionValue::Bool(true)).unwrap();
1783        assert!(matches!(
1784            o.get_by_name("cursorcolumn"),
1785            Some(OptionValue::Bool(true))
1786        ));
1787    }
1788
1789    #[test]
1790    fn options_signcolumn_roundtrip() {
1791        let mut o = Options::default();
1792        assert_eq!(
1793            o.signcolumn,
1794            SignColumnMode::Auto,
1795            "signcolumn defaults to auto"
1796        );
1797        o.set_by_name("signcolumn", OptionValue::String("yes".into()))
1798            .unwrap();
1799        assert_eq!(o.signcolumn, SignColumnMode::Yes);
1800        assert_eq!(
1801            o.get_by_name("scl"),
1802            Some(OptionValue::String("yes".into()))
1803        );
1804        o.set_by_name("scl", OptionValue::String("no".into()))
1805            .unwrap();
1806        assert_eq!(o.signcolumn, SignColumnMode::No);
1807        o.set_by_name("scl", OptionValue::String("auto".into()))
1808            .unwrap();
1809        assert_eq!(o.signcolumn, SignColumnMode::Auto);
1810    }
1811
1812    #[test]
1813    fn options_signcolumn_rejects_invalid() {
1814        let mut o = Options::default();
1815        assert!(matches!(
1816            o.set_by_name("signcolumn", OptionValue::String("maybe".into())),
1817            Err(EngineError::Ex(_))
1818        ));
1819        // Type mismatch
1820        assert!(matches!(
1821            o.set_by_name("signcolumn", OptionValue::Bool(true)),
1822            Err(EngineError::Ex(_))
1823        ));
1824    }
1825
1826    #[test]
1827    fn options_foldcolumn_roundtrip() {
1828        let mut o = Options::default();
1829        assert_eq!(o.foldcolumn, 0, "foldcolumn defaults to 0");
1830        o.set_by_name("fdc", OptionValue::Int(3)).unwrap();
1831        assert_eq!(o.foldcolumn, 3);
1832        assert_eq!(o.get_by_name("foldcolumn"), Some(OptionValue::Int(3)));
1833    }
1834
1835    #[test]
1836    fn options_foldcolumn_rejects_out_of_range() {
1837        let mut o = Options::default();
1838        assert!(matches!(
1839            o.set_by_name("foldcolumn", OptionValue::Int(13)),
1840            Err(EngineError::Ex(_))
1841        ));
1842        assert!(matches!(
1843            o.set_by_name("foldcolumn", OptionValue::Int(-1)),
1844            Err(EngineError::Ex(_))
1845        ));
1846    }
1847
1848    #[test]
1849    fn options_colorcolumn_roundtrip() {
1850        let mut o = Options::default();
1851        assert_eq!(o.colorcolumn, "", "colorcolumn defaults to empty string");
1852        o.set_by_name("cc", OptionValue::String("80,120".into()))
1853            .unwrap();
1854        assert_eq!(
1855            o.get_by_name("colorcolumn"),
1856            Some(OptionValue::String("80,120".into()))
1857        );
1858        o.set_by_name("colorcolumn", OptionValue::String(String::new()))
1859            .unwrap();
1860        assert_eq!(
1861            o.get_by_name("cc"),
1862            Some(OptionValue::String(String::new()))
1863        );
1864    }
1865
1866    #[test]
1867    fn options_cursorline_alias_cul() {
1868        let mut o = Options::default();
1869        // `:set cul` — bare name turns bool on
1870        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1871        assert!(o.cursorline);
1872        // `:set nocul` → Bool(false)
1873        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1874        assert!(!o.cursorline);
1875    }
1876
1877    #[test]
1878    fn sign_column_mode_default_is_auto() {
1879        assert_eq!(SignColumnMode::default(), SignColumnMode::Auto);
1880    }
1881
1882    #[test]
1883    fn options_scrolloff_default_and_set() {
1884        let mut o = Options::default();
1885        assert_eq!(o.scrolloff, 5, "scrolloff defaults to 5");
1886        o.set_by_name("scrolloff", OptionValue::Int(0)).unwrap();
1887        assert_eq!(o.scrolloff, 0);
1888        o.set_by_name("scrolloff", OptionValue::Int(999)).unwrap();
1889        assert_eq!(o.scrolloff, 999);
1890        assert_eq!(o.get_by_name("scrolloff"), Some(OptionValue::Int(999)));
1891    }
1892
1893    #[test]
1894    fn options_sidescrolloff_default_and_set() {
1895        let mut o = Options::default();
1896        assert_eq!(o.sidescrolloff, 0, "sidescrolloff defaults to 0");
1897        o.set_by_name("sidescrolloff", OptionValue::Int(5)).unwrap();
1898        assert_eq!(o.sidescrolloff, 5);
1899        assert_eq!(o.get_by_name("sidescrolloff"), Some(OptionValue::Int(5)));
1900    }
1901
1902    #[test]
1903    fn options_alias_so_siso() {
1904        let mut o = Options::default();
1905        // `so` sets scrolloff
1906        o.set_by_name("so", OptionValue::Int(3)).unwrap();
1907        assert_eq!(o.scrolloff, 3);
1908        assert_eq!(o.get_by_name("so"), Some(OptionValue::Int(3)));
1909        // `siso` sets sidescrolloff
1910        o.set_by_name("siso", OptionValue::Int(2)).unwrap();
1911        assert_eq!(o.sidescrolloff, 2);
1912        assert_eq!(o.get_by_name("siso"), Some(OptionValue::Int(2)));
1913    }
1914
1915    // ---- list / listchars options -----------------------------------------------
1916
1917    #[test]
1918    fn options_list_default_false_and_set() {
1919        let mut o = Options::default();
1920        assert!(!o.list, "list default is false");
1921        o.set_by_name("list", OptionValue::Bool(true)).unwrap();
1922        assert!(o.list);
1923        assert_eq!(o.get_by_name("list"), Some(OptionValue::Bool(true)));
1924        o.set_by_name("list", OptionValue::Bool(false)).unwrap();
1925        assert!(!o.list);
1926    }
1927
1928    #[test]
1929    fn options_listchars_default_matches_vim() {
1930        let o = Options::default();
1931        let lc = &o.listchars;
1932        assert_eq!(lc.tab_lead, '^');
1933        assert_eq!(lc.tab_fill, Some('I'));
1934        assert_eq!(lc.eol, Some('$'));
1935        assert_eq!(lc.space, None);
1936        assert_eq!(lc.trail, None);
1937        assert_eq!(lc.nbsp, None);
1938    }
1939
1940    #[test]
1941    fn options_listchars_set_and_get() {
1942        let mut o = Options::default();
1943        o.set_by_name("listchars", OptionValue::String("tab:>-,eol:$".to_string()))
1944            .unwrap();
1945        assert_eq!(o.listchars.tab_lead, '>');
1946        assert_eq!(o.listchars.tab_fill, Some('-'));
1947        assert_eq!(o.listchars.eol, Some('$'));
1948    }
1949
1950    #[test]
1951    fn options_lcs_alias_sets_listchars() {
1952        let mut o = Options::default();
1953        o.set_by_name("lcs", OptionValue::String("tab:>-,trail:~".to_string()))
1954            .unwrap();
1955        assert_eq!(o.listchars.tab_lead, '>');
1956        assert_eq!(o.listchars.trail, Some('~'));
1957    }
1958
1959    #[test]
1960    fn options_listchars_get_by_name_returns_string() {
1961        let o = Options::default();
1962        match o.get_by_name("listchars") {
1963            Some(OptionValue::String(s)) => {
1964                assert!(s.contains("tab:"), "canonical string should contain tab:");
1965            }
1966            other => panic!("expected String, got {other:?}"),
1967        }
1968    }
1969
1970    #[test]
1971    fn options_listchars_invalid_value_returns_err() {
1972        let mut o = Options::default();
1973        assert!(
1974            o.set_by_name("listchars", OptionValue::String("bogus:x".to_string()))
1975                .is_err()
1976        );
1977    }
1978
1979    // ── indent_guides / indent_guide_char option tests ──────────────────────
1980
1981    #[test]
1982    fn indent_guides_default_true() {
1983        assert!(
1984            Options::default().indent_guides,
1985            "indent_guides must default to true"
1986        );
1987    }
1988
1989    #[test]
1990    fn options_indent_guides_set_and_get() {
1991        let mut opts = Options::default();
1992        // Disable via full name.
1993        opts.set_by_name("indent_guides", OptionValue::Bool(false))
1994            .unwrap();
1995        assert!(!opts.indent_guides);
1996        // Re-enable via alias.
1997        opts.set_by_name("ig", OptionValue::Bool(true)).unwrap();
1998        assert!(opts.indent_guides);
1999        // Read back via both names.
2000        assert_eq!(opts.get_by_name("ig"), Some(OptionValue::Bool(true)));
2001        assert_eq!(
2002            opts.get_by_name("indent_guides"),
2003            Some(OptionValue::Bool(true))
2004        );
2005    }
2006
2007    #[test]
2008    fn options_indent_guide_char_set_and_get() {
2009        let mut opts = Options::default();
2010        opts.set_by_name("indent_guide_char", OptionValue::String(":".to_string()))
2011            .unwrap();
2012        assert_eq!(opts.indent_guide_char, ':');
2013        // Alias.
2014        opts.set_by_name("igc", OptionValue::String("┊".to_string()))
2015            .unwrap();
2016        assert_eq!(opts.indent_guide_char, '┊');
2017        // Read back via alias.
2018        assert_eq!(
2019            opts.get_by_name("igc"),
2020            Some(OptionValue::String("┊".to_string()))
2021        );
2022        assert_eq!(
2023            opts.get_by_name("indent_guide_char"),
2024            Some(OptionValue::String("┊".to_string()))
2025        );
2026    }
2027
2028    #[test]
2029    fn options_indent_guide_char_rejects_multi_char() {
2030        let mut opts = Options::default();
2031        assert!(
2032            opts.set_by_name("indent_guide_char", OptionValue::String("ab".to_string()))
2033                .is_err(),
2034            "multi-char value must be rejected"
2035        );
2036    }
2037
2038    #[test]
2039    fn options_indent_guide_char_rejects_empty() {
2040        let mut opts = Options::default();
2041        assert!(
2042            opts.set_by_name("indent_guide_char", OptionValue::String(String::new()))
2043                .is_err(),
2044            "empty string must be rejected"
2045        );
2046    }
2047
2048    // ── colorizer option tests ───────────────────────────────────────────────
2049
2050    #[test]
2051    fn colorizer_default_true() {
2052        assert!(
2053            Options::default().colorizer,
2054            "colorizer must default to true"
2055        );
2056    }
2057
2058    #[test]
2059    fn colorizer_filetypes_includes_css() {
2060        let o = Options::default();
2061        assert!(
2062            o.colorizer_filetypes.iter().any(|f| f == "css"),
2063            "default colorizer_filetypes must include 'css'"
2064        );
2065    }
2066
2067    #[test]
2068    fn options_colorizer_set_and_get() {
2069        let mut o = Options::default();
2070        o.set_by_name("colorizer", OptionValue::Bool(false))
2071            .unwrap();
2072        assert_eq!(o.get_by_name("colorizer"), Some(OptionValue::Bool(false)));
2073        o.set_by_name("clz", OptionValue::Bool(true)).unwrap();
2074        assert_eq!(o.get_by_name("clz"), Some(OptionValue::Bool(true)));
2075    }
2076
2077    #[test]
2078    fn options_colorizer_filetypes_set_and_get() {
2079        let mut o = Options::default();
2080        o.set_by_name(
2081            "colorizer_filetypes",
2082            OptionValue::String("css,scss,toml".into()),
2083        )
2084        .unwrap();
2085        assert_eq!(o.colorizer_filetypes, vec!["css", "scss", "toml"]);
2086        assert_eq!(
2087            o.get_by_name("clzft"),
2088            Some(OptionValue::String("css,scss,toml".into()))
2089        );
2090    }
2091
2092    // ── format_on_save / trim_trailing_whitespace ─────────────────────────────
2093
2094    #[test]
2095    fn format_on_save_default_true() {
2096        let o = Options::default();
2097        assert!(o.format_on_save, "format_on_save must default to true");
2098    }
2099
2100    #[test]
2101    fn trim_trailing_whitespace_default_false() {
2102        let o = Options::default();
2103        assert!(
2104            !o.trim_trailing_whitespace,
2105            "trim_trailing_whitespace must default to false"
2106        );
2107    }
2108
2109    #[test]
2110    fn options_fos_alias_sets_format_on_save() {
2111        let mut o = Options::default();
2112        o.set_by_name("fos", OptionValue::Bool(true)).unwrap();
2113        assert!(o.format_on_save, "fos alias must set format_on_save");
2114        assert_eq!(
2115            o.get_by_name("fos"),
2116            Some(OptionValue::Bool(true)),
2117            "get_by_name(fos) must reflect the new value"
2118        );
2119        assert_eq!(
2120            o.get_by_name("format_on_save"),
2121            Some(OptionValue::Bool(true)),
2122            "get_by_name(format_on_save) must also reflect the new value"
2123        );
2124    }
2125
2126    #[test]
2127    fn options_tts_alias_sets_trim_trailing_whitespace() {
2128        let mut o = Options::default();
2129        o.set_by_name("tts", OptionValue::Bool(true)).unwrap();
2130        assert!(
2131            o.trim_trailing_whitespace,
2132            "tts alias must set trim_trailing_whitespace"
2133        );
2134        assert_eq!(
2135            o.get_by_name("tts"),
2136            Some(OptionValue::Bool(true)),
2137            "get_by_name(tts) must reflect the new value"
2138        );
2139        assert_eq!(
2140            o.get_by_name("trim_trailing_whitespace"),
2141            Some(OptionValue::Bool(true)),
2142            "get_by_name(trim_trailing_whitespace) must also reflect the new value"
2143        );
2144    }
2145
2146    // ── rainbow_brackets ──────────────────────────────────────────────────────
2147
2148    #[test]
2149    fn rainbow_brackets_default_true() {
2150        let o = Options::default();
2151        assert!(o.rainbow_brackets, "rainbow_brackets must default to true");
2152    }
2153
2154    #[test]
2155    fn options_rb_alias_sets_rainbow_brackets() {
2156        let mut o = Options::default();
2157        o.set_by_name("rb", OptionValue::Bool(false)).unwrap();
2158        assert!(
2159            !o.rainbow_brackets,
2160            "rb alias must set rainbow_brackets to false"
2161        );
2162        assert_eq!(
2163            o.get_by_name("rb"),
2164            Some(OptionValue::Bool(false)),
2165            "get_by_name(rb) must reflect the new value"
2166        );
2167        assert_eq!(
2168            o.get_by_name("rainbow_brackets"),
2169            Some(OptionValue::Bool(false)),
2170            "get_by_name(rainbow_brackets) must also reflect the new value"
2171        );
2172    }
2173
2174    #[test]
2175    fn autoreload_default_true() {
2176        assert!(
2177            Options::default().autoreload,
2178            "autoreload must default true"
2179        );
2180    }
2181
2182    #[test]
2183    fn options_ar_alias_sets_autoreload() {
2184        let mut o = Options::default();
2185        o.set_by_name("ar", OptionValue::Bool(false)).unwrap();
2186        assert!(!o.autoreload, "ar alias must set autoreload");
2187        assert_eq!(o.get_by_name("autoreload"), Some(OptionValue::Bool(false)));
2188    }
2189
2190    // ── updatetime ────────────────────────────────────────────────────────────
2191
2192    #[test]
2193    fn updatetime_default_4000() {
2194        let o = Options::default();
2195        assert_eq!(o.updatetime, 4000, "updatetime must default to 4000 ms");
2196        assert_eq!(
2197            o.get_by_name("updatetime"),
2198            Some(OptionValue::Int(4000)),
2199            "get_by_name(updatetime) must return Int(4000)"
2200        );
2201    }
2202
2203    #[test]
2204    fn options_ut_alias_sets_updatetime() {
2205        let mut o = Options::default();
2206        o.set_by_name("ut", OptionValue::Int(1000)).unwrap();
2207        assert_eq!(o.updatetime, 1000, "ut alias must set updatetime");
2208        assert_eq!(
2209            o.get_by_name("ut"),
2210            Some(OptionValue::Int(1000)),
2211            "get_by_name(ut) must reflect the new value"
2212        );
2213        assert_eq!(
2214            o.get_by_name("updatetime"),
2215            Some(OptionValue::Int(1000)),
2216            "get_by_name(updatetime) must also reflect the new value"
2217        );
2218    }
2219
2220    // ── matchparen ────────────────────────────────────────────────────────────
2221
2222    #[test]
2223    fn matchparen_default_true() {
2224        let o = Options::default();
2225        assert!(o.matchparen, "matchparen must default to true");
2226        assert_eq!(
2227            o.get_by_name("matchparen"),
2228            Some(OptionValue::Bool(true)),
2229            "get_by_name(matchparen) must return Bool(true)"
2230        );
2231    }
2232
2233    #[test]
2234    fn options_matchparen_set_and_get() {
2235        let mut o = Options::default();
2236        o.set_by_name("matchparen", OptionValue::Bool(false))
2237            .unwrap();
2238        assert!(!o.matchparen, "matchparen must be false after set");
2239        assert_eq!(
2240            o.get_by_name("matchparen"),
2241            Some(OptionValue::Bool(false)),
2242            "get_by_name(matchparen) must reflect false"
2243        );
2244        // Alias mps
2245        o.set_by_name("mps", OptionValue::Bool(true)).unwrap();
2246        assert!(o.matchparen, "mps alias must set matchparen to true");
2247        assert_eq!(
2248            o.get_by_name("mps"),
2249            Some(OptionValue::Bool(true)),
2250            "get_by_name(mps) must reflect true"
2251        );
2252    }
2253
2254    // ── foldmethod / foldenable / foldlevelstart ──────────────────────────────
2255
2256    #[test]
2257    fn foldmethod_default_expr() {
2258        let o = Options::default();
2259        assert_eq!(
2260            o.foldmethod,
2261            FoldMethod::Expr,
2262            "foldmethod must default to Expr (tree-sitter)"
2263        );
2264        assert_eq!(
2265            o.get_by_name("foldmethod"),
2266            Some(OptionValue::String("expr".into())),
2267            "get_by_name(foldmethod) must return \"expr\""
2268        );
2269    }
2270
2271    #[test]
2272    fn foldmethod_fdm_alias_roundtrip() {
2273        let mut o = Options::default();
2274        o.set_by_name("fdm", OptionValue::String("manual".into()))
2275            .unwrap();
2276        assert_eq!(o.foldmethod, FoldMethod::Manual);
2277        assert_eq!(
2278            o.get_by_name("fdm"),
2279            Some(OptionValue::String("manual".into()))
2280        );
2281        o.set_by_name("foldmethod", OptionValue::String("expr".into()))
2282            .unwrap();
2283        assert_eq!(o.foldmethod, FoldMethod::Expr);
2284        o.set_by_name("foldmethod", OptionValue::String("marker".into()))
2285            .unwrap();
2286        assert_eq!(o.foldmethod, FoldMethod::Marker);
2287        // "syntax" is an alias for "expr"
2288        o.set_by_name("foldmethod", OptionValue::String("syntax".into()))
2289            .unwrap();
2290        assert_eq!(o.foldmethod, FoldMethod::Expr);
2291    }
2292
2293    #[test]
2294    fn foldmethod_rejects_invalid_value() {
2295        let mut o = Options::default();
2296        let err = o
2297            .set_by_name("foldmethod", OptionValue::String("bogus".into()))
2298            .unwrap_err();
2299        assert!(
2300            err.to_string().contains("must be"),
2301            "expected error about valid values, got: {err}"
2302        );
2303    }
2304
2305    #[test]
2306    fn foldenable_default_true() {
2307        let o = Options::default();
2308        assert!(o.foldenable, "foldenable must default to true");
2309        assert_eq!(
2310            o.get_by_name("foldenable"),
2311            Some(OptionValue::Bool(true)),
2312            "get_by_name(foldenable) must return Bool(true)"
2313        );
2314    }
2315
2316    #[test]
2317    fn foldenable_fen_alias_roundtrip() {
2318        let mut o = Options::default();
2319        o.set_by_name("fen", OptionValue::Bool(false)).unwrap();
2320        assert!(!o.foldenable, "fen alias must disable foldenable");
2321        assert_eq!(o.get_by_name("fen"), Some(OptionValue::Bool(false)));
2322        o.set_by_name("foldenable", OptionValue::Bool(true))
2323            .unwrap();
2324        assert!(o.foldenable);
2325    }
2326
2327    #[test]
2328    fn foldlevelstart_default_99() {
2329        let o = Options::default();
2330        assert_eq!(o.foldlevelstart, 99, "foldlevelstart must default to 99");
2331        assert_eq!(
2332            o.get_by_name("foldlevelstart"),
2333            Some(OptionValue::Int(99)),
2334            "get_by_name(foldlevelstart) must return Int(99)"
2335        );
2336    }
2337
2338    #[test]
2339    fn foldlevelstart_fls_alias_roundtrip() {
2340        let mut o = Options::default();
2341        o.set_by_name("fls", OptionValue::Int(0)).unwrap();
2342        assert_eq!(
2343            o.foldlevelstart, 0,
2344            "fls alias must set foldlevelstart to 0"
2345        );
2346        assert_eq!(o.get_by_name("fls"), Some(OptionValue::Int(0)));
2347        o.set_by_name("foldlevelstart", OptionValue::Int(5))
2348            .unwrap();
2349        assert_eq!(o.foldlevelstart, 5);
2350    }
2351}