Skip to main content

hjkl_engine/
types.rs

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