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: ®ex::Regex) -> Option<core::ops::Range<Pos>>;
1446 /// Last match at-or-before `from`.
1447 fn find_prev(&self, from: Pos, pat: ®ex::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}