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}
322
323/// Sign-column display mode. Controls whether a 1-cell gutter is reserved
324/// for diagnostic and git signs. Matches vim's `:set signcolumn`.
325#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
326#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
327pub enum SignColumnMode {
328    /// Never reserve a sign column.
329    No,
330    /// Always reserve a sign column.
331    Yes,
332    /// Reserve only when at least one sign is visible (default).
333    #[default]
334    Auto,
335}
336
337/// Soft-wrap mode for the renderer + scroll math + `gj` / `gk`.
338/// Engine-native equivalent of [`hjkl_buffer::Wrap`]; the engine
339/// converts at the boundary to the buffer's runtime wrap setting.
340#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
341#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
342pub enum WrapMode {
343    /// Long lines extend past the right edge; `top_col` clips the
344    /// left side. Matches vim's `:set nowrap`.
345    #[default]
346    None,
347    /// Break at the cell boundary regardless of word edges. Matches
348    /// `:set wrap`.
349    Char,
350    /// Break at the last whitespace inside the visible width when
351    /// possible; falls back to a char break for runs longer than the
352    /// width. Matches `:set linebreak`.
353    Word,
354}
355
356/// Typed value for [`Options::set_by_name`] / [`Options::get_by_name`].
357///
358/// `:set tabstop=4` parses as `OptionValue::Int(4)`;
359/// `:set noexpandtab` parses as `OptionValue::Bool(false)`;
360/// `:set iskeyword=...` as `OptionValue::String(...)`.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum OptionValue {
363    Bool(bool),
364    Int(i64),
365    String(String),
366}
367
368impl Default for Options {
369    fn default() -> Self {
370        Options {
371            tabstop: 4,
372            shiftwidth: 4,
373            expandtab: true,
374            softtabstop: 4,
375            iskeyword: "@,48-57,_,192-255".to_string(),
376            ignorecase: false,
377            smartcase: false,
378            hlsearch: true,
379            incsearch: true,
380            wrapscan: true,
381            autoindent: true,
382            smartindent: true,
383            timeout_len: core::time::Duration::from_millis(1000),
384            undo_levels: 1000,
385            undo_break_on_motion: true,
386            readonly: false,
387            wrap: WrapMode::None,
388            textwidth: 79,
389            number: true,
390            relativenumber: false,
391            numberwidth: 4,
392            cursorline: true,
393            cursorcolumn: false,
394            signcolumn: SignColumnMode::Auto,
395            foldcolumn: 0,
396            colorcolumn: String::new(),
397            formatoptions: "ro".to_string(),
398            filetype: String::new(),
399        }
400    }
401}
402
403impl Options {
404    /// Set an option by name. Vim-flavored option naming. Returns
405    /// [`EngineError::Ex`] for unknown names or type-mismatched values.
406    ///
407    /// Booleans accept `OptionValue::Bool(_)` directly or
408    /// `OptionValue::Int(0)`/`Int(non_zero)`. Integers accept only
409    /// `Int(_)`. Strings accept only `String(_)`.
410    pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
411        macro_rules! set_bool {
412            ($field:ident) => {{
413                self.$field = match val {
414                    OptionValue::Bool(b) => b,
415                    OptionValue::Int(n) => n != 0,
416                    other => {
417                        return Err(EngineError::Ex(format!(
418                            "option `{name}` expects bool, got {other:?}"
419                        )));
420                    }
421                };
422                Ok(())
423            }};
424        }
425        macro_rules! set_u32 {
426            ($field:ident) => {{
427                self.$field = match val {
428                    OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
429                    OptionValue::Int(n) => {
430                        return Err(EngineError::Ex(format!(
431                            "option `{name}` out of u32 range: {n}"
432                        )));
433                    }
434                    other => {
435                        return Err(EngineError::Ex(format!(
436                            "option `{name}` expects int, got {other:?}"
437                        )));
438                    }
439                };
440                Ok(())
441            }};
442        }
443        macro_rules! set_string {
444            ($field:ident) => {{
445                self.$field = match val {
446                    OptionValue::String(s) => s,
447                    other => {
448                        return Err(EngineError::Ex(format!(
449                            "option `{name}` expects string, got {other:?}"
450                        )));
451                    }
452                };
453                Ok(())
454            }};
455        }
456        match name {
457            "tabstop" | "ts" => set_u32!(tabstop),
458            "shiftwidth" | "sw" => set_u32!(shiftwidth),
459            "softtabstop" | "sts" => set_u32!(softtabstop),
460            "textwidth" | "tw" => set_u32!(textwidth),
461            "expandtab" | "et" => set_bool!(expandtab),
462            "iskeyword" | "isk" => set_string!(iskeyword),
463            "ignorecase" | "ic" => set_bool!(ignorecase),
464            "smartcase" | "scs" => set_bool!(smartcase),
465            "hlsearch" | "hls" => set_bool!(hlsearch),
466            "incsearch" | "is" => set_bool!(incsearch),
467            "wrapscan" | "ws" => set_bool!(wrapscan),
468            "autoindent" | "ai" => set_bool!(autoindent),
469            "smartindent" | "si" => set_bool!(smartindent),
470            "timeoutlen" | "tm" => {
471                self.timeout_len = match val {
472                    OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
473                    other => {
474                        return Err(EngineError::Ex(format!(
475                            "option `{name}` expects non-negative int (millis), got {other:?}"
476                        )));
477                    }
478                };
479                Ok(())
480            }
481            "undolevels" | "ul" => set_u32!(undo_levels),
482            "undobreak" => set_bool!(undo_break_on_motion),
483            "readonly" | "ro" => set_bool!(readonly),
484            "wrap" => {
485                let on = match val {
486                    OptionValue::Bool(b) => b,
487                    OptionValue::Int(n) => n != 0,
488                    other => {
489                        return Err(EngineError::Ex(format!(
490                            "option `{name}` expects bool, got {other:?}"
491                        )));
492                    }
493                };
494                self.wrap = match (on, self.wrap) {
495                    (false, _) => WrapMode::None,
496                    (true, WrapMode::Word) => WrapMode::Word,
497                    (true, _) => WrapMode::Char,
498                };
499                Ok(())
500            }
501            "linebreak" | "lbr" => {
502                let on = match val {
503                    OptionValue::Bool(b) => b,
504                    OptionValue::Int(n) => n != 0,
505                    other => {
506                        return Err(EngineError::Ex(format!(
507                            "option `{name}` expects bool, got {other:?}"
508                        )));
509                    }
510                };
511                self.wrap = match (on, self.wrap) {
512                    (true, _) => WrapMode::Word,
513                    (false, WrapMode::Word) => WrapMode::Char,
514                    (false, other) => other,
515                };
516                Ok(())
517            }
518            "number" | "nu" => set_bool!(number),
519            "relativenumber" | "rnu" => set_bool!(relativenumber),
520            "numberwidth" | "nuw" => {
521                self.numberwidth = match val {
522                    OptionValue::Int(n) if (1..=20).contains(&n) => n as usize,
523                    OptionValue::Int(n) => {
524                        return Err(EngineError::Ex(format!(
525                            "option `{name}` must be in range 1..=20, got {n}"
526                        )));
527                    }
528                    other => {
529                        return Err(EngineError::Ex(format!(
530                            "option `{name}` expects int, got {other:?}"
531                        )));
532                    }
533                };
534                Ok(())
535            }
536            "cursorline" | "cul" => set_bool!(cursorline),
537            "cursorcolumn" | "cuc" => set_bool!(cursorcolumn),
538            "signcolumn" | "scl" => {
539                self.signcolumn = match val {
540                    OptionValue::String(ref s) => match s.as_str() {
541                        "yes" => SignColumnMode::Yes,
542                        "no" => SignColumnMode::No,
543                        "auto" => SignColumnMode::Auto,
544                        other => {
545                            return Err(EngineError::Ex(format!(
546                                "option `{name}` must be `yes`, `no`, or `auto`, got {other:?}"
547                            )));
548                        }
549                    },
550                    other => {
551                        return Err(EngineError::Ex(format!(
552                            "option `{name}` expects string (yes/no/auto), got {other:?}"
553                        )));
554                    }
555                };
556                Ok(())
557            }
558            "foldcolumn" | "fdc" => {
559                self.foldcolumn = match val {
560                    OptionValue::Int(n) if (0..=12).contains(&n) => n as u32,
561                    OptionValue::Int(n) => {
562                        return Err(EngineError::Ex(format!(
563                            "option `{name}` must be in range 0..=12, got {n}"
564                        )));
565                    }
566                    other => {
567                        return Err(EngineError::Ex(format!(
568                            "option `{name}` expects int (0-12), got {other:?}"
569                        )));
570                    }
571                };
572                Ok(())
573            }
574            "colorcolumn" | "cc" => set_string!(colorcolumn),
575            "formatoptions" | "fo" => set_string!(formatoptions),
576            "filetype" | "ft" => set_string!(filetype),
577            other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
578        }
579    }
580
581    /// Read an option by name. `None` for unknown names.
582    pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
583        Some(match name {
584            "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
585            "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
586            "softtabstop" | "sts" => OptionValue::Int(self.softtabstop as i64),
587            "textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
588            "expandtab" | "et" => OptionValue::Bool(self.expandtab),
589            "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
590            "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
591            "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
592            "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
593            "incsearch" | "is" => OptionValue::Bool(self.incsearch),
594            "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
595            "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
596            "smartindent" | "si" => OptionValue::Bool(self.smartindent),
597            "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
598            "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
599            "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
600            "readonly" | "ro" => OptionValue::Bool(self.readonly),
601            "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
602            "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
603            "number" | "nu" => OptionValue::Bool(self.number),
604            "relativenumber" | "rnu" => OptionValue::Bool(self.relativenumber),
605            "numberwidth" | "nuw" => OptionValue::Int(self.numberwidth as i64),
606            "cursorline" | "cul" => OptionValue::Bool(self.cursorline),
607            "cursorcolumn" | "cuc" => OptionValue::Bool(self.cursorcolumn),
608            "signcolumn" | "scl" => OptionValue::String(
609                match self.signcolumn {
610                    SignColumnMode::Yes => "yes",
611                    SignColumnMode::No => "no",
612                    SignColumnMode::Auto => "auto",
613                }
614                .to_string(),
615            ),
616            "foldcolumn" | "fdc" => OptionValue::Int(self.foldcolumn as i64),
617            "colorcolumn" | "cc" => OptionValue::String(self.colorcolumn.clone()),
618            "formatoptions" | "fo" => OptionValue::String(self.formatoptions.clone()),
619            "filetype" | "ft" => OptionValue::String(self.filetype.clone()),
620            _ => return None,
621        })
622    }
623}
624
625/// Visible region of a buffer — the runtime viewport state the host
626/// owns and mutates per render frame.
627///
628/// 0.0.34 (Patch C-δ.1): semantic ownership moved from
629/// [`hjkl_buffer::Buffer`] to [`Host`]. The struct still lives in
630/// `hjkl-buffer` (alongside [`hjkl_buffer::Wrap`] and the rope-walking
631/// `wrap_segments` math it depends on) so the dependency graph stays
632/// `engine → buffer`; the engine re-exports it as
633/// [`crate::types::Viewport`] (this alias) for hosts that program to
634/// the SPEC surface.
635///
636/// The architectural decision is "viewport lives on Host, not Buffer":
637/// vim logic must work in GUI hosts (variable-width fonts, pixel
638/// canvases, soft-wrap by pixel) as well as TUI hosts, so the runtime
639/// viewport state is expressed in cells/rows/cols and is owned by the
640/// host. `top_row` and `top_col` are the first visible row / column
641/// (`top_col` is a char index).
642///
643/// `wrap` and `text_width` together drive soft-wrap-aware scrolling
644/// and motion. `text_width` is the cell width of the text area
645/// (i.e., `width` minus any gutter the host renders).
646pub use hjkl_buffer::Viewport;
647
648/// Opaque buffer identifier owned by the host. Engine echoes it back
649/// in [`Host::Intent`] variants for buffer-list operations
650/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
651#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
652pub struct BufferId(pub u64);
653
654/// Modifier bits accompanying every keystroke.
655#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
656pub struct Modifiers {
657    pub ctrl: bool,
658    pub shift: bool,
659    pub alt: bool,
660    pub super_: bool,
661}
662
663/// Special key codes — anything that isn't a printable character.
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
665#[non_exhaustive]
666pub enum SpecialKey {
667    Esc,
668    Enter,
669    Backspace,
670    Tab,
671    BackTab,
672    Up,
673    Down,
674    Left,
675    Right,
676    Home,
677    End,
678    PageUp,
679    PageDown,
680    Insert,
681    Delete,
682    F(u8),
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
686pub enum MouseKind {
687    Press,
688    Release,
689    Drag,
690    ScrollUp,
691    ScrollDown,
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
695pub struct MouseEvent {
696    pub kind: MouseKind,
697    pub pos: Pos,
698    pub mods: Modifiers,
699}
700
701/// Single input event handed to the engine.
702///
703/// `Paste` content bypasses insert-mode mappings, abbreviations, and
704/// autoindent; the engine inserts the bracketed-paste payload as-is.
705#[derive(Debug, Clone, PartialEq, Eq)]
706#[non_exhaustive]
707pub enum Input {
708    Char(char, Modifiers),
709    Key(SpecialKey, Modifiers),
710    Mouse(MouseEvent),
711    Paste(String),
712    FocusGained,
713    FocusLost,
714    Resize(u16, u16),
715}
716
717/// Host adapter consumed by the engine. Lives behind the planned
718/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
719/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
720/// align against.
721///
722/// Methods with default impls return safe no-ops so hosts that don't
723/// need a feature (cancellation, wrap-aware motion, syntax highlights)
724/// can ignore them.
725pub trait Host: Send {
726    /// Custom intent type. Hosts that don't fan out actions back to
727    /// themselves can use the unit type via the default impl approach
728    /// (set associated type explicitly).
729    type Intent;
730
731    // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
732
733    /// Fire-and-forget clipboard write. Engine never blocks; the host
734    /// queues internally and flushes on its own task (OSC52, `wl-copy`,
735    /// `pbcopy`, …).
736    fn write_clipboard(&mut self, text: String);
737
738    /// Returns the last-known cached clipboard value. May be stale —
739    /// matches the OSC52/wl-paste model neovim and helix both ship.
740    fn read_clipboard(&mut self) -> Option<String>;
741
742    // ── Time + cancellation ──
743
744    /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
745    /// reads this; engine never reads `Instant::now()` directly so
746    /// macro replay stays deterministic.
747    fn now(&self) -> core::time::Duration;
748
749    /// Cooperative cancellation. Engine polls during long search /
750    /// regex / multi-cursor edit loops. Default returns `false`.
751    fn should_cancel(&self) -> bool {
752        false
753    }
754
755    // ── Search prompt ──
756
757    /// Synchronously prompt the user for a search pattern. Returning
758    /// `None` aborts the search.
759    fn prompt_search(&mut self) -> Option<String>;
760
761    // ── Wrap-aware motion (default: wrap is identity) ──
762
763    /// Map a logical position to its display line for `gj`/`gk`. Hosts
764    /// without wrapping may use the default identity impl.
765    fn display_line_for(&self, pos: Pos) -> u32 {
766        pos.line
767    }
768
769    /// Inverse of [`display_line_for`]. Default identity.
770    fn pos_for_display(&self, line: u32, col: u32) -> Pos {
771        Pos { line, col }
772    }
773
774    // ── Syntax highlights (default: none) ──
775
776    /// Host-supplied syntax highlights for `range`. Empty by default;
777    /// hosts wire tree-sitter or LSP semantic tokens here.
778    fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
779        let _ = range;
780        Vec::new()
781    }
782
783    // ── Cursor shape ──
784
785    /// Engine emits this on every mode transition. Hosts repaint the
786    /// cursor in the requested shape.
787    fn emit_cursor_shape(&mut self, shape: CursorShape);
788
789    // ── Viewport (host owns runtime viewport state) ──
790
791    /// Borrow the host's viewport. The host writes `width`/`height`/
792    /// `text_width`/`wrap` per render frame; the engine reads/writes
793    /// `top_row` / `top_col` to scroll. 0.0.34 (Patch C-δ.1) moved
794    /// this off [`hjkl_buffer::Buffer`] onto `Host`.
795    fn viewport(&self) -> &Viewport;
796
797    /// Mutable viewport access. Engine motion + scroll code routes
798    /// here when scrolloff math advances `top_row`.
799    fn viewport_mut(&mut self) -> &mut Viewport;
800
801    // ── Custom intent fan-out ──
802
803    /// Host-defined event the engine raises (LSP request, fold op,
804    /// buffer switch, …).
805    fn emit_intent(&mut self, intent: Self::Intent);
806}
807
808/// Default no-op [`Host`] implementation. Suitable for tests, headless
809/// embedding, or any host that doesn't yet need clipboard / cursor-shape
810/// / cancellation plumbing.
811///
812/// Behaviour:
813/// - `write_clipboard` stores the most recent payload in an in-memory
814///   slot; `read_clipboard` returns it. Round-trip-only — no OS-level
815///   clipboard touched.
816/// - `now` returns wall-clock duration since construction.
817/// - `prompt_search` returns `None` (search is aborted).
818/// - `emit_cursor_shape` records the most recent shape; readable via
819///   [`DefaultHost::last_cursor_shape`].
820/// - `emit_intent` discards intents (intent type is `()`).
821#[derive(Debug)]
822pub struct DefaultHost {
823    clipboard: Option<String>,
824    last_cursor_shape: CursorShape,
825    started: std::time::Instant,
826    viewport: Viewport,
827}
828
829impl Default for DefaultHost {
830    fn default() -> Self {
831        Self::new()
832    }
833}
834
835impl DefaultHost {
836    /// Default viewport size for headless / test hosts: 80x24, no
837    /// soft-wrap. Matches the conventional terminal default.
838    pub const DEFAULT_VIEWPORT: Viewport = Viewport {
839        top_row: 0,
840        top_col: 0,
841        width: 80,
842        height: 24,
843        wrap: hjkl_buffer::Wrap::None,
844        text_width: 80,
845        tab_width: 0,
846    };
847
848    pub fn new() -> Self {
849        Self {
850            clipboard: None,
851            last_cursor_shape: CursorShape::Block,
852            started: std::time::Instant::now(),
853            viewport: Self::DEFAULT_VIEWPORT,
854        }
855    }
856
857    /// Construct a [`DefaultHost`] with a custom initial viewport.
858    /// Useful for tests that want to exercise scrolloff math at a
859    /// specific window size.
860    pub fn with_viewport(viewport: Viewport) -> Self {
861        Self {
862            clipboard: None,
863            last_cursor_shape: CursorShape::Block,
864            started: std::time::Instant::now(),
865            viewport,
866        }
867    }
868
869    /// Most recent cursor shape requested by the engine.
870    pub fn last_cursor_shape(&self) -> CursorShape {
871        self.last_cursor_shape
872    }
873}
874
875impl Host for DefaultHost {
876    type Intent = ();
877
878    fn write_clipboard(&mut self, text: String) {
879        self.clipboard = Some(text);
880    }
881
882    fn read_clipboard(&mut self) -> Option<String> {
883        self.clipboard.clone()
884    }
885
886    fn now(&self) -> core::time::Duration {
887        self.started.elapsed()
888    }
889
890    fn prompt_search(&mut self) -> Option<String> {
891        None
892    }
893
894    fn emit_cursor_shape(&mut self, shape: CursorShape) {
895        self.last_cursor_shape = shape;
896    }
897
898    fn viewport(&self) -> &Viewport {
899        &self.viewport
900    }
901
902    fn viewport_mut(&mut self) -> &mut Viewport {
903        &mut self.viewport
904    }
905
906    fn emit_intent(&mut self, _intent: Self::Intent) {}
907}
908
909/// Engine render frame consumed by the host once per redraw.
910///
911/// Borrow-style — the engine builds it on demand from its internal
912/// state without allocating clones of large fields. Hosts diff across
913/// frames to decide what to repaint.
914///
915/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
916/// a snapshot of the current line count (to size the gutter). The
917/// SPEC-target fields (`selections`, `highlights`, `command_line`,
918/// `search_prompt`, `status_line`) land once trait extraction wires
919/// the FSM through `SelectionSet` and the highlight pipeline.
920#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
921pub struct RenderFrame {
922    pub mode: SnapshotMode,
923    pub cursor_row: u32,
924    pub cursor_col: u32,
925    pub cursor_shape: CursorShape,
926    pub viewport_top: u32,
927    pub line_count: u32,
928}
929
930/// Coarse editor snapshot suitable for serde round-tripping.
931///
932/// Today's shape is intentionally minimal — it carries only the bits
933/// the runtime [`crate::Editor`] knows how to round-trip without the
934/// trait extraction (mode, cursor, lines, viewport top, settings).
935/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
936/// grows to cover full SPEC state: registers, marks, jump list, change
937/// list, undo tree, full options.
938///
939/// Hosts that persist editor state between sessions should:
940///
941/// - Treat the snapshot as opaque. Don't manually mutate fields.
942/// - Always check `version` after deserialization; reject on
943///   mismatch rather than attempt migration.
944///
945/// # Wire-format stability
946///
947/// - **0.0.x:** [`Self::VERSION`] bumps with every structural change to
948///   the snapshot. Hosts must reject mismatched persisted state — no
949///   migration path is offered.
950/// - **0.1.0:** [`Self::VERSION`] freezes. Hosts persisting editor state
951///   between sessions can rely on the wire format being stable for the
952///   entire 0.1.x line.
953/// - **0.2.0+:** any further structural change to this struct requires a
954///   `VERSION++` bump and is gated behind a major version bump of the
955///   crate.
956#[derive(Debug, Clone)]
957#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
958pub struct EditorSnapshot {
959    /// Format version. See [`Self::VERSION`] for the lock policy.
960    /// Hosts use this to detect mismatched persisted state.
961    pub version: u32,
962    /// Mode at snapshot time (status-line granularity).
963    pub mode: SnapshotMode,
964    /// Cursor `(row, col)` in byte indexing.
965    pub cursor: (u32, u32),
966    /// Buffer lines. Trailing `\n` not included.
967    pub lines: Vec<String>,
968    /// Viewport top line at snapshot time.
969    pub viewport_top: u32,
970    /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
971    /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
972    /// doesn't derive them today.
973    pub registers: crate::Registers,
974    /// Named marks — lowercase (`'a`–`'z`, buffer-scope). Round-trips
975    /// across tab swaps in the host.
976    ///
977    /// 0.0.36: consolidated from the prior `file_marks` field;
978    /// lowercase marks now persist as well since they live in the
979    /// same unified [`crate::Editor::marks`] map.
980    pub marks: std::collections::BTreeMap<char, (u32, u32)>,
981    /// Global (file) marks — uppercase (`'A`–`'Z`). Each entry records
982    /// `(buffer_id, row, col)` so cross-buffer jumps can switch to the
983    /// correct slot. Added in VERSION 5.
984    pub global_marks: std::collections::BTreeMap<char, (u64, u32, u32)>,
985}
986
987/// Status-line mode summary. Bridges to the legacy
988/// [`crate::VimMode`] without leaking the full FSM type into the
989/// snapshot wire format.
990#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
991#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
992pub enum SnapshotMode {
993    #[default]
994    Normal,
995    Insert,
996    Visual,
997    VisualLine,
998    VisualBlock,
999}
1000
1001impl EditorSnapshot {
1002    /// Current snapshot format version.
1003    ///
1004    /// Bumped to 2 in v0.0.8: registers added.
1005    /// Bumped to 3 in v0.0.9: file_marks added.
1006    /// Bumped to 4 in v0.0.36: file_marks → unified `marks` map
1007    /// (lowercase + uppercase consolidated).
1008    /// Bumped to 5: `global_marks` field added for cross-buffer uppercase
1009    /// marks (closes #175).
1010    ///
1011    /// # Lock policy
1012    ///
1013    /// - **0.0.x (today):** `VERSION` bumps freely with each structural
1014    ///   change to [`EditorSnapshot`]. Persisted state from an older
1015    ///   patch release will not round-trip; hosts must reject the
1016    ///   snapshot rather than attempt a field-by-field migration.
1017    /// - **0.1.0:** `VERSION` freezes. Hosts persisting editor state
1018    ///   between sessions can rely on the wire format being stable for
1019    ///   the entire 0.1.x line.
1020    /// - **0.2.0+:** any further structural change requires `VERSION++`
1021    ///   together with a major-version bump of `hjkl-engine`.
1022    pub const VERSION: u32 = 5;
1023}
1024
1025/// Errors surfaced from the engine to the host. Intentionally narrow —
1026/// callsites that fail in user-facing ways return `Result<_,
1027/// EngineError>`; internal invariant breaks use `debug_assert!`.
1028#[derive(Debug, thiserror::Error)]
1029pub enum EngineError {
1030    /// `:s/pat/.../` couldn't compile the pattern. Host displays the
1031    /// regex error in the status line.
1032    #[error("regex compile error: {0}")]
1033    Regex(#[from] regex::Error),
1034
1035    /// `:[range]` parse failed.
1036    #[error("invalid range: {0}")]
1037    InvalidRange(String),
1038
1039    /// Ex command parse failed (unknown command, malformed args).
1040    #[error("ex parse: {0}")]
1041    Ex(String),
1042
1043    /// Edit attempted on a read-only buffer.
1044    #[error("buffer is read-only")]
1045    ReadOnly,
1046
1047    /// Position passed by the caller pointed outside the buffer.
1048    #[error("position out of bounds: {0:?}")]
1049    OutOfBounds(Pos),
1050
1051    /// Snapshot version mismatch. Host should treat as "abandon
1052    /// snapshot" rather than attempt migration.
1053    #[error("snapshot version mismatch: file={0}, expected={1}")]
1054    SnapshotVersion(u32, u32),
1055}
1056
1057pub(crate) mod sealed {
1058    /// Sealing trait for the planned 0.1.0 [`super::Buffer`] surface.
1059    /// Pre-1.0 the engine reserves the right to add methods to the
1060    /// `Buffer` super-trait without a major bump; downstream cannot
1061    /// `impl Buffer` from outside this family.
1062    ///
1063    /// The in-tree [`hjkl_buffer::Buffer`] is the canonical impl; the
1064    /// `Sealed` marker for it lives in `crate::buffer_impl`. The module
1065    /// itself stays `pub(crate)` so the sibling impl module can name
1066    /// the trait while keeping the seal closed to the outside world.
1067    pub trait Sealed {}
1068}
1069
1070/// Cursor sub-trait of [`Buffer`].
1071///
1072/// `Pos` here is the engine's grapheme-indexed [`Pos`] type. Buffer
1073/// implementations convert at the boundary if their internal indexing
1074/// differs (e.g., the rope's byte indexing).
1075pub trait Cursor: Send {
1076    /// Active primary cursor position.
1077    fn cursor(&self) -> Pos;
1078    /// Move the active primary cursor.
1079    fn set_cursor(&mut self, pos: Pos);
1080    /// Byte offset for `pos`. Used by regex search bridges.
1081    fn byte_offset(&self, pos: Pos) -> usize;
1082    /// Inverse of [`Self::byte_offset`].
1083    fn pos_at_byte(&self, byte: usize) -> Pos;
1084}
1085
1086/// Read-only query sub-trait of [`Buffer`].
1087pub trait Query: Send {
1088    /// Number of logical lines (excluding the implicit trailing line).
1089    fn line_count(&self) -> u32;
1090    /// Return an owned copy of line `idx` (0-based). Implementations should
1091    /// panic on out-of-bounds rather than silently return empty.
1092    fn line(&self, idx: u32) -> String;
1093    /// Total buffer length in bytes.
1094    fn len_bytes(&self) -> usize;
1095    /// Slice for the half-open `range`. May allocate (rope joins)
1096    /// or borrow (contiguous storage). Returns
1097    /// [`std::borrow::Cow<'_, str>`] so contiguous backends can
1098    /// avoid the allocation.
1099    fn slice(&self, range: core::ops::Range<Pos>) -> std::borrow::Cow<'_, str>;
1100    /// Monotonic mutation generation counter. Increments on every
1101    /// content-changing call (insert / delete / replace / fold-touch
1102    /// edit / `set_content`). Read-only ops (cursor moves, queries,
1103    /// view changes) leave it untouched.
1104    ///
1105    /// Engine consumers cache per-row data (search-match positions,
1106    /// syntax spans, wrap layout) keyed off this counter — when it
1107    /// advances, the cache is invalidated.
1108    ///
1109    /// Implementations may return any monotonically non-decreasing
1110    /// value (zero is fine for non-canonical impls that don't have a
1111    /// caching story); the contract is "if `dirty_gen` changed, the
1112    /// content **may** have changed."
1113    fn dirty_gen(&self) -> u64 {
1114        0
1115    }
1116
1117    /// Byte offset of the first byte of `row` within the buffer's
1118    /// canonical `lines().join("\n")` rendering. Out-of-range rows
1119    /// clamp to `len_bytes()`.
1120    ///
1121    /// Default implementation walks every prior row's byte length and
1122    /// adds a separator byte per row gap. Backends with a faster path
1123    /// (rope position-of-line) should override.
1124    ///
1125    /// Pre-0.1.0 default-impl addition — does not extend the sealed
1126    /// surface for downstream impls.
1127    fn byte_of_row(&self, row: usize) -> usize {
1128        let n = self.line_count() as usize;
1129        let row = row.min(n);
1130        let mut acc = 0usize;
1131        for r in 0..row {
1132            acc += self.line(r as u32).len();
1133            // Separator newline between rows. The canonical engine
1134            // join uses `\n` between every pair of lines (no trailing
1135            // newline), so add one separator per row strictly before
1136            // the last buffer row.
1137            if r + 1 < n {
1138                acc += 1;
1139            }
1140        }
1141        acc
1142    }
1143
1144    /// Return the canonical `lines().join("\n")` rendering of the
1145    /// document as an `Arc<String>`. Multiple per-tick consumers (syntax
1146    /// pipeline, LSP notify, git signature, dirty hash) need this; the
1147    /// `Buffer` impl caches against `dirty_gen` so they share one
1148    /// allocation per generation.
1149    ///
1150    /// Default impl walks `line(r)` for every row — slow but correct.
1151    /// Backends with cheaper paths (rope contiguous view) should override.
1152    fn content_joined(&self) -> std::sync::Arc<String> {
1153        let n = self.line_count() as usize;
1154        let mut acc = String::with_capacity(self.len_bytes());
1155        for r in 0..n {
1156            if r > 0 {
1157                acc.push('\n');
1158            }
1159            acc.push_str(&self.line(r as u32));
1160        }
1161        std::sync::Arc::new(acc)
1162    }
1163
1164    /// Byte length of `row`. Out-of-range rows return 0.
1165    ///
1166    /// Default impl pays a full `line(row)` clone just to read its length.
1167    /// Backends with row-indexed storage (canonical `hjkl_buffer::Buffer`)
1168    /// should override to read the byte length under one lock with no
1169    /// allocation — `Editor::restore_text` calls this on every undo/redo
1170    /// to recompute the inverse `ContentEdit`.
1171    fn line_bytes(&self, row: usize) -> usize {
1172        let n = self.line_count() as usize;
1173        if row >= n {
1174            return 0;
1175        }
1176        self.line(row as u32).len()
1177    }
1178
1179    /// Return a cheaply-cloned rope snapshot of the buffer. O(1) for the
1180    /// canonical `hjkl_buffer::Buffer` (Arc-backed B-tree clone). Used by
1181    /// the syntax pipeline's `parse_initial_rope` / `parse_incremental_rope`
1182    /// to stream bytes into tree-sitter without materializing a contiguous
1183    /// `String`.
1184    ///
1185    /// Default impl builds a rope from `content_joined()` — correct but
1186    /// O(N). Backends that own a rope internally should override.
1187    fn rope(&self) -> ropey::Rope {
1188        ropey::Rope::from_str(&self.content_joined())
1189    }
1190}
1191
1192/// Mutating sub-trait of [`Buffer`]. Distinct trait name from the
1193/// crate-root [`Edit`] struct — this one carries methods, the other
1194/// is a value type.
1195pub trait BufferEdit: Send {
1196    /// Insert `text` at `pos`. Implementations clamp out-of-range
1197    /// positions to the document end.
1198    fn insert_at(&mut self, pos: Pos, text: &str);
1199    /// Delete the half-open `range`.
1200    fn delete_range(&mut self, range: core::ops::Range<Pos>);
1201    /// Replace the half-open `range` with `replacement`.
1202    fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str);
1203    /// Replace the entire buffer content with `text`. The cursor is
1204    /// clamped to the surviving content. Used by `:e!` / undo
1205    /// restore / snapshot replay where expressing "replace whole
1206    /// buffer" via [`replace_range`] would require knowing the end
1207    /// position. Default impl uses [`replace_range`] with a
1208    /// best-effort end (`u32::MAX` / `u32::MAX`); the canonical
1209    /// in-tree impl overrides it for a single-shot rebuild.
1210    fn replace_all(&mut self, text: &str) {
1211        self.replace_range(
1212            Pos::ORIGIN..Pos {
1213                line: u32::MAX,
1214                col: u32::MAX,
1215            },
1216            text,
1217        );
1218    }
1219}
1220
1221/// Search sub-trait of [`Buffer`]. The pattern is owned by the engine;
1222/// buffers do not cache compiled regexes.
1223pub trait Search: Send {
1224    /// First match at-or-after `from`. `None` when no match remains.
1225    fn find_next(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1226    /// Last match at-or-before `from`.
1227    fn find_prev(&self, from: Pos, pat: &regex::Regex) -> Option<core::ops::Range<Pos>>;
1228}
1229
1230/// Buffer super-trait — the pre-1.0 contract every backend implements.
1231///
1232/// Sealed to the engine's own crate family (in-tree
1233/// `hjkl_buffer::Buffer` is the canonical impl). Pre-0.1.0 the engine
1234/// reserves the right to add methods on patch bumps; downstream
1235/// consumers depend on the full trait without naming
1236/// [`sealed::Sealed`].
1237pub trait Buffer: Cursor + Query + BufferEdit + Search + sealed::Sealed + Send {}
1238
1239/// Canonical fold-mutation op carried through [`FoldProvider::apply`].
1240///
1241/// Introduced in 0.0.38 (Patch C-δ.4). The engine raises one `FoldOp`
1242/// per `z…` keystroke / `:fold*` Ex command and dispatches it through
1243/// the [`FoldProvider::apply`] surface. Hosts that own the fold storage
1244/// (default in-tree wraps `&mut hjkl_buffer::Buffer`) decide how to
1245/// apply it — possibly batching, deduping, or vetoing. Hosts without
1246/// folds use [`NoopFoldProvider`] which silently discards every op.
1247///
1248/// `FoldOp` is engine-canonical (per the design doc's resolved
1249/// question 8.2): hosts don't invent their own fold-op enums. Each
1250/// host that exposes folds embeds a `FoldOp` variant in its `Intent`
1251/// enum (or simply observes the engine's pending-fold-op queue via
1252/// [`crate::Editor::take_fold_ops`]).
1253///
1254/// Row indices are zero-based and match the row coordinate space used
1255/// by [`hjkl_buffer::Buffer`]'s fold methods.
1256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1257#[non_exhaustive]
1258pub enum FoldOp {
1259    /// `:fold {start,end}` / `zf{motion}` / visual-mode `zf` — register a
1260    /// new fold spanning `[start_row, end_row]` (inclusive). The `closed`
1261    /// flag matches the underlying [`hjkl_buffer::Fold::closed`].
1262    Add {
1263        start_row: usize,
1264        end_row: usize,
1265        closed: bool,
1266    },
1267    /// `zd` — drop the fold under `row` if any.
1268    RemoveAt(usize),
1269    /// `zo` — open the fold under `row` if any.
1270    OpenAt(usize),
1271    /// `zc` — close the fold under `row` if any.
1272    CloseAt(usize),
1273    /// `za` — flip the fold under `row` between open / closed.
1274    ToggleAt(usize),
1275    /// `zR` — open every fold in the buffer.
1276    OpenAll,
1277    /// `zM` — close every fold in the buffer.
1278    CloseAll,
1279    /// `zE` — eliminate every fold.
1280    ClearAll,
1281    /// Edit-driven fold invalidation. Drops every fold touching the
1282    /// row range `[start_row, end_row]`. Mirrors vim's "edits inside a
1283    /// fold open it" behaviour. Fired by the engine's edit pipeline,
1284    /// not bound to a `z…` keystroke.
1285    Invalidate { start_row: usize, end_row: usize },
1286}
1287
1288/// Fold-iteration + mutation trait. The engine asks "what's the next
1289/// visible row" / "is this row hidden" through this surface, and
1290/// dispatches fold mutations through [`FoldProvider::apply`], so fold
1291/// storage can live wherever the host pleases (on the buffer, in a
1292/// separate host-side fold tree, or absent entirely).
1293///
1294/// Introduced in 0.0.32 (Patch C-β) for read access; 0.0.38 (Patch
1295/// C-δ.4) added [`FoldProvider::apply`] + [`FoldProvider::invalidate_range`]
1296/// so engine call sites that used to call
1297/// `hjkl_buffer::Buffer::{open,close,toggle,…}_fold_at` directly route
1298/// through this trait now. The canonical read-only implementation
1299/// [`crate::buffer_impl::BufferFoldProvider`] wraps a
1300/// `&hjkl_buffer::Buffer`; the canonical mutable implementation
1301/// [`crate::buffer_impl::BufferFoldProviderMut`] wraps a
1302/// `&mut hjkl_buffer::Buffer`. Hosts that don't care about folds can
1303/// use [`NoopFoldProvider`].
1304///
1305/// The engine carries a `Box<dyn FoldProvider + 'a>` slot today and
1306/// looks up rows through it. Once `Editor<B, H>` flips generic
1307/// (Patch C, 0.1.0) the slot moves onto `Host` directly.
1308pub trait FoldProvider: Send {
1309    /// First visible row strictly after `row`, skipping hidden rows.
1310    /// `None` past the end of the buffer.
1311    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize>;
1312    /// First visible row strictly before `row`. `None` past the top.
1313    fn prev_visible_row(&self, row: usize) -> Option<usize>;
1314    /// Is `row` currently hidden by a closed fold?
1315    fn is_row_hidden(&self, row: usize) -> bool;
1316    /// Range `(start_row, end_row, closed)` of the fold containing
1317    /// `row`, if any. Lets `za` / `zo` / `zc` find their target
1318    /// without iterating the full fold list.
1319    fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)>;
1320
1321    /// Apply a [`FoldOp`] to the underlying fold storage. Read-only
1322    /// providers (e.g. [`crate::buffer_impl::BufferFoldProvider`] which
1323    /// holds a `&Buffer`) and providers that don't track folds (e.g.
1324    /// [`NoopFoldProvider`]) implement this as a no-op.
1325    ///
1326    /// Default impl is a no-op so that read-only / host-stub providers
1327    /// don't need to override it; mutable providers
1328    /// (e.g. [`crate::buffer_impl::BufferFoldProviderMut`]) override
1329    /// this to dispatch to the underlying buffer's fold methods.
1330    fn apply(&mut self, op: FoldOp) {
1331        let _ = op;
1332    }
1333
1334    /// Drop every fold whose range overlaps `[start_row, end_row]`.
1335    /// Edit pipelines call this after a user edit so vim's "edits
1336    /// inside a fold open it" behaviour fires. Default impl forwards
1337    /// to [`FoldProvider::apply`] with a [`FoldOp::Invalidate`].
1338    fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
1339        self.apply(FoldOp::Invalidate { start_row, end_row });
1340    }
1341}
1342
1343/// No-op [`FoldProvider`] for hosts that don't expose folds. Every
1344/// row is visible; `is_row_hidden` always returns `false`.
1345#[derive(Debug, Default, Clone, Copy)]
1346pub struct NoopFoldProvider;
1347
1348impl FoldProvider for NoopFoldProvider {
1349    fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize> {
1350        let last = row_count.saturating_sub(1);
1351        if last == 0 && row == 0 {
1352            return None;
1353        }
1354        let r = row.checked_add(1)?;
1355        (r <= last).then_some(r)
1356    }
1357
1358    fn prev_visible_row(&self, row: usize) -> Option<usize> {
1359        row.checked_sub(1)
1360    }
1361
1362    fn is_row_hidden(&self, _row: usize) -> bool {
1363        false
1364    }
1365
1366    fn fold_at_row(&self, _row: usize) -> Option<(usize, usize, bool)> {
1367        None
1368    }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373    use super::*;
1374
1375    #[test]
1376    fn caret_is_empty() {
1377        let sel = Selection::caret(Pos::new(2, 4));
1378        assert!(sel.is_empty());
1379        assert_eq!(sel.anchor, sel.head);
1380    }
1381
1382    #[test]
1383    fn selection_set_default_has_one_caret() {
1384        let set = SelectionSet::default();
1385        assert_eq!(set.items.len(), 1);
1386        assert_eq!(set.primary, 0);
1387        assert_eq!(set.primary().anchor, Pos::ORIGIN);
1388    }
1389
1390    #[test]
1391    fn edit_constructors() {
1392        let p = Pos::new(0, 5);
1393        assert_eq!(Edit::insert(p, "x").range, p..p);
1394        assert!(Edit::insert(p, "x").replacement == "x");
1395        assert!(Edit::delete(p..p).replacement.is_empty());
1396    }
1397
1398    #[test]
1399    fn attrs_flags() {
1400        let a = Attrs::BOLD | Attrs::UNDERLINE;
1401        assert!(a.contains(Attrs::BOLD));
1402        assert!(!a.contains(Attrs::ITALIC));
1403    }
1404
1405    #[test]
1406    fn options_set_get_roundtrip() {
1407        let mut o = Options::default();
1408        o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
1409        assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
1410        o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
1411        assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
1412        o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
1413            .unwrap();
1414        match o.get_by_name("iskeyword") {
1415            Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
1416            other => panic!("expected String, got {other:?}"),
1417        }
1418    }
1419
1420    #[test]
1421    fn options_unknown_name_errors_on_set() {
1422        let mut o = Options::default();
1423        assert!(matches!(
1424            o.set_by_name("frobnicate", OptionValue::Int(1)),
1425            Err(EngineError::Ex(_))
1426        ));
1427        assert!(o.get_by_name("frobnicate").is_none());
1428    }
1429
1430    #[test]
1431    fn options_type_mismatch_errors() {
1432        let mut o = Options::default();
1433        assert!(matches!(
1434            o.set_by_name("tabstop", OptionValue::String("nope".into())),
1435            Err(EngineError::Ex(_))
1436        ));
1437        assert!(matches!(
1438            o.set_by_name("iskeyword", OptionValue::Int(7)),
1439            Err(EngineError::Ex(_))
1440        ));
1441    }
1442
1443    #[test]
1444    fn options_int_to_bool_coercion() {
1445        // `:set ic=0` reads as boolean false; `:set ic=1` as true.
1446        // Common vim spelling.
1447        let mut o = Options::default();
1448        o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
1449        assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
1450        o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
1451        assert!(matches!(
1452            o.get_by_name("ic"),
1453            Some(OptionValue::Bool(false))
1454        ));
1455    }
1456
1457    #[test]
1458    fn options_wrap_linebreak_roundtrip() {
1459        let mut o = Options::default();
1460        assert_eq!(o.wrap, WrapMode::None);
1461        o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
1462        assert_eq!(o.wrap, WrapMode::Char);
1463        o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
1464        assert_eq!(o.wrap, WrapMode::Word);
1465        assert!(matches!(
1466            o.get_by_name("wrap"),
1467            Some(OptionValue::Bool(true))
1468        ));
1469        assert!(matches!(
1470            o.get_by_name("lbr"),
1471            Some(OptionValue::Bool(true))
1472        ));
1473        o.set_by_name("linebreak", OptionValue::Bool(false))
1474            .unwrap();
1475        assert_eq!(o.wrap, WrapMode::Char);
1476        o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
1477        assert_eq!(o.wrap, WrapMode::None);
1478    }
1479
1480    #[test]
1481    fn options_default_modern() {
1482        // 0.2.0: defaults flipped from vim's tabstop=8/expandtab=off to
1483        // modern editor defaults (4-space soft tabs).
1484        let o = Options::default();
1485        assert_eq!(o.tabstop, 4);
1486        assert_eq!(o.shiftwidth, 4);
1487        assert_eq!(o.softtabstop, 4);
1488        assert!(o.expandtab);
1489        assert!(o.hlsearch);
1490        assert!(o.wrapscan);
1491        assert!(o.smartindent);
1492        assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
1493    }
1494
1495    #[test]
1496    fn editor_snapshot_version_const() {
1497        assert_eq!(EditorSnapshot::VERSION, 5);
1498    }
1499
1500    #[test]
1501    fn editor_snapshot_default_shape() {
1502        let s = EditorSnapshot {
1503            version: EditorSnapshot::VERSION,
1504            mode: SnapshotMode::Normal,
1505            cursor: (0, 0),
1506            lines: vec!["hello".to_string()],
1507            viewport_top: 0,
1508            registers: crate::Registers::default(),
1509            marks: Default::default(),
1510            global_marks: Default::default(),
1511        };
1512        assert_eq!(s.cursor, (0, 0));
1513        assert_eq!(s.lines.len(), 1);
1514    }
1515
1516    #[cfg(feature = "serde")]
1517    #[test]
1518    fn editor_snapshot_roundtrip() {
1519        let mut marks = std::collections::BTreeMap::new();
1520        marks.insert('a', (1u32, 0u32));
1521        let mut global_marks = std::collections::BTreeMap::new();
1522        global_marks.insert('A', (42u64, 5u32, 2u32));
1523        let s = EditorSnapshot {
1524            version: EditorSnapshot::VERSION,
1525            mode: SnapshotMode::Insert,
1526            cursor: (3, 7),
1527            lines: vec!["alpha".into(), "beta".into()],
1528            viewport_top: 2,
1529            registers: crate::Registers::default(),
1530            marks,
1531            global_marks,
1532        };
1533        let json = serde_json::to_string(&s).unwrap();
1534        let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
1535        assert_eq!(s.cursor, back.cursor);
1536        assert_eq!(s.lines, back.lines);
1537        assert_eq!(s.viewport_top, back.viewport_top);
1538        assert_eq!(s.global_marks, back.global_marks);
1539    }
1540
1541    #[test]
1542    fn engine_error_display() {
1543        let e = EngineError::ReadOnly;
1544        assert_eq!(e.to_string(), "buffer is read-only");
1545        let e = EngineError::OutOfBounds(Pos::new(3, 7));
1546        assert!(e.to_string().contains("out of bounds"));
1547    }
1548
1549    // ── New render-level options ─────────────────────────────────────────────
1550
1551    #[test]
1552    fn options_cursorline_roundtrip() {
1553        let mut o = Options::default();
1554        assert!(o.cursorline, "cursorline defaults to true");
1555        o.set_by_name("cursorline", OptionValue::Bool(false))
1556            .unwrap();
1557        assert!(matches!(
1558            o.get_by_name("cul"),
1559            Some(OptionValue::Bool(false))
1560        ));
1561        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1562        assert!(matches!(
1563            o.get_by_name("cursorline"),
1564            Some(OptionValue::Bool(true))
1565        ));
1566    }
1567
1568    #[test]
1569    fn options_cursorcolumn_roundtrip() {
1570        let mut o = Options::default();
1571        assert!(!o.cursorcolumn, "cursorcolumn defaults to false");
1572        o.set_by_name("cuc", OptionValue::Bool(true)).unwrap();
1573        assert!(matches!(
1574            o.get_by_name("cursorcolumn"),
1575            Some(OptionValue::Bool(true))
1576        ));
1577    }
1578
1579    #[test]
1580    fn options_signcolumn_roundtrip() {
1581        let mut o = Options::default();
1582        assert_eq!(
1583            o.signcolumn,
1584            SignColumnMode::Auto,
1585            "signcolumn defaults to auto"
1586        );
1587        o.set_by_name("signcolumn", OptionValue::String("yes".into()))
1588            .unwrap();
1589        assert_eq!(o.signcolumn, SignColumnMode::Yes);
1590        assert_eq!(
1591            o.get_by_name("scl"),
1592            Some(OptionValue::String("yes".into()))
1593        );
1594        o.set_by_name("scl", OptionValue::String("no".into()))
1595            .unwrap();
1596        assert_eq!(o.signcolumn, SignColumnMode::No);
1597        o.set_by_name("scl", OptionValue::String("auto".into()))
1598            .unwrap();
1599        assert_eq!(o.signcolumn, SignColumnMode::Auto);
1600    }
1601
1602    #[test]
1603    fn options_signcolumn_rejects_invalid() {
1604        let mut o = Options::default();
1605        assert!(matches!(
1606            o.set_by_name("signcolumn", OptionValue::String("maybe".into())),
1607            Err(EngineError::Ex(_))
1608        ));
1609        // Type mismatch
1610        assert!(matches!(
1611            o.set_by_name("signcolumn", OptionValue::Bool(true)),
1612            Err(EngineError::Ex(_))
1613        ));
1614    }
1615
1616    #[test]
1617    fn options_foldcolumn_roundtrip() {
1618        let mut o = Options::default();
1619        assert_eq!(o.foldcolumn, 0, "foldcolumn defaults to 0");
1620        o.set_by_name("fdc", OptionValue::Int(3)).unwrap();
1621        assert_eq!(o.foldcolumn, 3);
1622        assert_eq!(o.get_by_name("foldcolumn"), Some(OptionValue::Int(3)));
1623    }
1624
1625    #[test]
1626    fn options_foldcolumn_rejects_out_of_range() {
1627        let mut o = Options::default();
1628        assert!(matches!(
1629            o.set_by_name("foldcolumn", OptionValue::Int(13)),
1630            Err(EngineError::Ex(_))
1631        ));
1632        assert!(matches!(
1633            o.set_by_name("foldcolumn", OptionValue::Int(-1)),
1634            Err(EngineError::Ex(_))
1635        ));
1636    }
1637
1638    #[test]
1639    fn options_colorcolumn_roundtrip() {
1640        let mut o = Options::default();
1641        assert_eq!(o.colorcolumn, "", "colorcolumn defaults to empty string");
1642        o.set_by_name("cc", OptionValue::String("80,120".into()))
1643            .unwrap();
1644        assert_eq!(
1645            o.get_by_name("colorcolumn"),
1646            Some(OptionValue::String("80,120".into()))
1647        );
1648        o.set_by_name("colorcolumn", OptionValue::String(String::new()))
1649            .unwrap();
1650        assert_eq!(
1651            o.get_by_name("cc"),
1652            Some(OptionValue::String(String::new()))
1653        );
1654    }
1655
1656    #[test]
1657    fn options_cursorline_alias_cul() {
1658        let mut o = Options::default();
1659        // `:set cul` — bare name turns bool on
1660        o.set_by_name("cul", OptionValue::Bool(true)).unwrap();
1661        assert!(o.cursorline);
1662        // `:set nocul` → Bool(false)
1663        o.set_by_name("cul", OptionValue::Bool(false)).unwrap();
1664        assert!(!o.cursorline);
1665    }
1666
1667    #[test]
1668    fn sign_column_mode_default_is_auto() {
1669        assert_eq!(SignColumnMode::default(), SignColumnMode::Auto);
1670    }
1671}