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