hjkl_engine/types.rs
1//! Core types for the planned 0.1.0 trait surface (per `SPEC.md`).
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
123impl Edit {
124 pub fn insert(at: Pos, text: impl Into<String>) -> Self {
125 Edit {
126 range: at..at,
127 replacement: text.into(),
128 }
129 }
130
131 pub fn delete(range: Range<Pos>) -> Self {
132 Edit {
133 range,
134 replacement: String::new(),
135 }
136 }
137
138 pub fn replace(range: Range<Pos>, text: impl Into<String>) -> Self {
139 Edit {
140 range,
141 replacement: text.into(),
142 }
143 }
144}
145
146/// Vim editor mode. Distinct from the legacy [`crate::VimMode`] — that one
147/// is the host-facing status-line summary; this is the engine's internal
148/// state machine.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum Mode {
151 #[default]
152 Normal,
153 Insert,
154 Visual,
155 Replace,
156 Command,
157 OperatorPending,
158}
159
160/// Cursor shape intent emitted on mode transitions. Hosts honor it via
161/// `Host::emit_cursor_shape` once the trait extraction lands.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
163pub enum CursorShape {
164 #[default]
165 Block,
166 Bar,
167 Underline,
168}
169
170/// Engine-native style. Replaces direct ratatui `Style` use in the public
171/// API once phase 5 trait extraction completes; until then both coexist.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
173pub struct Style {
174 pub fg: Option<Color>,
175 pub bg: Option<Color>,
176 pub attrs: Attrs,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
180pub struct Color(pub u8, pub u8, pub u8);
181
182bitflags::bitflags! {
183 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
184 pub struct Attrs: u8 {
185 const BOLD = 1 << 0;
186 const ITALIC = 1 << 1;
187 const UNDERLINE = 1 << 2;
188 const REVERSE = 1 << 3;
189 const DIM = 1 << 4;
190 const STRIKE = 1 << 5;
191 }
192}
193
194/// Highlight kind emitted by the engine's render pass. The host's style
195/// resolver picks colors for `Selection`/`SearchMatch`/etc.; `Syntax(id)`
196/// carries an opaque host-supplied id whose styling lives in the host.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum HighlightKind {
199 Selection,
200 SearchMatch,
201 IncSearch,
202 MatchParen,
203 Syntax(u32),
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Highlight {
208 pub range: Range<Pos>,
209 pub kind: HighlightKind,
210}
211
212/// Editor settings surfaced via `:set`. Per SPEC. Consumed once trait
213/// extraction lands; today's legacy `Settings` (in [`crate::editor`])
214/// continues to drive runtime behaviour.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct Options {
217 /// Display width of `\t` for column math + render. Default 8.
218 pub tabstop: u32,
219 /// Spaces per shift step (`>>`, `<<`, `Ctrl-T`, `Ctrl-D`).
220 pub shiftwidth: u32,
221 /// Insert spaces (`true`) or literal `\t` (`false`) for the Tab key.
222 pub expandtab: bool,
223 /// Characters considered part of a "word" for `w`/`b`/`*`/`#`.
224 /// Default `"@,48-57,_,192-255"` (ASCII letters, digits, `_`, plus
225 /// extended Latin); host may override per language.
226 pub iskeyword: String,
227 /// Default `false`: search is case-sensitive.
228 pub ignorecase: bool,
229 /// When `true` and `ignorecase` is `true`, an uppercase letter in the
230 /// pattern flips back to case-sensitive for that search.
231 pub smartcase: bool,
232 /// Highlight all matches of the last search.
233 pub hlsearch: bool,
234 /// Incrementally highlight matches while typing the search pattern.
235 pub incsearch: bool,
236 /// Wrap searches around the buffer ends.
237 pub wrapscan: bool,
238 /// Copy previous line's leading whitespace on Enter in insert mode.
239 pub autoindent: bool,
240 /// Multi-key sequence timeout (e.g., `<C-w>v`). Vim's `timeoutlen`.
241 pub timeout_len: core::time::Duration,
242 /// Maximum undo-tree depth. Older entries pruned.
243 pub undo_levels: u32,
244 /// Break the current undo group on cursor motion in insert mode.
245 /// Matches vim default; turn off to merge multi-segment edits.
246 pub undo_break_on_motion: bool,
247 /// Reject every edit. `:set ro` sets this; `:w!` clears it.
248 pub readonly: bool,
249 /// Soft-wrap behavior for lines that exceed the viewport width.
250 /// Maps directly to `:set wrap` / `:set linebreak` / `:set nowrap`.
251 pub wrap: WrapMode,
252 /// Wrap column for `gq{motion}` text reflow. Vim's default is 79.
253 pub textwidth: u32,
254}
255
256/// Soft-wrap mode for the renderer + scroll math + `gj` / `gk`.
257/// Engine-native equivalent of [`hjkl_buffer::Wrap`]; the engine
258/// converts at the boundary to the buffer's runtime wrap setting.
259#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
260#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
261pub enum WrapMode {
262 /// Long lines extend past the right edge; `top_col` clips the
263 /// left side. Matches vim's `:set nowrap`.
264 #[default]
265 None,
266 /// Break at the cell boundary regardless of word edges. Matches
267 /// `:set wrap`.
268 Char,
269 /// Break at the last whitespace inside the visible width when
270 /// possible; falls back to a char break for runs longer than the
271 /// width. Matches `:set linebreak`.
272 Word,
273}
274
275/// Typed value for [`Options::set_by_name`] / [`Options::get_by_name`].
276///
277/// `:set tabstop=4` parses as `OptionValue::Int(4)`;
278/// `:set noexpandtab` parses as `OptionValue::Bool(false)`;
279/// `:set iskeyword=...` as `OptionValue::String(...)`.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum OptionValue {
282 Bool(bool),
283 Int(i64),
284 String(String),
285}
286
287impl Default for Options {
288 fn default() -> Self {
289 Options {
290 tabstop: 8,
291 shiftwidth: 8,
292 expandtab: false,
293 iskeyword: "@,48-57,_,192-255".to_string(),
294 ignorecase: false,
295 smartcase: false,
296 hlsearch: true,
297 incsearch: true,
298 wrapscan: true,
299 autoindent: true,
300 timeout_len: core::time::Duration::from_millis(1000),
301 undo_levels: 1000,
302 undo_break_on_motion: true,
303 readonly: false,
304 wrap: WrapMode::None,
305 textwidth: 79,
306 }
307 }
308}
309
310impl Options {
311 /// Set an option by name. Vim-flavored option naming. Returns
312 /// [`EngineError::Ex`] for unknown names or type-mismatched values.
313 ///
314 /// Booleans accept `OptionValue::Bool(_)` directly or
315 /// `OptionValue::Int(0)`/`Int(non_zero)`. Integers accept only
316 /// `Int(_)`. Strings accept only `String(_)`.
317 pub fn set_by_name(&mut self, name: &str, val: OptionValue) -> Result<(), EngineError> {
318 macro_rules! set_bool {
319 ($field:ident) => {{
320 self.$field = match val {
321 OptionValue::Bool(b) => b,
322 OptionValue::Int(n) => n != 0,
323 other => {
324 return Err(EngineError::Ex(format!(
325 "option `{name}` expects bool, got {other:?}"
326 )));
327 }
328 };
329 Ok(())
330 }};
331 }
332 macro_rules! set_u32 {
333 ($field:ident) => {{
334 self.$field = match val {
335 OptionValue::Int(n) if n >= 0 && n <= u32::MAX as i64 => n as u32,
336 OptionValue::Int(n) => {
337 return Err(EngineError::Ex(format!(
338 "option `{name}` out of u32 range: {n}"
339 )));
340 }
341 other => {
342 return Err(EngineError::Ex(format!(
343 "option `{name}` expects int, got {other:?}"
344 )));
345 }
346 };
347 Ok(())
348 }};
349 }
350 macro_rules! set_string {
351 ($field:ident) => {{
352 self.$field = match val {
353 OptionValue::String(s) => s,
354 other => {
355 return Err(EngineError::Ex(format!(
356 "option `{name}` expects string, got {other:?}"
357 )));
358 }
359 };
360 Ok(())
361 }};
362 }
363 match name {
364 "tabstop" | "ts" => set_u32!(tabstop),
365 "shiftwidth" | "sw" => set_u32!(shiftwidth),
366 "textwidth" | "tw" => set_u32!(textwidth),
367 "expandtab" | "et" => set_bool!(expandtab),
368 "iskeyword" | "isk" => set_string!(iskeyword),
369 "ignorecase" | "ic" => set_bool!(ignorecase),
370 "smartcase" | "scs" => set_bool!(smartcase),
371 "hlsearch" | "hls" => set_bool!(hlsearch),
372 "incsearch" | "is" => set_bool!(incsearch),
373 "wrapscan" | "ws" => set_bool!(wrapscan),
374 "autoindent" | "ai" => set_bool!(autoindent),
375 "timeoutlen" | "tm" => {
376 self.timeout_len = match val {
377 OptionValue::Int(n) if n >= 0 => core::time::Duration::from_millis(n as u64),
378 other => {
379 return Err(EngineError::Ex(format!(
380 "option `{name}` expects non-negative int (millis), got {other:?}"
381 )));
382 }
383 };
384 Ok(())
385 }
386 "undolevels" | "ul" => set_u32!(undo_levels),
387 "undobreak" => set_bool!(undo_break_on_motion),
388 "readonly" | "ro" => set_bool!(readonly),
389 "wrap" => {
390 let on = match val {
391 OptionValue::Bool(b) => b,
392 OptionValue::Int(n) => n != 0,
393 other => {
394 return Err(EngineError::Ex(format!(
395 "option `{name}` expects bool, got {other:?}"
396 )));
397 }
398 };
399 self.wrap = match (on, self.wrap) {
400 (false, _) => WrapMode::None,
401 (true, WrapMode::Word) => WrapMode::Word,
402 (true, _) => WrapMode::Char,
403 };
404 Ok(())
405 }
406 "linebreak" | "lbr" => {
407 let on = match val {
408 OptionValue::Bool(b) => b,
409 OptionValue::Int(n) => n != 0,
410 other => {
411 return Err(EngineError::Ex(format!(
412 "option `{name}` expects bool, got {other:?}"
413 )));
414 }
415 };
416 self.wrap = match (on, self.wrap) {
417 (true, _) => WrapMode::Word,
418 (false, WrapMode::Word) => WrapMode::Char,
419 (false, other) => other,
420 };
421 Ok(())
422 }
423 other => Err(EngineError::Ex(format!("unknown option `{other}`"))),
424 }
425 }
426
427 /// Read an option by name. `None` for unknown names.
428 pub fn get_by_name(&self, name: &str) -> Option<OptionValue> {
429 Some(match name {
430 "tabstop" | "ts" => OptionValue::Int(self.tabstop as i64),
431 "shiftwidth" | "sw" => OptionValue::Int(self.shiftwidth as i64),
432 "textwidth" | "tw" => OptionValue::Int(self.textwidth as i64),
433 "expandtab" | "et" => OptionValue::Bool(self.expandtab),
434 "iskeyword" | "isk" => OptionValue::String(self.iskeyword.clone()),
435 "ignorecase" | "ic" => OptionValue::Bool(self.ignorecase),
436 "smartcase" | "scs" => OptionValue::Bool(self.smartcase),
437 "hlsearch" | "hls" => OptionValue::Bool(self.hlsearch),
438 "incsearch" | "is" => OptionValue::Bool(self.incsearch),
439 "wrapscan" | "ws" => OptionValue::Bool(self.wrapscan),
440 "autoindent" | "ai" => OptionValue::Bool(self.autoindent),
441 "timeoutlen" | "tm" => OptionValue::Int(self.timeout_len.as_millis() as i64),
442 "undolevels" | "ul" => OptionValue::Int(self.undo_levels as i64),
443 "undobreak" => OptionValue::Bool(self.undo_break_on_motion),
444 "readonly" | "ro" => OptionValue::Bool(self.readonly),
445 "wrap" => OptionValue::Bool(!matches!(self.wrap, WrapMode::None)),
446 "linebreak" | "lbr" => OptionValue::Bool(matches!(self.wrap, WrapMode::Word)),
447 _ => return None,
448 })
449 }
450}
451
452/// Visible region of a buffer — the runtime viewport state the host
453/// owns and mutates per render frame.
454///
455/// 0.0.34 (Patch C-δ.1): semantic ownership moved from
456/// [`hjkl_buffer::Buffer`] to [`Host`]. The struct still lives in
457/// `hjkl-buffer` (alongside [`hjkl_buffer::Wrap`] and the rope-walking
458/// `wrap_segments` math it depends on) so the dependency graph stays
459/// `engine → buffer`; the engine re-exports it as
460/// [`crate::types::Viewport`] (this alias) for hosts that program to
461/// the SPEC surface.
462///
463/// The architectural decision is "viewport lives on Host, not Buffer":
464/// vim logic must work in GUI hosts (variable-width fonts, pixel
465/// canvases, soft-wrap by pixel) as well as TUI hosts, so the runtime
466/// viewport state is expressed in cells/rows/cols and is owned by the
467/// host. `top_row` and `top_col` are the first visible row / column
468/// (`top_col` is a char index).
469///
470/// `wrap` and `text_width` together drive soft-wrap-aware scrolling
471/// and motion. `text_width` is the cell width of the text area
472/// (i.e., `width` minus any gutter the host renders).
473pub use hjkl_buffer::Viewport;
474
475/// Opaque buffer identifier owned by the host. Engine echoes it back
476/// in [`Host::Intent`] variants for buffer-list operations
477/// (`SwitchBuffer`, etc.). Generation is the host's responsibility.
478#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
479pub struct BufferId(pub u64);
480
481/// Modifier bits accompanying every keystroke.
482#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
483pub struct Modifiers {
484 pub ctrl: bool,
485 pub shift: bool,
486 pub alt: bool,
487 pub super_: bool,
488}
489
490/// Special key codes — anything that isn't a printable character.
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
492#[non_exhaustive]
493pub enum SpecialKey {
494 Esc,
495 Enter,
496 Backspace,
497 Tab,
498 BackTab,
499 Up,
500 Down,
501 Left,
502 Right,
503 Home,
504 End,
505 PageUp,
506 PageDown,
507 Insert,
508 Delete,
509 F(u8),
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
513pub enum MouseKind {
514 Press,
515 Release,
516 Drag,
517 ScrollUp,
518 ScrollDown,
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
522pub struct MouseEvent {
523 pub kind: MouseKind,
524 pub pos: Pos,
525 pub mods: Modifiers,
526}
527
528/// Single input event handed to the engine.
529///
530/// `Paste` content bypasses insert-mode mappings, abbreviations, and
531/// autoindent; the engine inserts the bracketed-paste payload as-is.
532#[derive(Debug, Clone, PartialEq, Eq)]
533#[non_exhaustive]
534pub enum Input {
535 Char(char, Modifiers),
536 Key(SpecialKey, Modifiers),
537 Mouse(MouseEvent),
538 Paste(String),
539 FocusGained,
540 FocusLost,
541 Resize(u16, u16),
542}
543
544/// Host adapter consumed by the engine. Lives behind the planned
545/// `Editor<B: Buffer, H: Host>` generic; today it's the contract that
546/// `buffr-modal::BuffrHost` and the (future) `sqeel-tui` Host impl
547/// align against.
548///
549/// Methods with default impls return safe no-ops so hosts that don't
550/// need a feature (cancellation, wrap-aware motion, syntax highlights)
551/// can ignore them.
552pub trait Host: Send {
553 /// Custom intent type. Hosts that don't fan out actions back to
554 /// themselves can use the unit type via the default impl approach
555 /// (set associated type explicitly).
556 type Intent;
557
558 // ── Clipboard (hybrid: write fire-and-forget, read cached) ──
559
560 /// Fire-and-forget clipboard write. Engine never blocks; the host
561 /// queues internally and flushes on its own task (OSC52, `wl-copy`,
562 /// `pbcopy`, …).
563 fn write_clipboard(&mut self, text: String);
564
565 /// Returns the last-known cached clipboard value. May be stale —
566 /// matches the OSC52/wl-paste model neovim and helix both ship.
567 fn read_clipboard(&mut self) -> Option<String>;
568
569 // ── Time + cancellation ──
570
571 /// Monotonic time. Multi-key timeout (`timeoutlen`) resolution
572 /// reads this; engine never reads `Instant::now()` directly so
573 /// macro replay stays deterministic.
574 fn now(&self) -> core::time::Duration;
575
576 /// Cooperative cancellation. Engine polls during long search /
577 /// regex / multi-cursor edit loops. Default returns `false`.
578 fn should_cancel(&self) -> bool {
579 false
580 }
581
582 // ── Search prompt ──
583
584 /// Synchronously prompt the user for a search pattern. Returning
585 /// `None` aborts the search.
586 fn prompt_search(&mut self) -> Option<String>;
587
588 // ── Wrap-aware motion (default: wrap is identity) ──
589
590 /// Map a logical position to its display line for `gj`/`gk`. Hosts
591 /// without wrapping may use the default identity impl.
592 fn display_line_for(&self, pos: Pos) -> u32 {
593 pos.line
594 }
595
596 /// Inverse of [`display_line_for`]. Default identity.
597 fn pos_for_display(&self, line: u32, col: u32) -> Pos {
598 Pos { line, col }
599 }
600
601 // ── Syntax highlights (default: none) ──
602
603 /// Host-supplied syntax highlights for `range`. Empty by default;
604 /// hosts wire tree-sitter or LSP semantic tokens here.
605 fn syntax_highlights(&self, range: Range<Pos>) -> Vec<Highlight> {
606 let _ = range;
607 Vec::new()
608 }
609
610 // ── Cursor shape ──
611
612 /// Engine emits this on every mode transition. Hosts repaint the
613 /// cursor in the requested shape.
614 fn emit_cursor_shape(&mut self, shape: CursorShape);
615
616 // ── Viewport (host owns runtime viewport state) ──
617
618 /// Borrow the host's viewport. The host writes `width`/`height`/
619 /// `text_width`/`wrap` per render frame; the engine reads/writes
620 /// `top_row` / `top_col` to scroll. 0.0.34 (Patch C-δ.1) moved
621 /// this off [`hjkl_buffer::Buffer`] onto `Host`.
622 fn viewport(&self) -> &Viewport;
623
624 /// Mutable viewport access. Engine motion + scroll code routes
625 /// here when scrolloff math advances `top_row`.
626 fn viewport_mut(&mut self) -> &mut Viewport;
627
628 // ── Custom intent fan-out ──
629
630 /// Host-defined event the engine raises (LSP request, fold op,
631 /// buffer switch, …).
632 fn emit_intent(&mut self, intent: Self::Intent);
633}
634
635/// Default no-op [`Host`] implementation. Suitable for tests, headless
636/// embedding, or any host that doesn't yet need clipboard / cursor-shape
637/// / cancellation plumbing.
638///
639/// Behaviour:
640/// - `write_clipboard` stores the most recent payload in an in-memory
641/// slot; `read_clipboard` returns it. Round-trip-only — no OS-level
642/// clipboard touched.
643/// - `now` returns wall-clock duration since construction.
644/// - `prompt_search` returns `None` (search is aborted).
645/// - `emit_cursor_shape` records the most recent shape; readable via
646/// [`DefaultHost::last_cursor_shape`].
647/// - `emit_intent` discards intents (intent type is `()`).
648#[derive(Debug)]
649pub struct DefaultHost {
650 clipboard: Option<String>,
651 last_cursor_shape: CursorShape,
652 started: std::time::Instant,
653 viewport: Viewport,
654}
655
656impl Default for DefaultHost {
657 fn default() -> Self {
658 Self::new()
659 }
660}
661
662impl DefaultHost {
663 /// Default viewport size for headless / test hosts: 80x24, no
664 /// soft-wrap. Matches the conventional terminal default.
665 pub const DEFAULT_VIEWPORT: Viewport = Viewport {
666 top_row: 0,
667 top_col: 0,
668 width: 80,
669 height: 24,
670 wrap: hjkl_buffer::Wrap::None,
671 text_width: 80,
672 };
673
674 pub fn new() -> Self {
675 Self {
676 clipboard: None,
677 last_cursor_shape: CursorShape::Block,
678 started: std::time::Instant::now(),
679 viewport: Self::DEFAULT_VIEWPORT,
680 }
681 }
682
683 /// Construct a [`DefaultHost`] with a custom initial viewport.
684 /// Useful for tests that want to exercise scrolloff math at a
685 /// specific window size.
686 pub fn with_viewport(viewport: Viewport) -> Self {
687 Self {
688 clipboard: None,
689 last_cursor_shape: CursorShape::Block,
690 started: std::time::Instant::now(),
691 viewport,
692 }
693 }
694
695 /// Most recent cursor shape requested by the engine.
696 pub fn last_cursor_shape(&self) -> CursorShape {
697 self.last_cursor_shape
698 }
699}
700
701impl Host for DefaultHost {
702 type Intent = ();
703
704 fn write_clipboard(&mut self, text: String) {
705 self.clipboard = Some(text);
706 }
707
708 fn read_clipboard(&mut self) -> Option<String> {
709 self.clipboard.clone()
710 }
711
712 fn now(&self) -> core::time::Duration {
713 self.started.elapsed()
714 }
715
716 fn prompt_search(&mut self) -> Option<String> {
717 None
718 }
719
720 fn emit_cursor_shape(&mut self, shape: CursorShape) {
721 self.last_cursor_shape = shape;
722 }
723
724 fn viewport(&self) -> &Viewport {
725 &self.viewport
726 }
727
728 fn viewport_mut(&mut self) -> &mut Viewport {
729 &mut self.viewport
730 }
731
732 fn emit_intent(&mut self, _intent: Self::Intent) {}
733}
734
735/// Engine render frame consumed by the host once per redraw.
736///
737/// Borrow-style — the engine builds it on demand from its internal
738/// state without allocating clones of large fields. Hosts diff across
739/// frames to decide what to repaint.
740///
741/// Coarse today: covers mode, cursor, cursor shape, viewport top, and
742/// a snapshot of the current line count (to size the gutter). The
743/// SPEC-target fields (`selections`, `highlights`, `command_line`,
744/// `search_prompt`, `status_line`) land once trait extraction wires
745/// the FSM through `SelectionSet` and the highlight pipeline.
746#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
747pub struct RenderFrame {
748 pub mode: SnapshotMode,
749 pub cursor_row: u32,
750 pub cursor_col: u32,
751 pub cursor_shape: CursorShape,
752 pub viewport_top: u32,
753 pub line_count: u32,
754}
755
756/// Coarse editor snapshot suitable for serde round-tripping.
757///
758/// Today's shape is intentionally minimal — it carries only the bits
759/// the runtime [`crate::Editor`] knows how to round-trip without the
760/// trait extraction (mode, cursor, lines, viewport top, settings).
761/// Once `Editor<B: Buffer, H: Host>` ships under phase 5, this struct
762/// grows to cover full SPEC state: registers, marks, jump list, change
763/// list, undo tree, full options.
764///
765/// Hosts that persist editor state between sessions should:
766///
767/// - Treat the snapshot as opaque. Don't manually mutate fields.
768/// - Always check `version` after deserialization; reject on
769/// mismatch rather than attempt migration.
770///
771/// # Wire-format stability
772///
773/// - **0.0.x:** [`Self::VERSION`] bumps with every structural change to
774/// the snapshot. Hosts must reject mismatched persisted state — no
775/// migration path is offered.
776/// - **0.1.0:** [`Self::VERSION`] freezes. Hosts persisting editor state
777/// between sessions can rely on the wire format being stable for the
778/// entire 0.1.x line.
779/// - **0.2.0+:** any further structural change to this struct requires a
780/// `VERSION++` bump and is gated behind a major version bump of the
781/// crate.
782#[derive(Debug, Clone)]
783#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
784pub struct EditorSnapshot {
785 /// Format version. See [`Self::VERSION`] for the lock policy.
786 /// Hosts use this to detect mismatched persisted state.
787 pub version: u32,
788 /// Mode at snapshot time (status-line granularity).
789 pub mode: SnapshotMode,
790 /// Cursor `(row, col)` in byte indexing.
791 pub cursor: (u32, u32),
792 /// Buffer lines. Trailing `\n` not included.
793 pub lines: Vec<String>,
794 /// Viewport top line at snapshot time.
795 pub viewport_top: u32,
796 /// Register bank. Vim's `""`, `"0`–`"9`, `"a`–`"z`, `"+`/`"*`.
797 /// Skipped for `Eq`/`PartialEq` because [`crate::Registers`]
798 /// doesn't derive them today.
799 pub registers: crate::Registers,
800 /// Named marks — both lowercase (`'a`–`'z`, buffer-scope) and
801 /// uppercase (`'A`–`'Z`, file-scope). Round-trips across tab
802 /// swaps in the host.
803 ///
804 /// 0.0.36: consolidated from the prior `file_marks` field;
805 /// lowercase marks now persist as well since they live in the
806 /// same unified [`crate::Editor::marks`] map.
807 pub marks: std::collections::BTreeMap<char, (u32, u32)>,
808}
809
810/// Status-line mode summary. Bridges to the legacy
811/// [`crate::VimMode`] without leaking the full FSM type into the
812/// snapshot wire format.
813#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
814#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
815pub enum SnapshotMode {
816 #[default]
817 Normal,
818 Insert,
819 Visual,
820 VisualLine,
821 VisualBlock,
822}
823
824impl EditorSnapshot {
825 /// Current snapshot format version.
826 ///
827 /// Bumped to 2 in v0.0.8: registers added.
828 /// Bumped to 3 in v0.0.9: file_marks added.
829 /// Bumped to 4 in v0.0.36: file_marks → unified `marks` map
830 /// (lowercase + uppercase consolidated).
831 ///
832 /// # Lock policy
833 ///
834 /// - **0.0.x (today):** `VERSION` bumps freely with each structural
835 /// change to [`EditorSnapshot`]. Persisted state from an older
836 /// patch release will not round-trip; hosts must reject the
837 /// snapshot rather than attempt a field-by-field migration.
838 /// - **0.1.0:** `VERSION` freezes. Hosts persisting editor state
839 /// between sessions can rely on the wire format being stable for
840 /// the entire 0.1.x line.
841 /// - **0.2.0+:** any further structural change requires `VERSION++`
842 /// together with a major-version bump of `hjkl-engine`.
843 pub const VERSION: u32 = 4;
844}
845
846/// Errors surfaced from the engine to the host. Intentionally narrow —
847/// callsites that fail in user-facing ways return `Result<_,
848/// EngineError>`; internal invariant breaks use `debug_assert!`.
849#[derive(Debug, thiserror::Error)]
850pub enum EngineError {
851 /// `:s/pat/.../` couldn't compile the pattern. Host displays the
852 /// regex error in the status line.
853 #[error("regex compile error: {0}")]
854 Regex(#[from] regex::Error),
855
856 /// `:[range]` parse failed.
857 #[error("invalid range: {0}")]
858 InvalidRange(String),
859
860 /// Ex command parse failed (unknown command, malformed args).
861 #[error("ex parse: {0}")]
862 Ex(String),
863
864 /// Edit attempted on a read-only buffer.
865 #[error("buffer is read-only")]
866 ReadOnly,
867
868 /// Position passed by the caller pointed outside the buffer.
869 #[error("position out of bounds: {0:?}")]
870 OutOfBounds(Pos),
871
872 /// Snapshot version mismatch. Host should treat as "abandon
873 /// snapshot" rather than attempt migration.
874 #[error("snapshot version mismatch: file={0}, expected={1}")]
875 SnapshotVersion(u32, u32),
876}
877
878pub(crate) mod sealed {
879 /// Sealing trait for the planned 0.1.0 [`super::Buffer`] surface.
880 /// Pre-1.0 the engine reserves the right to add methods to the
881 /// `Buffer` super-trait without a major bump; downstream cannot
882 /// `impl Buffer` from outside this family.
883 ///
884 /// The in-tree [`hjkl_buffer::Buffer`] is the canonical impl; the
885 /// `Sealed` marker for it lives in `crate::buffer_impl`. The module
886 /// itself stays `pub(crate)` so the sibling impl module can name
887 /// the trait while keeping the seal closed to the outside world.
888 pub trait Sealed {}
889}
890
891/// Cursor sub-trait of [`Buffer`]. Pre-0.1.0; signature follows
892/// SPEC.md §"`Buffer` trait surface".
893///
894/// `Pos` here is the engine's grapheme-indexed [`Pos`] type. Buffer
895/// implementations convert at the boundary if their internal indexing
896/// differs (e.g., the rope's byte indexing).
897pub trait Cursor: Send {
898 /// Active primary cursor position.
899 fn cursor(&self) -> Pos;
900 /// Move the active primary cursor.
901 fn set_cursor(&mut self, pos: Pos);
902 /// Byte offset for `pos`. Used by regex search bridges.
903 fn byte_offset(&self, pos: Pos) -> usize;
904 /// Inverse of [`Self::byte_offset`].
905 fn pos_at_byte(&self, byte: usize) -> Pos;
906}
907
908/// Read-only query sub-trait of [`Buffer`].
909pub trait Query: Send {
910 /// Number of logical lines (excluding the implicit trailing line).
911 fn line_count(&self) -> u32;
912 /// Borrow line `idx` (0-based). Implementations should panic on
913 /// out-of-bounds rather than silently return empty.
914 fn line(&self, idx: u32) -> &str;
915 /// Total buffer length in bytes.
916 fn len_bytes(&self) -> usize;
917 /// Slice for the half-open `range`. May allocate (rope joins)
918 /// or borrow (contiguous storage). Returns
919 /// [`std::borrow::Cow<'_, str>`] so contiguous backends can
920 /// avoid the allocation.
921 fn slice(&self, range: core::ops::Range<Pos>) -> std::borrow::Cow<'_, str>;
922 /// Monotonic mutation generation counter. Increments on every
923 /// content-changing call (insert / delete / replace / fold-touch
924 /// edit / `set_content`). Read-only ops (cursor moves, queries,
925 /// view changes) leave it untouched.
926 ///
927 /// Engine consumers cache per-row data (search-match positions,
928 /// syntax spans, wrap layout) keyed off this counter — when it
929 /// advances, the cache is invalidated.
930 ///
931 /// Implementations may return any monotonically non-decreasing
932 /// value (zero is fine for non-canonical impls that don't have a
933 /// caching story); the contract is "if `dirty_gen` changed, the
934 /// content **may** have changed."
935 fn dirty_gen(&self) -> u64 {
936 0
937 }
938}
939
940/// Mutating sub-trait of [`Buffer`]. Distinct trait name from the
941/// crate-root [`Edit`] struct — this one carries methods, the other
942/// is a value type.
943pub trait BufferEdit: Send {
944 /// Insert `text` at `pos`. Implementations clamp out-of-range
945 /// positions to the document end.
946 fn insert_at(&mut self, pos: Pos, text: &str);
947 /// Delete the half-open `range`.
948 fn delete_range(&mut self, range: core::ops::Range<Pos>);
949 /// Replace the half-open `range` with `replacement`.
950 fn replace_range(&mut self, range: core::ops::Range<Pos>, replacement: &str);
951 /// Replace the entire buffer content with `text`. The cursor is
952 /// clamped to the surviving content. Used by `:e!` / undo
953 /// restore / snapshot replay where expressing "replace whole
954 /// buffer" via [`replace_range`] would require knowing the end
955 /// position. Default impl uses [`replace_range`] with a
956 /// best-effort end (`u32::MAX` / `u32::MAX`); the canonical
957 /// in-tree impl overrides it for a single-shot rebuild.
958 fn replace_all(&mut self, text: &str) {
959 self.replace_range(
960 Pos::ORIGIN..Pos {
961 line: u32::MAX,
962 col: u32::MAX,
963 },
964 text,
965 );
966 }
967}
968
969/// Search sub-trait of [`Buffer`]. The pattern is owned by the engine
970/// (see SPEC.md "Open issues"); buffers do not cache compiled regexes.
971pub trait Search: Send {
972 /// First match at-or-after `from`. `None` when no match remains.
973 fn find_next(&self, from: Pos, pat: ®ex::Regex) -> Option<core::ops::Range<Pos>>;
974 /// Last match at-or-before `from`.
975 fn find_prev(&self, from: Pos, pat: ®ex::Regex) -> Option<core::ops::Range<Pos>>;
976}
977
978/// Buffer super-trait — the pre-1.0 contract every backend implements.
979///
980/// Sealed to the engine's own crate family (in-tree
981/// `hjkl_buffer::Buffer` is the canonical impl). Pre-0.1.0 the engine
982/// reserves the right to add methods on patch bumps; downstream
983/// consumers depend on the full trait without naming
984/// [`sealed::Sealed`].
985pub trait Buffer: Cursor + Query + BufferEdit + Search + sealed::Sealed + Send {}
986
987/// Canonical fold-mutation op carried through [`FoldProvider::apply`].
988///
989/// Introduced in 0.0.38 (Patch C-δ.4). The engine raises one `FoldOp`
990/// per `z…` keystroke / `:fold*` Ex command and dispatches it through
991/// the [`FoldProvider::apply`] surface. Hosts that own the fold storage
992/// (default in-tree wraps `&mut hjkl_buffer::Buffer`) decide how to
993/// apply it — possibly batching, deduping, or vetoing. Hosts without
994/// folds use [`NoopFoldProvider`] which silently discards every op.
995///
996/// `FoldOp` is engine-canonical (per the design doc's resolved
997/// question 8.2): hosts don't invent their own fold-op enums. Each
998/// host that exposes folds embeds a `FoldOp` variant in its `Intent`
999/// enum (or simply observes the engine's pending-fold-op queue via
1000/// [`crate::Editor::take_fold_ops`]).
1001///
1002/// Row indices are zero-based and match the row coordinate space used
1003/// by [`hjkl_buffer::Buffer`]'s fold methods.
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1005#[non_exhaustive]
1006pub enum FoldOp {
1007 /// `:fold {start,end}` / `zf{motion}` / visual-mode `zf` — register a
1008 /// new fold spanning `[start_row, end_row]` (inclusive). The `closed`
1009 /// flag matches the underlying [`hjkl_buffer::Fold::closed`].
1010 Add {
1011 start_row: usize,
1012 end_row: usize,
1013 closed: bool,
1014 },
1015 /// `zd` — drop the fold under `row` if any.
1016 RemoveAt(usize),
1017 /// `zo` — open the fold under `row` if any.
1018 OpenAt(usize),
1019 /// `zc` — close the fold under `row` if any.
1020 CloseAt(usize),
1021 /// `za` — flip the fold under `row` between open / closed.
1022 ToggleAt(usize),
1023 /// `zR` — open every fold in the buffer.
1024 OpenAll,
1025 /// `zM` — close every fold in the buffer.
1026 CloseAll,
1027 /// `zE` — eliminate every fold.
1028 ClearAll,
1029 /// Edit-driven fold invalidation. Drops every fold touching the
1030 /// row range `[start_row, end_row]`. Mirrors vim's "edits inside a
1031 /// fold open it" behaviour. Fired by the engine's edit pipeline,
1032 /// not bound to a `z…` keystroke.
1033 Invalidate { start_row: usize, end_row: usize },
1034}
1035
1036/// Fold-iteration + mutation trait. The engine asks "what's the next
1037/// visible row" / "is this row hidden" through this surface, and
1038/// dispatches fold mutations through [`FoldProvider::apply`], so fold
1039/// storage can live wherever the host pleases (on the buffer, in a
1040/// separate host-side fold tree, or absent entirely).
1041///
1042/// Introduced in 0.0.32 (Patch C-β) for read access; 0.0.38 (Patch
1043/// C-δ.4) added [`FoldProvider::apply`] + [`FoldProvider::invalidate_range`]
1044/// so engine call sites that used to call
1045/// `hjkl_buffer::Buffer::{open,close,toggle,…}_fold_at` directly route
1046/// through this trait now. The canonical read-only implementation
1047/// [`crate::buffer_impl::BufferFoldProvider`] wraps a
1048/// `&hjkl_buffer::Buffer`; the canonical mutable implementation
1049/// [`crate::buffer_impl::BufferFoldProviderMut`] wraps a
1050/// `&mut hjkl_buffer::Buffer`. Hosts that don't care about folds can
1051/// use [`NoopFoldProvider`].
1052///
1053/// The engine carries a `Box<dyn FoldProvider + 'a>` slot today and
1054/// looks up rows through it. Once `Editor<B, H>` flips generic
1055/// (Patch C, 0.1.0) the slot moves onto `Host` directly.
1056pub trait FoldProvider: Send {
1057 /// First visible row strictly after `row`, skipping hidden rows.
1058 /// `None` past the end of the buffer.
1059 fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize>;
1060 /// First visible row strictly before `row`. `None` past the top.
1061 fn prev_visible_row(&self, row: usize) -> Option<usize>;
1062 /// Is `row` currently hidden by a closed fold?
1063 fn is_row_hidden(&self, row: usize) -> bool;
1064 /// Range `(start_row, end_row, closed)` of the fold containing
1065 /// `row`, if any. Lets `za` / `zo` / `zc` find their target
1066 /// without iterating the full fold list.
1067 fn fold_at_row(&self, row: usize) -> Option<(usize, usize, bool)>;
1068
1069 /// Apply a [`FoldOp`] to the underlying fold storage. Read-only
1070 /// providers (e.g. [`crate::buffer_impl::BufferFoldProvider`] which
1071 /// holds a `&Buffer`) and providers that don't track folds (e.g.
1072 /// [`NoopFoldProvider`]) implement this as a no-op.
1073 ///
1074 /// Default impl is a no-op so that read-only / host-stub providers
1075 /// don't need to override it; mutable providers
1076 /// (e.g. [`crate::buffer_impl::BufferFoldProviderMut`]) override
1077 /// this to dispatch to the underlying buffer's fold methods.
1078 fn apply(&mut self, op: FoldOp) {
1079 let _ = op;
1080 }
1081
1082 /// Drop every fold whose range overlaps `[start_row, end_row]`.
1083 /// Edit pipelines call this after a user edit so vim's "edits
1084 /// inside a fold open it" behaviour fires. Default impl forwards
1085 /// to [`FoldProvider::apply`] with a [`FoldOp::Invalidate`].
1086 fn invalidate_range(&mut self, start_row: usize, end_row: usize) {
1087 self.apply(FoldOp::Invalidate { start_row, end_row });
1088 }
1089}
1090
1091/// No-op [`FoldProvider`] for hosts that don't expose folds. Every
1092/// row is visible; `is_row_hidden` always returns `false`.
1093#[derive(Debug, Default, Clone, Copy)]
1094pub struct NoopFoldProvider;
1095
1096impl FoldProvider for NoopFoldProvider {
1097 fn next_visible_row(&self, row: usize, row_count: usize) -> Option<usize> {
1098 let last = row_count.saturating_sub(1);
1099 if last == 0 && row == 0 {
1100 return None;
1101 }
1102 let r = row.checked_add(1)?;
1103 (r <= last).then_some(r)
1104 }
1105
1106 fn prev_visible_row(&self, row: usize) -> Option<usize> {
1107 row.checked_sub(1)
1108 }
1109
1110 fn is_row_hidden(&self, _row: usize) -> bool {
1111 false
1112 }
1113
1114 fn fold_at_row(&self, _row: usize) -> Option<(usize, usize, bool)> {
1115 None
1116 }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122
1123 #[test]
1124 fn caret_is_empty() {
1125 let sel = Selection::caret(Pos::new(2, 4));
1126 assert!(sel.is_empty());
1127 assert_eq!(sel.anchor, sel.head);
1128 }
1129
1130 #[test]
1131 fn selection_set_default_has_one_caret() {
1132 let set = SelectionSet::default();
1133 assert_eq!(set.items.len(), 1);
1134 assert_eq!(set.primary, 0);
1135 assert_eq!(set.primary().anchor, Pos::ORIGIN);
1136 }
1137
1138 #[test]
1139 fn edit_constructors() {
1140 let p = Pos::new(0, 5);
1141 assert_eq!(Edit::insert(p, "x").range, p..p);
1142 assert!(Edit::insert(p, "x").replacement == "x");
1143 assert!(Edit::delete(p..p).replacement.is_empty());
1144 }
1145
1146 #[test]
1147 fn attrs_flags() {
1148 let a = Attrs::BOLD | Attrs::UNDERLINE;
1149 assert!(a.contains(Attrs::BOLD));
1150 assert!(!a.contains(Attrs::ITALIC));
1151 }
1152
1153 #[test]
1154 fn options_set_get_roundtrip() {
1155 let mut o = Options::default();
1156 o.set_by_name("tabstop", OptionValue::Int(4)).unwrap();
1157 assert!(matches!(o.get_by_name("ts"), Some(OptionValue::Int(4))));
1158 o.set_by_name("expandtab", OptionValue::Bool(true)).unwrap();
1159 assert!(matches!(o.get_by_name("et"), Some(OptionValue::Bool(true))));
1160 o.set_by_name("iskeyword", OptionValue::String("a-z".into()))
1161 .unwrap();
1162 match o.get_by_name("iskeyword") {
1163 Some(OptionValue::String(s)) => assert_eq!(s, "a-z"),
1164 other => panic!("expected String, got {other:?}"),
1165 }
1166 }
1167
1168 #[test]
1169 fn options_unknown_name_errors_on_set() {
1170 let mut o = Options::default();
1171 assert!(matches!(
1172 o.set_by_name("frobnicate", OptionValue::Int(1)),
1173 Err(EngineError::Ex(_))
1174 ));
1175 assert!(o.get_by_name("frobnicate").is_none());
1176 }
1177
1178 #[test]
1179 fn options_type_mismatch_errors() {
1180 let mut o = Options::default();
1181 assert!(matches!(
1182 o.set_by_name("tabstop", OptionValue::String("nope".into())),
1183 Err(EngineError::Ex(_))
1184 ));
1185 assert!(matches!(
1186 o.set_by_name("iskeyword", OptionValue::Int(7)),
1187 Err(EngineError::Ex(_))
1188 ));
1189 }
1190
1191 #[test]
1192 fn options_int_to_bool_coercion() {
1193 // `:set ic=0` reads as boolean false; `:set ic=1` as true.
1194 // Common vim spelling.
1195 let mut o = Options::default();
1196 o.set_by_name("ignorecase", OptionValue::Int(1)).unwrap();
1197 assert!(matches!(o.get_by_name("ic"), Some(OptionValue::Bool(true))));
1198 o.set_by_name("ignorecase", OptionValue::Int(0)).unwrap();
1199 assert!(matches!(
1200 o.get_by_name("ic"),
1201 Some(OptionValue::Bool(false))
1202 ));
1203 }
1204
1205 #[test]
1206 fn options_wrap_linebreak_roundtrip() {
1207 let mut o = Options::default();
1208 assert_eq!(o.wrap, WrapMode::None);
1209 o.set_by_name("wrap", OptionValue::Bool(true)).unwrap();
1210 assert_eq!(o.wrap, WrapMode::Char);
1211 o.set_by_name("linebreak", OptionValue::Bool(true)).unwrap();
1212 assert_eq!(o.wrap, WrapMode::Word);
1213 assert!(matches!(
1214 o.get_by_name("wrap"),
1215 Some(OptionValue::Bool(true))
1216 ));
1217 assert!(matches!(
1218 o.get_by_name("lbr"),
1219 Some(OptionValue::Bool(true))
1220 ));
1221 o.set_by_name("linebreak", OptionValue::Bool(false))
1222 .unwrap();
1223 assert_eq!(o.wrap, WrapMode::Char);
1224 o.set_by_name("wrap", OptionValue::Bool(false)).unwrap();
1225 assert_eq!(o.wrap, WrapMode::None);
1226 }
1227
1228 #[test]
1229 fn options_default_matches_vim() {
1230 let o = Options::default();
1231 assert_eq!(o.tabstop, 8);
1232 assert!(!o.expandtab);
1233 assert!(o.hlsearch);
1234 assert!(o.wrapscan);
1235 assert_eq!(o.timeout_len, core::time::Duration::from_millis(1000));
1236 }
1237
1238 #[test]
1239 fn editor_snapshot_version_const() {
1240 assert_eq!(EditorSnapshot::VERSION, 4);
1241 }
1242
1243 #[test]
1244 fn editor_snapshot_default_shape() {
1245 let s = EditorSnapshot {
1246 version: EditorSnapshot::VERSION,
1247 mode: SnapshotMode::Normal,
1248 cursor: (0, 0),
1249 lines: vec!["hello".to_string()],
1250 viewport_top: 0,
1251 registers: crate::Registers::default(),
1252 marks: Default::default(),
1253 };
1254 assert_eq!(s.cursor, (0, 0));
1255 assert_eq!(s.lines.len(), 1);
1256 }
1257
1258 #[cfg(feature = "serde")]
1259 #[test]
1260 fn editor_snapshot_roundtrip() {
1261 let mut marks = std::collections::BTreeMap::new();
1262 marks.insert('A', (5u32, 2u32));
1263 marks.insert('a', (1u32, 0u32));
1264 let s = EditorSnapshot {
1265 version: EditorSnapshot::VERSION,
1266 mode: SnapshotMode::Insert,
1267 cursor: (3, 7),
1268 lines: vec!["alpha".into(), "beta".into()],
1269 viewport_top: 2,
1270 registers: crate::Registers::default(),
1271 marks,
1272 };
1273 let json = serde_json::to_string(&s).unwrap();
1274 let back: EditorSnapshot = serde_json::from_str(&json).unwrap();
1275 assert_eq!(s.cursor, back.cursor);
1276 assert_eq!(s.lines, back.lines);
1277 assert_eq!(s.viewport_top, back.viewport_top);
1278 }
1279
1280 #[test]
1281 fn engine_error_display() {
1282 let e = EngineError::ReadOnly;
1283 assert_eq!(e.to_string(), "buffer is read-only");
1284 let e = EngineError::OutOfBounds(Pos::new(3, 7));
1285 assert!(e.to_string().contains("out of bounds"));
1286 }
1287}