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