Skip to main content

hjkl_engine/
types.rs

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