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