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