escriba_runtime/lib.rs
1//! `escriba-runtime` — editor state machine.
2//!
3//! Wraps everything: `BufferSet`, `ModalState`, `Keymap`, `CommandRegistry`,
4//! `Layout`. Exposes `tick(input)` which advances one frame's worth of
5//! state given one input event. Pure — no rendering, no I/O beyond file
6//! save/load through `BufferSet`.
7
8extern crate self as escriba_runtime;
9
10mod plugin_host;
11pub use plugin_host::{LazyTrigger, PluginHost};
12
13mod operator_pending;
14pub mod status;
15
16pub use operator_pending::{OpState, OperatorPending};
17pub use status::{PromptKind, StatusModel};
18
19use std::collections::HashMap;
20
21use awase::KeyRepeatGate;
22use escriba_buffer::BufferSet;
23use escriba_buffer::TextRev;
24use escriba_command::CommandRegistry;
25use escriba_core::{
26 Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
27 Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
28};
29use escriba_input::{InputOutcome, translate_app_event};
30use escriba_keymap::{Key, Keymap};
31use escriba_madoguchi::{Negai, Outcome};
32use escriba_mode::ModalState;
33use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
34use escriba_ui::chrome::{ChromePalette, FleetTheme};
35use escriba_ui::splash::Splash;
36use escriba_ui::{Layout, Rect, Viewport, Window};
37use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
38use madori::AppEvent;
39use std::time::Instant;
40
41/// Full editor state — the single Rust value the binary hands to the
42/// renderer each frame.
43pub struct EditorState {
44 pub buffers: BufferSet,
45 pub modal: ModalState,
46 /// Search session — the committed pattern, its matches, the live `/`
47 /// prompt and history. Owns no buffer or cursor; it answers questions
48 /// about text and this runtime applies the answers.
49 pub search: SearchState,
50 pub keymap: Keymap,
51 pub commands: CommandRegistry,
52 pub layout: Layout,
53 pub active: BufferId,
54 /// The single typed home for cursor state. Phase-1 holds one primary
55 /// [`Position`]; reads go through [`Self::cursor`], writes through
56 /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
57 /// `Position` field beside an unused multi-caret type to desync.
58 cursors: Cursors,
59 pub quit_requested: bool,
60 /// Messages surfaced to the user (status line / `:messages`) — the
61 /// sink for the tatara-lisp `(message …)` effect and other feedback.
62 pub messages: Vec<String>,
63 /// Which match the cursor last landed on (0-based) — the `[3/17]`
64 /// numerator, ANCHORED to the text revision it was computed against.
65 ///
66 /// The anchor is what removes the manual invalidation this field used to
67 /// need. An ordinal indexes a match set; when the text changes the set
68 /// changes underneath it and the number silently means something else.
69 /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
70 /// forgetting to clear is no longer a thing that can be forgotten.
71 search_at: Option<Anchored<usize, TextRev>>,
72 /// The last text change, for `.`.
73 ///
74 /// An action plus whatever was typed while it held Insert open. Both
75 /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
76 /// one — the text that followed is the rest, and replaying without it
77 /// would delete a word and leave the buffer in Insert.
78 last_change: Option<LastChange>,
79 /// True while an insert session belonging to `last_change` is open, so
80 /// typed characters are appended to it. Cleared on leaving Insert.
81 recording_insert: bool,
82
83 /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
84 /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
85 /// makes a search a place you can come back from.
86 pub jumps: JumpList,
87 /// Generic editor option store (name → value). Written by the
88 /// tatara-lisp `(set-option …)` effect and the declarative
89 /// `defoption` apply path; typed accessors layer on top later.
90 pub options: HashMap<String, String>,
91 /// Cached embedded tatara-lisp runtime, built lazily on first
92 /// `run_lisp`. Caching avoids re-installing the ~175-definition full
93 /// stdlib on every call; the interpreter's top-level env also
94 /// persists across calls, giving REPL-like session semantics (an
95 /// earlier `(define …)` is visible to a later `run_lisp`).
96 lisp_vm: Option<EscribaVm>,
97 /// Keys accumulated for an in-progress multi-key sequence — e.g.
98 /// holding `[,, f]` while waiting for the final key of
99 /// `<leader>ff`. Empty when not mid-sequence. Lives on
100 /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
101 /// depend on `escriba-keymap`'s `Key`.
102 pub pending_keys: Vec<Key>,
103 /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
104 /// the windowing system deliver one `KeyDown` per repeat tick
105 /// (~30-50ms); without a gate those flood the motion path and thrash
106 /// the viewport. The gate lets ONE event per `min_interval` (80ms
107 /// default — ~12 intentional taps/sec still pass) reach the editor in
108 /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
109 /// the same one mado uses) is reused — not reinvented.
110 repeat_gate: KeyRepeatGate<Key>,
111 /// Runtime lazy-activation host for USER plugin caixas (the bundled
112 /// default catalog is applied eagerly at boot, not through here).
113 /// A command / filetype-open / event fires the matching plugins'
114 /// entries through the escriba-lisp apply paths. See [`PluginHost`].
115 pub plugin_host: PluginHost,
116 /// The unnamed register — the home for text an operator yanks or
117 /// deletes (`Operator::leaves_register`). `None` until the first
118 /// register-leaving operator runs. Phase-1 holds the single unnamed
119 /// register; named registers (`"ay`) layer on later.
120 register: Option<String>,
121 /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
122 /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
123 /// action passes through it; only an operator-then-motion pair is rewritten
124 /// into an [`Action::ApplyOperator`].
125 op_pending: zenmai::Stateful<OperatorPending>,
126 /// Monotonic refresh-generation stamp — the root of the sealed refresh
127 /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
128 /// action + resize; the renderer gates on it so an idle frame does zero
129 /// re-highlight / re-shape, and a stale frame is unreachable.
130 edit_gen: EditGen,
131 /// The accumulated dirty region since the renderer last drained it (M1).
132 /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
133 /// always covers the changed region (`Damage ⊇ changed`); the renderer
134 /// drains it with [`take_damage`](Self::take_damage) to scope its work.
135 damage: Damage,
136 /// The theme every face paints with.
137 ///
138 /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
139 /// resolved to a real `FleetTheme` — and then nothing consumed it,
140 /// because each renderer called `ChromePalette::prescribed()` at every
141 /// paint site. The declaration was honoured on paper only. Holding it
142 /// here means a face reads the operator's theme the same way it reads
143 /// the cursor: from the state, per frame.
144 theme: FleetTheme,
145 /// `theme` resolved to concrete colours — cached because it is a plain
146 /// `Copy` struct read many times per frame, and re-derived only in
147 /// [`set_theme`](Self::set_theme), so the two cannot disagree.
148 chrome: ChromePalette,
149 /// How deep the current command dispatch is nested.
150 ///
151 /// `Negai::RunCommand` lets a command invoke a command, which is useful
152 /// and which can also recurse forever. The budget makes the runaway
153 /// bounded and REPORTED rather than a stack overflow — the difference
154 /// between a typed refusal and the editor dying under the operator.
155 dispatch_depth: u8,
156 /// Every live result list — diagnostics, hunks, grep hits, TODOs.
157 ///
158 /// Public so a producer outside the runtime can publish into it once the
159 /// courier lands; today the only producer is the marker scan.
160 pub results: escriba_shirube::ListRegistry,
161 /// Extension → language facts, populated from `(defmode …)`.
162 ///
163 /// The consumer `:commentstring` never had. Public so the binary's apply
164 /// pass can fill it the way it fills the keymap and the option store.
165 pub filetypes: escriba_core::FiletypeTable,
166 /// The start screen, while it is up.
167 ///
168 /// `Some` only between boot and the first keypress, and only when the
169 /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
170 /// is a state keys are interpreted *in*, and the splash interprets
171 /// exactly one key before it is gone. Modelling it as `Option<Splash>`
172 /// keeps the modal state machine's variant set — and every exhaustive
173 /// match over it — untouched.
174 splash: Option<Splash>,
175}
176
177/// What the start screen did with a keypress.
178///
179/// Total, and matched exhaustively at its one call site, so a future
180/// outcome (a menu that opens a submenu, say) is a compile error rather
181/// than a key that silently falls through to the buffer.
182enum SplashKey {
183 /// No start screen is up — the key is the buffer's.
184 NotShowing,
185 /// The key selected a menu entry; run this.
186 Ran(Action),
187 /// The screen is gone and the key was not a menu key, so it still
188 /// means whatever it normally means. Anything else would make the
189 /// first keystroke after boot vanish.
190 Dismissed,
191}
192
193// ─── The counter, and the one place slips become mutations ───────────────
194
195/// `EditorState` read through the counter.
196///
197/// Borrowed, never copied: building it is free, so a command dispatch does
198/// not pay for a snapshot of the buffers.
199pub struct EditorWindow<'a> {
200 state: &'a EditorState,
201}
202
203impl escriba_madoguchi::CursorView for EditorWindow<'_> {
204 fn position(&self) -> Position {
205 self.state.cursor()
206 }
207 fn mode(&self) -> Mode {
208 self.state.modal.mode()
209 }
210}
211
212impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
213 fn filetype(&self) -> Option<&escriba_core::Filetype> {
214 let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
215 self.state.filetypes.resolve(path)
216 }
217}
218
219impl escriba_madoguchi::SearchView for EditorWindow<'_> {
220 fn pattern(&self) -> Option<&str> {
221 self.state.search.committed_pattern()
222 }
223 fn match_count(&self) -> Option<usize> {
224 // `None` means "nothing committed", which is not the same as zero
225 // matches — a distinction the status line already makes and that a
226 // handler must not have to re-derive.
227 self.state
228 .search
229 .committed_pattern()
230 .map(|_| self.state.search.match_count())
231 }
232 fn is_prompting(&self) -> bool {
233 self.state.search.is_prompting()
234 }
235}
236
237impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
238 fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
239 self.buffer(self.state.active)
240 }
241 fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
242 self.state
243 .buffers
244 .get(id)
245 .map(|b| b as &dyn escriba_madoguchi::BufferView)
246 }
247 fn buffer_ids(&self) -> Vec<BufferId> {
248 self.state.buffers.ids()
249 }
250 fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
251 self
252 }
253 fn option(&self, name: &str) -> Option<&str> {
254 self.state.options.get(name).map(String::as_str)
255 }
256 fn search(&self) -> &dyn escriba_madoguchi::SearchView {
257 self
258 }
259 fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
260 self
261 }
262}
263
264impl EditorState {
265 /// A read-only window onto this editor.
266 #[must_use]
267 pub fn window(&self) -> EditorWindow<'_> {
268 EditorWindow { state: self }
269 }
270
271 /// Honour an [`Outcome`] — the ONLY place slips become mutations.
272 ///
273 /// Every `&mut self` in the dispatch path lives here. A command cannot
274 /// reach editor state, so if the editor ends up in a state nobody
275 /// designed, this function is where it happened; that narrowing is the
276 /// whole return on the seam.
277 ///
278 /// A failed outcome's slips are DROPPED rather than half-applied: a
279 /// handler that reported failure has no business also mutating, and
280 /// applying part of what it asked for is how an editor reaches a state
281 /// nobody designed.
282 pub fn interpret(&mut self, outcome: Outcome) {
283 if let Some(m) = outcome.verdict.message() {
284 self.messages.push(m.to_string());
285 self.damage = self.damage.join(Damage::Viewport);
286 self.bump_gen();
287 }
288 if outcome.verdict.is_failure() {
289 return;
290 }
291 for slip in outcome.slips {
292 self.honour(slip);
293 }
294 }
295
296 /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
297 ///
298 /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
299 /// editing, the operator-pending FSM, motion resolution, the jumplist,
300 /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
301 /// one, and forcing them into `Negai` would put `PromptClearToStart` and
302 /// `SearchPreviewStep` in front of every plugin author and make the
303 /// capability question meaningless (what capability does a caret move
304 /// read?). One type serving two vocabularies is the mistake this avoids.
305 ///
306 /// The plan's M3 predicate was "apply_resolved contains zero `self.`
307 /// mutations", which would have forced exactly that. Amended: the
308 /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
309 /// vocabulary. See docs/backlog-plan.md §V Phase 1.
310 fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
311 Some(match action {
312 Action::Quit => vec![Negai::Quit],
313 Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
314 Action::Save => vec![Negai::Save { buffer: active }],
315 Action::Undo => vec![Negai::Undo { buffer: active }],
316 Action::Redo => vec![Negai::Redo { buffer: active }],
317 // `apply_edit` was a STUB that did nothing, so this action was a
318 // silent no-op while `Negai::Edit` applied for real. Lowering it
319 // makes keymap-originated edits work for the first time — and
320 // nothing binds it today, so the duplication goes away at zero
321 // risk.
322 Action::Edit(edit) => vec![Negai::Edit {
323 buffer: active,
324 edit: edit.clone(),
325 }],
326 _ => return None,
327 })
328 }
329
330 /// What the world currently is, for freshness.
331 ///
332 /// One text axis per open buffer. A list sealed against this is fresh
333 /// exactly while the buffers it depends on are unchanged — and a buffer
334 /// that has since CLOSED drops out, which makes lists about it stale
335 /// rather than silently kept.
336 #[must_use]
337 pub fn world(&self) -> escriba_shirube::Anchor {
338 let mut a = escriba_shirube::Anchor::new();
339 for id in self.buffers.ids() {
340 if let Some(b) = self.buffers.get(id) {
341 a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
342 }
343 }
344 a
345 }
346
347 /// Move the cursor to the next/previous finding in `list`.
348 ///
349 /// Reports the wrap, because `n`/`N` do and a reader losing their place
350 /// in a long file is the same problem either way.
351 fn walk_list(&mut self, list: &str, forward: bool) {
352 let world = self.world();
353 let Some(result) = self.results.get(list) else {
354 let mut m = String::from("no list named ");
355 m.push_str(list);
356 self.messages.push(m);
357 return;
358 };
359 if result.is_stale(&world) {
360 self.messages
361 .push("that list is out of date — run it again".to_string());
362 return;
363 }
364 let here = self.cursor().line;
365 let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
366 else {
367 let mut m = String::from("no entries in ");
368 m.push_str(list);
369 self.messages.push(m);
370 return;
371 };
372 let to = found.site.range.start;
373 let msg = found.message.clone();
374 // A far jump, so it belongs in the jumplist — `<C-o>` must come back
375 // from a `]t` exactly as it comes back from an `n`.
376 self.jumps.push(self.cursor());
377 let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
378 self.set_cursor(clamped);
379 self.messages.push(msg);
380 }
381
382 /// Close a buffer, keeping "there is always an active buffer" true.
383 ///
384 /// The invariant is the whole reason this is not just
385 /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
386 /// an `Option`, so a dangling active is not a degraded state — it is a
387 /// state where every read of the active buffer returns `None` and the
388 /// editor renders `<no buffer>` forever. Closing the last buffer opens a
389 /// scratch rather than emptying the set, which is what vim's `:bd` does
390 /// and what the type demands.
391 fn close_buffer(&mut self, id: BufferId) {
392 if self.buffers.close(id).is_none() {
393 self.messages.push("no such buffer".to_string());
394 return;
395 }
396 if self.active != id {
397 return;
398 }
399 // The active buffer went. Prefer the next one by id so repeated
400 // closes walk forward predictably rather than jumping around.
401 let next = self.buffers.ids().into_iter().find(|b| *b > id);
402 self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
403 Some(b) => b,
404 None => self.buffers.scratch(""),
405 };
406 self.set_cursor(Position::ZERO);
407 if let Some(w) = self
408 .layout
409 .windows
410 .iter_mut()
411 .find(|w| w.id == self.layout.active)
412 {
413 w.buffer_id = self.active;
414 }
415 }
416
417 /// Move to the next or previous buffer, wrapping.
418 fn cycle_buffer(&mut self, forward: bool) {
419 let ids = self.buffers.ids();
420 if ids.len() < 2 {
421 self.messages.push("only one buffer".to_string());
422 return;
423 }
424 let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
425 let next = if forward {
426 (at + 1) % ids.len()
427 } else {
428 (at + ids.len() - 1) % ids.len()
429 };
430 self.active = ids[next];
431 self.set_cursor(Position::ZERO);
432 if let Some(w) = self
433 .layout
434 .windows
435 .iter_mut()
436 .find(|w| w.id == self.layout.active)
437 {
438 w.buffer_id = self.active;
439 }
440 }
441
442 /// Re-clamp the cursor and re-contain the viewport after a buffer
443 /// mutation.
444 ///
445 /// An undo can SHRINK the buffer under a cursor that was legal a moment
446 /// ago, leaving it out of bounds and its viewport scrolled past the end.
447 /// The Action executor has always done this (`self.set_cursor(self.cursor())`
448 /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
449 /// and `:undo` did not — two implementations of one operation, already
450 /// drifted within one milestone of being written. Naming it once is the
451 /// fix; lowering the Action arms onto the same slips is what keeps it
452 /// fixed.
453 fn refollow(&mut self) {
454 self.set_cursor(self.cursor());
455 }
456
457 /// Apply one slip and record what it damaged.
458 ///
459 /// The bookkeeping wrapper. The Action executor calls
460 /// [`honour_one`](Self::honour_one) directly because it does its own,
461 /// wider bookkeeping (the dot register, the S3 damage seal) around a
462 /// whole action.
463 fn honour(&mut self, slip: Negai) {
464 let touches_text = slip.touches_text();
465 self.honour_one(slip);
466 self.damage = self.damage.join(if touches_text {
467 Damage::Full
468 } else {
469 Damage::Viewport
470 });
471 self.bump_gen();
472 }
473
474 /// Apply one slip. THE single implementation of every mutation a slip
475 /// can ask for.
476 ///
477 /// Total over `Negai`: a new request variant is a compile error here
478 /// rather than a request silently ignored — the same failure Phase 0
479 /// removed one layer up.
480 fn honour_one(&mut self, slip: Negai) {
481 match slip {
482 Negai::Edit { buffer, edit } => {
483 if let Some(b) = self.buffers.get_mut(buffer) {
484 let _ = b.apply(&edit);
485 }
486 self.refollow();
487 }
488 Negai::SetCursor { buffer, to } => {
489 // Clamping is the interpreter's job, exactly so that no
490 // handler has to re-implement it and get it wrong.
491 let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
492 self.set_cursor(clamped);
493 }
494 Negai::EnterMode(m) => self.modal.enter(m),
495 Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
496 Negai::FocusBuffer(id) => {
497 if self.buffers.get(id).is_some() {
498 self.active = id;
499 }
500 }
501 Negai::OpenPath(path) => match self.buffers.open(&path) {
502 Ok(id) => self.active = id,
503 Err(e) => self.messages.push(e.to_string()),
504 },
505 Negai::CloseBuffer(id) => self.close_buffer(id),
506 Negai::Save { buffer } => {
507 if let Some(b) = self.buffers.get_mut(buffer) {
508 if let Err(e) = b.save() {
509 self.messages.push(e.to_string());
510 }
511 }
512 self.refollow();
513 }
514 Negai::Undo { buffer } => {
515 if let Some(b) = self.buffers.get_mut(buffer) {
516 let _ = b.undo();
517 }
518 self.refollow();
519 }
520 Negai::Redo { buffer } => {
521 if let Some(b) = self.buffers.get_mut(buffer) {
522 let _ = b.redo();
523 }
524 self.refollow();
525 }
526 Negai::Yank { text, .. } => self.register = Some(text),
527 Negai::ClearSearchHighlight => self.search.clear_highlight(),
528 Negai::SetOption { name, value } => {
529 self.options.insert(name, value);
530 }
531 Negai::InsertText(text) => self.insert_text(&text),
532 Negai::RunCommand { name, args } => self.run_command(&name, &args),
533 Negai::PublishFindings { list, findings } => {
534 let world = self.world();
535 self.results
536 .publish(list, escriba_shirube::ResultList::new(findings, world));
537 }
538 Negai::WalkList { list, forward } => self.walk_list(&list, forward),
539 Negai::Message(m) => self.messages.push(m),
540 Negai::Quit => self.quit_requested = true,
541 // Both suspend the dispatch and need machinery that does not
542 // exist yet — the courier (Phase 5) and the AwaitKey resume
543 // (M3). Announced, never silently dropped: a slip that vanishes
544 // is the class Phase 0 sealed.
545 Negai::Errand(_) | Negai::AwaitKey { .. } => {
546 self.messages
547 .push("deferred work is not wired yet".to_string());
548 }
549 }
550 }
551}
552
553/// Outcome of feeding one key to the multi-key pending-stroke loop.
554enum SeqStep {
555 /// Key consumed into an in-progress sequence; wait for the next.
556 Pending,
557 /// A full bound sequence resolved — run this action.
558 Resolved(Action),
559 /// Key is not part of any sequence; hand it to single-key dispatch.
560 Passthrough,
561}
562
563/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
564/// therefore exists to debounce.
565///
566/// This is an ALLOW-LIST, and it used to be the complement — an exception list
567/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
568/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
569/// swallowed and someone noticed. The third growth is the signal that the
570/// default was backwards: almost every key in a modal editor is a discrete,
571/// deliberate press, and only a handful are ones you HOLD.
572///
573/// Inverting it makes the failure mode safe. Forgetting to list a key here now
574/// means it is ungated — one extra keypress honoured — instead of silently
575/// dropped, and a dropped key is indistinguishable from a dead one.
576///
577/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
578/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
579/// is keyed by KEY and the two presses fell inside one debounce window.
580const fn is_repeat_storm_candidate(key: &Key) -> bool {
581 matches!(
582 key,
583 // The four navigation keys a user actually holds down. `h`/`l` and
584 // `j`/`k` flood the motion path and thrash the viewport; everything
585 // else is pressed once and meant once.
586 Key::Char('h')
587 | Key::Char('j')
588 | Key::Char('k')
589 | Key::Char('l')
590 | Key::Left
591 | Key::Right
592 | Key::Up
593 | Key::Down
594 )
595}
596
597/// Turn a command failure into the sentence an operator should read.
598///
599/// The two failures mean genuinely different things and must not be reported
600/// the same way:
601///
602/// - `:flurb` — the operator typed a name that does not exist. "command not
603/// found" is exactly right; it says *you* made a typo.
604/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
605/// declares this, `--list-rc` counts it, and it is not built yet. Telling
606/// the operator "command not found" blames them for a gap we shipped.
607///
608/// The discriminator is the dotted form. `:action` takes action SYMBOLS
609/// (`picker.files`), never command names — that boundary is already pinned by
610/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
611/// dotted name that reached dispatch and resolved to nothing is a declared
612/// capability with no implementation, which is precisely what the 85 entries
613/// in `escriba/tests/action_resolution.rs` are.
614fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
615 use escriba_command::CommandError as E;
616 match e {
617 // Already the right words — the registry knew it was declared.
618 E::Unhandled(_) => e.to_string(),
619 E::NotFound(n) if n.contains('.') => {
620 let mut m = String::with_capacity(n.len() + 48);
621 m.push('`');
622 m.push_str(n);
623 m.push_str("` is declared but not implemented yet");
624 m
625 }
626 _ => {
627 let _ = name;
628 e.to_string()
629 }
630 }
631}
632
633/// What committing the open search prompt did.
634///
635/// The two commit paths — bare `/` and operated `d/` — used to own private
636/// copies of the whole sequence (read origin+skip, `accept`, three-arm
637/// match, `commit_step_skipping`), and they drifted: the operated one
638/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
639/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
640///
641/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
642/// a compile error in two places rather than a case one path quietly
643/// forgets. It does not make divergence impossible — the two paths
644/// genuinely differ at the landing step — it makes FORGETTING A CASE
645/// impossible, which is the failure that actually happened.
646enum CommitOutcome {
647 /// The prompt committed and a match was found.
648 Landed {
649 origin: usize,
650 step: escriba_search::Step,
651 },
652 /// Committed, but nothing matched. E486 already reported.
653 NotFound,
654 /// Nothing typed and no previous pattern. E35 already reported.
655 NoPrevious,
656 /// No prompt was open.
657 NoPrompt,
658}
659
660/// A replayable text change.
661#[derive(Debug, Clone)]
662struct LastChange {
663 /// The action that began the change.
664 action: Action,
665 /// How many times it ran.
666 count: u32,
667 /// Characters typed while the change held Insert mode open.
668 inserted: String,
669}
670
671impl EditorState {
672 /// Build a fresh editor with one buffer (scratch or file-backed).
673 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
674 let window = Window {
675 id: WindowId(1),
676 buffer_id: active,
677 viewport: Viewport {
678 top_line: 0,
679 left_column: 0,
680 visible_lines: 40,
681 visible_columns: 160,
682 },
683 rect: Rect {
684 x: 0,
685 y: 0,
686 width: 1200,
687 height: 800,
688 },
689 };
690 Self {
691 buffers: initial,
692 modal: ModalState::new(),
693 search: SearchState::new(escriba_search::CaseMode::Smart),
694 search_at: None,
695 last_change: None,
696 recording_insert: false,
697 jumps: JumpList::new(),
698 keymap: Keymap::default_vim(),
699 commands: CommandRegistry::default_set(),
700 layout: Layout::single(window),
701 active,
702 cursors: Cursors::single(Position::ZERO),
703 quit_requested: false,
704 register: None,
705 op_pending: zenmai::Stateful::new(OpState::Resting),
706 messages: Vec::new(),
707 options: HashMap::new(),
708 lisp_vm: None,
709 pending_keys: Vec::new(),
710 repeat_gate: KeyRepeatGate::new(),
711 plugin_host: PluginHost::default(),
712 edit_gen: EditGen::default(),
713 damage: Damage::None,
714 // The FLEET default until an rc says otherwise — never a
715 // hand-written theme name, so a fleet re-point lands for free.
716 dispatch_depth: 0,
717 filetypes: escriba_core::FiletypeTable::new(),
718 results: escriba_shirube::ListRegistry::new(),
719 theme: FleetTheme::prescribed_default(),
720 chrome: ChromePalette::prescribed(),
721 splash: None,
722 }
723 }
724
725 /// The theme this editor is set to.
726 #[must_use]
727 pub const fn theme(&self) -> FleetTheme {
728 self.theme
729 }
730
731 /// The colours every face paints with — read once per frame.
732 #[must_use]
733 pub const fn chrome(&self) -> ChromePalette {
734 self.chrome
735 }
736
737 /// Point the editor at a theme. The wiring that makes
738 /// `(deftheme :preset …)` real.
739 ///
740 /// Bumps the refresh generation, because a theme change repaints
741 /// everything: the GPU face caches its shaped buffer against that
742 /// generation and would otherwise keep the old colours until an
743 /// unrelated edit happened to invalidate it.
744 pub fn set_theme(&mut self, theme: FleetTheme) {
745 if self.theme == theme {
746 return;
747 }
748 self.theme = theme;
749 self.chrome = ChromePalette::for_theme(theme);
750 self.damage = self.damage.join(Damage::Viewport);
751 self.bump_gen();
752 }
753
754 /// The start screen, if one is up. Renderers paint this INSTEAD of the
755 /// buffer pane; `None` is the ordinary editor.
756 #[must_use]
757 pub fn splash(&self) -> Option<&Splash> {
758 self.splash.as_ref()
759 }
760
761 /// Raise the start screen. The binary calls this at boot when no file
762 /// was named; an empty splash is refused so a face never has to render
763 /// a blank screen over a perfectly good buffer.
764 pub fn set_splash(&mut self, splash: Splash) {
765 if splash.is_empty() {
766 return;
767 }
768 self.splash = Some(splash);
769 self.damage = self.damage.join(Damage::Viewport);
770 self.bump_gen();
771 }
772
773 /// Take the start screen down. Idempotent; bumps the refresh generation
774 /// only when something actually changed, so dismissing twice does not
775 /// cost a repaint.
776 pub fn dismiss_splash(&mut self) {
777 if self.splash.take().is_some() {
778 self.damage = self.damage.join(Damage::Viewport);
779 self.bump_gen();
780 }
781 }
782
783 /// Offer `key` to the start screen.
784 ///
785 /// A menu key runs its entry; ANY other key simply takes the screen
786 /// down and is then handled normally — so the first thing an operator
787 /// types is never swallowed.
788 fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
789 let Some(splash) = self.splash.as_ref() else {
790 return SplashKey::NotShowing;
791 };
792 let chosen = match key {
793 Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
794 _ => None,
795 };
796 self.dismiss_splash();
797 chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
798 }
799
800 /// The current refresh generation. A renderer caches its products against
801 /// this; equality is the freshness test (an unchanged generation ⇒ the
802 /// last frame is still valid, so skip the re-highlight + re-shape).
803 #[must_use]
804 pub fn edit_gen(&self) -> EditGen {
805 self.edit_gen
806 }
807
808 /// Advance the refresh generation (a mutation happened).
809 fn bump_gen(&mut self) {
810 self.edit_gen = self.edit_gen.next();
811 }
812
813 /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
814 #[must_use]
815 pub fn damage(&self) -> Damage {
816 self.damage
817 }
818
819 /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
820 /// renderer calls this once per frame to learn what to repaint, then the
821 /// accumulator restarts — so damage never double-counts across frames.
822 pub fn take_damage(&mut self) -> Damage {
823 std::mem::replace(&mut self.damage, Damage::None)
824 }
825
826 /// The line count of the active buffer (0 if none) — used to compute the
827 /// [`Damage`] scope of a mutation.
828 fn active_line_count(&self) -> u32 {
829 self.buffers
830 .get(self.active)
831 .map_or(0, escriba_buffer::Buffer::line_count)
832 }
833
834 /// Register a lazy USER plugin: its escriba entry is deferred until
835 /// one of its `triggers` fires. Bundled defaults do NOT go through
836 /// here — they are applied eagerly at boot. Empty `triggers` means
837 /// the plugin never lazily activates (the binary applies eager
838 /// plugins directly).
839 pub fn register_lazy_plugin(
840 &mut self,
841 name: impl Into<String>,
842 triggers: Vec<LazyTrigger>,
843 entry_src: impl Into<String>,
844 ) {
845 self.plugin_host.register(name, triggers, entry_src);
846 }
847
848 /// Apply a plugin entry's escriba-lisp to live state — the same
849 /// keymap / command / option apply paths a user rc uses. Options are
850 /// applied before keybinds so a plugin that sets `mapleader` resolves
851 /// `<leader>` correctly. Returns the count of commands + keybinds it
852 /// registered (best-effort; a malformed entry is skipped, not fatal).
853 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
854 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
855 return 0;
856 };
857 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
858 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
859 if let Some(value) = self.options.get("mapleader") {
860 if let Some(key) = escriba_lisp::parse_leader_key(value) {
861 self.keymap.set_leader(key);
862 }
863 }
864 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
865 (cmd.registered + km.keybinds_applied) as usize
866 }
867
868 /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
869 /// Returns the number of plugins activated. Call when a buffer of a
870 /// known filetype is opened.
871 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
872 let pending = self.plugin_host.pending_for_filetype(filetype);
873 let n = pending.len();
874 for src in pending {
875 self.apply_plugin_entry(&src);
876 }
877 n
878 }
879
880 /// Fire any lazy plugin gated on an `Event` trigger for `event`.
881 /// Returns the number of plugins activated.
882 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
883 let pending = self.plugin_host.pending_for_event(event);
884 let n = pending.len();
885 for src in pending {
886 self.apply_plugin_entry(&src);
887 }
888 n
889 }
890
891 /// Advance one frame's worth of state given a raw madori event.
892 ///
893 /// Key events pass through the [`KeyRepeatGate`] first (see
894 /// [`Self::tick_at`]); everything else is handled directly.
895 pub fn tick(&mut self, event: &AppEvent) {
896 self.tick_at(event, Instant::now());
897 }
898
899 /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
900 /// lets tests drive the debounce window without depending on the
901 /// wall clock.
902 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
903 match translate_app_event(event) {
904 InputOutcome::Key(k) => {
905 if self.gate_key(&k, now) {
906 self.on_key(&k);
907 }
908 }
909 InputOutcome::Resized { width, height } => {
910 if let Some(w) = self
911 .layout
912 .windows
913 .iter_mut()
914 .find(|w| w.id == self.layout.active)
915 {
916 w.rect.width = width;
917 w.rect.height = height;
918 }
919 self.damage = self.damage.join(Damage::Viewport);
920 self.bump_gen();
921 }
922 InputOutcome::Quit => self.quit_requested = true,
923 InputOutcome::Focus(_) | InputOutcome::None => {}
924 }
925 }
926
927 /// Decide whether `key` survives the key-repeat gate at time `now`.
928 ///
929 /// Returns `true` when the key should be processed, `false` when it is
930 /// an OS key-repeat storm tick that should be dropped. Gating applies
931 /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
932 /// are where a held `j`/`l` floods the motion path and thrashes the
933 /// viewport. Insert and Command modes pass every key through ungated,
934 /// because there "hold a key to repeat the character" is the intended
935 /// behavior, not a storm to suppress.
936 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
937 match self.modal.mode() {
938 Mode::Normal | Mode::Visual | Mode::VisualLine => {
939 // The gate exists for HELD keys that flood the motion path and
940 // thrash the viewport (`j`, `l`). It is wrong for the discrete
941 // jumps: two `n` presses 10 ms apart mean two matches, and
942 // swallowing the second is indistinguishable from a dead key —
943 // the exact symptom the gate was added to prevent elsewhere.
944 if is_repeat_storm_candidate(key) {
945 return self.repeat_gate.try_pass_at(*key, now);
946 }
947 true
948 }
949 Mode::Insert | Mode::Command => true,
950 }
951 }
952
953 /// Dispatch a single key through the keymap + apply the resulting action.
954 pub fn on_key(&mut self, key: &Key) {
955 // The start screen owns the first keypress and nothing after it.
956 match self.consume_splash_key(key) {
957 SplashKey::NotShowing | SplashKey::Dismissed => {}
958 SplashKey::Ran(action) => {
959 self.apply(&action);
960 return;
961 }
962 }
963 // Multi-key sequence resolution runs first: a key that begins or
964 // continues a bound sequence (`<leader>ff`, `gg`) is held or
965 // resolved here before the single-key path sees it.
966 match self.step_sequence(key) {
967 SeqStep::Pending => return,
968 SeqStep::Resolved(action) => {
969 let count = self.modal.pending_count().unwrap_or(1);
970 self.modal.clear_count();
971 for _ in 0..count {
972 self.apply(&action);
973 if self.quit_requested {
974 return;
975 }
976 }
977 return;
978 }
979 SeqStep::Passthrough => {}
980 }
981 let counted = self.keymap.dispatch(&self.modal, key);
982 // Count prefixes accumulate into modal state.
983 if matches!(counted.action, Action::Pending) {
984 if let Key::Char(c) = key {
985 if c.is_ascii_digit() {
986 let d = u32::from(*c as u8 - b'0');
987 self.modal.append_count(d);
988 }
989 }
990 return;
991 }
992 // The count flows through the operator-pending FSM (apply_counted), which
993 // owns repetition: a bare motion runs count× , an operator captures its
994 // count, and an operated motion multiplies the two. No naive outer loop.
995 self.apply_counted(&counted.action, counted.count);
996 // After applying, reset pending count.
997 self.modal.clear_count();
998 }
999
1000 /// Advance the multi-key pending-stroke state machine for `key`.
1001 ///
1002 /// Sequences only apply in normal / visual modes — insert and
1003 /// command modes treat keys as literal text. Rules:
1004 /// - Mid-sequence: extend the pending prefix. Exact match →
1005 /// [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1006 /// otherwise abort the sequence and re-process this key fresh.
1007 /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1008 /// itself a complete single binding (single bindings win, so no
1009 /// chord timeout is needed) → start pending. Otherwise
1010 /// [`SeqStep::Passthrough`] to the single-key dispatcher.
1011 fn step_sequence(&mut self, key: &Key) -> SeqStep {
1012 let mode = self.modal.mode();
1013 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1014 return SeqStep::Passthrough;
1015 }
1016 if !self.pending_keys.is_empty() {
1017 let mut seq = self.pending_keys.clone();
1018 seq.push(key.clone());
1019 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1020 let action = b.action.clone();
1021 self.pending_keys.clear();
1022 return SeqStep::Resolved(action);
1023 }
1024 if self.keymap.is_sequence_prefix(mode, &seq) {
1025 self.pending_keys = seq;
1026 return SeqStep::Pending;
1027 }
1028 // The key broke the in-progress sequence — abort it and let
1029 // the key be re-processed as a fresh stroke below.
1030 self.pending_keys.clear();
1031 }
1032 let start = [key.clone()];
1033 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1034 self.pending_keys = start.to_vec();
1035 return SeqStep::Pending;
1036 }
1037 SeqStep::Passthrough
1038 }
1039
1040 /// The primary cursor position. The single read accessor — every
1041 /// renderer + motion path goes through it, so the underlying
1042 /// representation (today a single-cursor [`Cursors`]) can grow to
1043 /// multi-caret without changing read sites.
1044 #[must_use]
1045 pub fn cursor(&self) -> Position {
1046 self.cursors.primary()
1047 }
1048
1049 /// The **single** cursor-mutation path. Clamp the requested position to
1050 /// the active buffer's bounds, then scroll the active window's viewport
1051 /// to contain it on BOTH axes. Routing every cursor change through this
1052 /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1053 /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1054 /// typed home — there is no code path that advances the cursor without
1055 /// re-deriving the viewport from it, and no second `Position` field to
1056 /// fall out of sync.
1057 fn set_cursor(&mut self, pos: Position) {
1058 let clamped = if let Some(buf) = self.buffers.get(self.active) {
1059 buf.clamp(pos)
1060 } else {
1061 pos
1062 };
1063 self.cursors.set_primary(clamped);
1064 if let Some(w) = self
1065 .layout
1066 .windows
1067 .iter_mut()
1068 .find(|w| w.id == self.layout.active)
1069 {
1070 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1071 }
1072 }
1073
1074 /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1075 fn apply(&mut self, action: &Action) {
1076 self.apply_counted(action, 1);
1077 }
1078
1079 /// Dispatch one resolved action with its count. Routes `(action, count)`
1080 /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1081 /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1082 /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1083 /// operator-then-motion pair is rewritten into a counted
1084 /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1085 /// composition — there is no naive outer repeat loop.
1086 fn apply_counted(&mut self, action: &Action, count: u32) {
1087 // An uncompilable pattern must not reach the operator machine.
1088 //
1089 // `SearchState::accept` puts the prompt BACK on a compile error so the
1090 // typed text is not lost — but the FSM had already transitioned out of
1091 // `AwaitingSearch` on the way in, so the prompt survived and the
1092 // OPERATOR did not, with nothing said about it. The `d` was simply
1093 // gone, and the corrected pattern then ran as a bare search.
1094 //
1095 // The machine is a pure `(State, Event) -> (State, effects)` and
1096 // cannot observe the result of an effect, so it cannot decide this
1097 // itself. The fix is to stop handing it an event it has no business
1098 // deciding: the runtime classifies the submit first, from state it
1099 // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1100 // the bare-`/<CR>` reuse path is untouched.
1101 //
1102 // Tier-honest: parse-rejected at the boundary, not
1103 // truly-unrepresentable.
1104 if matches!(action, Action::SubmitCommand) {
1105 if let Some(e) = self.search.prompt_error() {
1106 let mut m = String::from("E383: Invalid search string: ");
1107 m.push_str(&e.to_string());
1108 self.messages.push(m);
1109 return;
1110 }
1111 }
1112
1113 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1114 for _ in 0..times {
1115 self.apply_resolved(&resolved);
1116 if self.quit_requested {
1117 return;
1118 }
1119 }
1120 }
1121 }
1122
1123 /// The active buffer's text. Search is a pure function of it.
1124 /// The active buffer's text revision — the token an offset measured
1125 /// against it should carry.
1126 #[must_use]
1127 fn text_rev(&self) -> TextRev {
1128 self.buffers
1129 .get(self.active)
1130 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1131 }
1132
1133 fn active_text(&self) -> String {
1134 self.buffers
1135 .get(self.active)
1136 .map(escriba_buffer::Buffer::to_string)
1137 .unwrap_or_default()
1138 }
1139
1140 /// The cursor as a char offset — the coordinate search speaks.
1141 fn cursor_char(&self) -> usize {
1142 self.buffers
1143 .get(self.active)
1144 .and_then(|b| b.position_to_char(self.cursor()).ok())
1145 .unwrap_or(0)
1146 }
1147
1148 /// Move the cursor onto a match and report a wrap the way vim does.
1149 /// The status line as data — what every face draws.
1150 ///
1151 /// One model, so the two faces can only disagree about styling. Before
1152 /// this existed the GPU face built its own line from a fixed `format!()`
1153 /// and drew neither the prompt nor any message, which made a fully
1154 /// working `/` look like a dead key on escriba's default renderer.
1155 #[must_use]
1156 pub fn status_model(&self) -> StatusModel<'_> {
1157 let cursor = self.cursor();
1158 let prompt = self.search.prompt();
1159
1160 let kind = match prompt.map(|p| p.direction) {
1161 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1162 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1163 // Command mode with no search prompt open is an ex-command; the
1164 // typed `Option<Prompt>` is the discriminator, never a mode flag.
1165 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1166 None => PromptKind::None,
1167 };
1168
1169 StatusModel {
1170 mode: self.modal.mode(),
1171 line: cursor.line.saturating_add(1) as usize,
1172 column: cursor.column.saturating_add(1) as usize,
1173 prompt: kind,
1174 prompt_text: prompt
1175 .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1176 prompt_caret: prompt.map_or_else(
1177 || self.modal.minibuffer_caret(),
1178 escriba_search::Prompt::caret,
1179 ),
1180 count: self.match_count(),
1181 message: self.messages.last().map(String::as_str),
1182 }
1183 }
1184
1185 /// `[3/17]` for the current pattern.
1186 ///
1187 /// While a prompt is open the count describes the PREVIEW — the answer to
1188 /// "what would Enter do", which is the question being asked mid-typing.
1189 /// Once committed it describes where the cursor actually is.
1190 #[must_use]
1191 fn match_count(&self) -> MatchCount {
1192 if self.search.is_prompting() {
1193 let text = self.active_text();
1194 // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1195 // the same `None`, so a half-typed character class reported
1196 // `[0/0]` — telling the user their pattern matches nothing while
1197 // they are still writing it.
1198 return match self.search.preview(&text) {
1199 escriba_search::Preview::Landed { step, total } => {
1200 MatchCount::new(step.index, total)
1201 }
1202 escriba_search::Preview::NoMatch => MatchCount::None,
1203 escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1204 MatchCount::Idle
1205 }
1206 };
1207 }
1208 if self.search.pattern().is_none() {
1209 return MatchCount::Idle;
1210 }
1211 let total = self.search.matches().len();
1212 // Read THROUGH the anchor: an ordinal computed against text that has
1213 // since changed reads as absent, so a stale count cannot be displayed.
1214 let rev = self.text_rev();
1215 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1216 if total == 0 {
1217 MatchCount::None
1218 } else {
1219 MatchCount::Idle
1220 },
1221 |&i| MatchCount::new(i, total),
1222 )
1223 }
1224
1225 /// `.` — replay the last change at the cursor.
1226 ///
1227 /// Two steps, because a change can be two: run the action, then re-type
1228 /// whatever followed it. `cgn` + `.` is exactly this — change the next
1229 /// match, then repeat that whole gesture on the one after.
1230 fn repeat_last_change(&mut self) {
1231 let Some(change) = self.last_change.clone() else {
1232 self.messages
1233 .push("E32: No previous change to repeat".to_string());
1234 return;
1235 };
1236
1237 for _ in 0..change.count.max(1) {
1238 self.apply_resolved(&change.action);
1239 }
1240 for c in change.inserted.chars() {
1241 self.apply_resolved(&Action::InsertChar(c));
1242 }
1243 if self.modal.mode() == Mode::Insert {
1244 // A replayed change must not leave the editor in Insert — the
1245 // original ended with an Esc the recording deliberately does not
1246 // store, since it is punctuation rather than part of the change.
1247 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1248 }
1249 // The replay wrote through `apply_resolved`, which re-records
1250 // `last_change` from the inner action. Put the ORIGINAL back so a
1251 // second `.` repeats the same change rather than a fragment of it.
1252 self.last_change = Some(change);
1253 self.recording_insert = false;
1254 }
1255
1256 /// Resolve a text object to the range it names.
1257 ///
1258 /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1259 /// match operates on THAT match rather than skipping to the next — which
1260 /// is what makes `cgn` then `.` walk matches one at a time instead of
1261 /// every other one.
1262 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
1263 use escriba_core::TextObject as O;
1264 let at = self.cursor_char();
1265 let matches = self.search.matches();
1266
1267 // A match CONTAINING the cursor wins outright, whichever direction the
1268 // object names.
1269 //
1270 // Comparing only against `m.start` — which is what a `starts`-vector
1271 // plus `Bound::Inclusive` does — is right only when the cursor sits on
1272 // a match's FIRST character. One column further in, `start < at` and
1273 // the match is rejected, so `cgn` skipped the very instance the
1274 // operator was standing in and the rename silently missed it. vim
1275 // operates on the containing match from every interior column, and the
1276 // `starts`-only comparison cannot express "contains" because it never
1277 // looks at `m.end`.
1278 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
1279 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
1280 match object {
1281 O::NextMatch => Bound::Inclusive.first_matching(&starts, at, true),
1282 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
1283 }
1284 })?;
1285
1286 let m = matches.get(idx)?;
1287 let buf = self.buffers.get(self.active)?;
1288 Some(Range {
1289 start: buf.char_to_position(m.start),
1290 end: buf.char_to_position(m.end),
1291 })
1292 }
1293
1294 fn land_on(&mut self, step: escriba_search::Step) {
1295 if let Some(buf) = self.buffers.get(self.active) {
1296 let pos = buf.char_to_position(step.target.start);
1297 self.set_cursor(pos);
1298 }
1299 // The `[3/17]` numerator. `Step` has carried this index since the
1300 // engine was written — `engine.rs` even names the counter as the
1301 // reason it exists — and every consumer discarded it until now.
1302 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
1303 }
1304
1305 /// vim's "search hit BOTTOM, continuing at TOP".
1306 ///
1307 /// One reporter, called by the two places a search can wrap: the shared
1308 /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
1309 /// commit would say it twice.
1310 fn report_wrap(&mut self, step: &escriba_search::Step) {
1311 if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
1312 self.messages.push(msg.to_string());
1313 }
1314 }
1315
1316 /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
1317 /// than failing silently — a search that appears to do nothing is
1318 /// indistinguishable from a dropped keystroke.
1319 fn jump_search(&mut self, reverse: bool) {
1320 // Using the matches re-lights them: `n` after an auto-clear shows you
1321 // what you are walking through.
1322 self.search.relight();
1323 // `n` is a far jump — record where we leave from so `<C-o>` works.
1324 self.jumps.push(self.cursor());
1325 let at = self.cursor_char();
1326 match self.search.repeat(at, reverse) {
1327 Some(step) => {
1328 // `n` wrapping the file says so, same as a commit does.
1329 self.report_wrap(&step);
1330 self.land_on(step);
1331 }
1332 None => {
1333 let msg = self.search.pattern().map_or_else(
1334 || "E35: No previous regular expression".to_string(),
1335 |p| {
1336 let mut m = String::from("E486: Pattern not found: ");
1337 m.push_str(p.raw());
1338 m
1339 },
1340 );
1341 self.messages.push(msg);
1342 }
1343 }
1344 }
1345
1346 /// Move the cursor to where the in-progress pattern would land, without
1347 /// committing anything. vim's `incsearch`.
1348 ///
1349 /// A pattern that does not compile yet (`/a[`, mid-typing) previews
1350 /// nothing and reports nothing — an error toast on every keystroke of a
1351 /// character class would be unusable.
1352 fn preview_search(&mut self) {
1353 let text = self.active_text();
1354 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
1355 return;
1356 };
1357 let target = match self.search.preview(&text) {
1358 escriba_search::Preview::Landed { step, .. } => step.target.start,
1359 // Nothing to show: back to where the search started. Covers a
1360 // half-typed pattern and a pattern that finds nothing alike —
1361 // both mean "there is no match to preview".
1362 escriba_search::Preview::Idle
1363 | escriba_search::Preview::Incomplete
1364 | escriba_search::Preview::NoMatch => origin,
1365 };
1366 // A pattern that STOPS matching returns the cursor to the origin.
1367 //
1368 // Preview used to only ever move forward, so typing `ch` (a match) and
1369 // then `chz` (none) left the cursor parked on the `ch` match — a
1370 // preview showing a position the pattern no longer justifies, while
1371 // the count beside it read `[0/0]`. Restoring is also what makes
1372 // Escape's promise legible: at every keystroke the cursor is either on
1373 // a real match or back where you started, never on a stale one.
1374 if let Some(buf) = self.buffers.get(self.active) {
1375 let pos = buf.char_to_position(target);
1376 self.set_cursor(pos);
1377 }
1378 }
1379
1380 /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
1381 /// where the search lands, as ONE action.
1382 ///
1383 /// Split from [`Self::submit_search`] rather than sharing it because the
1384 /// two want opposite things from the commit: the bare `/` MOVES the cursor
1385 /// to the match, and an operated `/` must NOT — the cursor is the
1386 /// operator's start point, and moving it first would leave the operator
1387 /// with a zero-width range.
1388 /// Commit the open search prompt. The ONE copy of the sequence.
1389 ///
1390 /// Reports its own failures (E486 / E35) so neither caller has to carry a
1391 /// third copy of the message strings. `Accepted::Invalid` cannot reach
1392 /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
1393 /// boundary before the FSM or this method ever sees the submit.
1394 fn commit_search_prompt(&mut self) -> CommitOutcome {
1395 let text = self.active_text();
1396 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
1397 else {
1398 return CommitOutcome::NoPrompt;
1399 };
1400
1401 match self.search.accept(&text) {
1402 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
1403 self.modal.clear_minibuffer();
1404 self.modal.enter(Mode::Normal);
1405 match self.search.commit_step_skipping(origin, skip) {
1406 Some(step) => {
1407 // The wrap notice belongs HERE, once, for both commit
1408 // paths. Reporting it in each caller is what let the
1409 // operated path lose it in the first place — and my
1410 // first attempt at this refactor duplicated it again
1411 // rather than moving it, which the red proof caught.
1412 self.report_wrap(&step);
1413 CommitOutcome::Landed { origin, step }
1414 }
1415 None => {
1416 self.report_pattern_not_found();
1417 CommitOutcome::NotFound
1418 }
1419 }
1420 }
1421 escriba_search::Accepted::NothingToRepeat => {
1422 self.modal.clear_minibuffer();
1423 self.modal.enter(Mode::Normal);
1424 self.messages
1425 .push("E35: No previous regular expression".to_string());
1426 CommitOutcome::NoPrevious
1427 }
1428 // Unreachable: the boundary guard in `apply_counted` returns early
1429 // on an uncompilable pattern, leaving the prompt open. Reported
1430 // rather than `unreachable!()` — a panic in the editor's commit
1431 // path is a worse failure than a duplicate message.
1432 escriba_search::Accepted::Invalid(e) => {
1433 let mut m = String::from("E383: Invalid search string: ");
1434 m.push_str(&e.to_string());
1435 self.messages.push(m);
1436 CommitOutcome::NoPrompt
1437 }
1438 }
1439 }
1440
1441 /// vim's E486, with the pattern named. One place, so every path that fails
1442 /// to find reports identically.
1443 fn report_pattern_not_found(&mut self) {
1444 let mut m = String::from("E486: Pattern not found");
1445 if let Some(p) = self.search.pattern() {
1446 m.push_str(": ");
1447 m.push_str(p.raw());
1448 }
1449 self.messages.push(m);
1450 }
1451
1452 /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
1453 ///
1454 /// The only difference from the operated path is that this one lands;
1455 /// everything else lives in `commit_search_prompt`.
1456 fn submit_search(&mut self) {
1457 match self.commit_search_prompt() {
1458 CommitOutcome::Landed { origin, step } => {
1459 if let Some(buf) = self.buffers.get(self.active) {
1460 let from = buf.char_to_position(origin);
1461 self.jumps.push(from);
1462 }
1463 self.land_on(step);
1464 }
1465 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1466 }
1467 }
1468
1469 /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
1470 /// search lands, as ONE action.
1471 ///
1472 /// The cursor must NOT move to the match first: it is the operator's start
1473 /// point. That is the whole reason this differs from the bare path, and
1474 /// now the only reason.
1475 fn submit_search_operated(&mut self, op: Operator) {
1476 match self.commit_search_prompt() {
1477 CommitOutcome::Landed { origin, step } => {
1478 if let Some(buf) = self.buffers.get(self.active) {
1479 let from = buf.char_to_position(origin);
1480 let target = buf.char_to_position(step.target.start);
1481 // Operating over a search is itself a far jump.
1482 self.jumps.push(from);
1483 self.set_cursor(from);
1484 self.apply_operator_to(op, target);
1485 }
1486 }
1487 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
1488 }
1489 }
1490
1491 fn apply_resolved(&mut self, action: &Action) {
1492 // Snapshot the scope inputs before the mutation so the resulting
1493 // Damage covers the changed region (the S3 seal — conservative widen).
1494 let lines_before = self.active_line_count();
1495 // Snapshot for the dot register: the only reliable witness that this
1496 // action changed text is that the buffer's revision moved.
1497 let rev_before = self.text_rev();
1498 let cline_before = self.cursor().line;
1499 match action {
1500 // Every action with an exact slip equivalent goes through the
1501 // interpreter, so "undo" has ONE implementation rather than one
1502 // per entry point. These had already drifted: the executor
1503 // re-followed the viewport after undo and the M1 interpreter did
1504 // not, so `u` and `:undo` behaved differently within a milestone
1505 // of each other.
1506 // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
1507 // guard: a guard arm does not count toward exhaustiveness, so the
1508 // guarded form silently gave up the total match — the compiler
1509 // said so, and it was right. `lowering_and_dispatch_agree` pins
1510 // that this list and `lower` stay the same set.
1511 Action::Quit
1512 | Action::ClearSearchHighlight
1513 | Action::Save
1514 | Action::Undo
1515 | Action::Redo
1516 | Action::Edit(_) => {
1517 for slip in Self::lower(action, self.active).unwrap_or_default() {
1518 self.honour_one(slip);
1519 }
1520 }
1521 Action::Move(m) => self.apply_motion(*m),
1522 Action::SearchOpen(dir) => {
1523 // vim's `/` is the command-line with a different prompt char,
1524 // so we reuse Command mode; `search.prompt` is what tells a
1525 // later <CR> this is a search and not an ex-command.
1526 let origin = self.cursor_char();
1527 self.search.open(*dir, origin);
1528 self.modal.enter(Mode::Command);
1529 }
1530 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
1531 Action::SearchWord { reverse } => {
1532 let dir = if *reverse {
1533 SearchDirection::Backward
1534 } else {
1535 SearchDirection::Forward
1536 };
1537 let (text, at) = (self.active_text(), self.cursor_char());
1538 // `*` jumps, so it records too.
1539 self.jumps.push(self.cursor());
1540 match self.search.search_word(&text, at, dir) {
1541 Some(step) => self.land_on(step),
1542 // vim beeps and stays put when there is no word under the
1543 // cursor; a silent no-op would look like a broken key.
1544 None => self
1545 .messages
1546 .push("E348: No string under cursor".to_string()),
1547 }
1548 }
1549 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
1550 Action::TextObject(object) => {
1551 // Bare `gn` moves onto the match. vim additionally starts a
1552 // Visual selection of it; escriba's Visual plumbing does not
1553 // carry a selection an operator can consume yet, so this
1554 // stops at the jump rather than faking a selection that
1555 // nothing would honour.
1556 if let Some(range) = self.resolve_object(*object) {
1557 self.jumps.push(self.cursor());
1558 self.set_cursor(range.start);
1559 } else {
1560 self.report_pattern_not_found();
1561 }
1562 }
1563 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
1564 Some(range) => self.apply_operator_over(*op, range),
1565 None => self.report_pattern_not_found(),
1566 },
1567 Action::RepeatLastChange => self.repeat_last_change(),
1568 Action::JumpBack => {
1569 let here = self.cursor();
1570 if let Some(pos) = self.jumps.back(here) {
1571 self.set_cursor(pos);
1572 } else {
1573 self.messages
1574 .push("E662: At start of changelist".to_string());
1575 }
1576 }
1577 Action::JumpForward => {
1578 if let Some(pos) = self.jumps.forward() {
1579 self.set_cursor(pos);
1580 } else {
1581 self.messages.push("E663: At end of changelist".to_string());
1582 }
1583 }
1584 Action::ChangeMode(m) => {
1585 // Leaving the cmdline abandons any open search prompt and
1586 // returns the cursor home. The COMMITTED pattern survives —
1587 // cancelling a new search must not erase the old highlights.
1588 if *m == Mode::Normal && self.search.is_prompting() {
1589 if let Some(origin) = self.search.cancel() {
1590 if let Some(buf) = self.buffers.get(self.active) {
1591 let pos = buf.char_to_position(origin);
1592 self.set_cursor(pos);
1593 }
1594 }
1595 }
1596 self.modal.enter(*m);
1597 }
1598 Action::InsertChar(c) => self.insert_char(*c),
1599
1600 Action::SubmitCommand => {
1601 if self.search.is_prompting() {
1602 self.submit_search();
1603 } else {
1604 self.submit_command();
1605 }
1606 }
1607 Action::Command { name, args } => self.run_command(name, args),
1608 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
1609 // The operator-pending FSM consumes Operator keys (begins pending);
1610 // they never reach the executor. Defensive no-op for exhaustiveness.
1611 Action::Operator(_) => {}
1612 Action::PromptCaret { to } => {
1613 // Both prompts have a caret now, and the same keys move it.
1614 if self.search.is_prompting() {
1615 self.search.move_caret(*to);
1616 } else {
1617 self.modal.move_minibuffer_caret(*to);
1618 }
1619 }
1620 Action::SearchPreviewStep { forward } => {
1621 if self.search.is_prompting() {
1622 self.search.preview_step(*forward);
1623 self.preview_search();
1624 }
1625 }
1626 Action::PromptDelete => {
1627 if self.search.is_prompting() {
1628 self.search.delete_at_caret();
1629 self.preview_search();
1630 } else {
1631 self.modal.delete_minibuffer_at_caret();
1632 }
1633 }
1634 Action::PromptDeleteWord => {
1635 if self.search.is_prompting() {
1636 self.search.delete_word_before_caret();
1637 self.preview_search();
1638 }
1639 }
1640 Action::PromptClearToStart => {
1641 if self.search.is_prompting() {
1642 self.search.clear_before_caret();
1643 self.preview_search();
1644 }
1645 }
1646 Action::PromptBackspace => {
1647 self.prompt_backspace();
1648 // Shortening the pattern changes which matches exist, so the
1649 // preview must re-run — otherwise the cursor sits on a match
1650 // of a pattern that is no longer typed.
1651 if self.search.is_prompting() {
1652 self.preview_search();
1653 }
1654 }
1655 Action::PromptHistory { back } => {
1656 if self.search.is_prompting() {
1657 self.search.history_step(*back);
1658 // No minibuffer resync: the shadow is the ex-line's store
1659 // and nothing reads it while a search prompt is open, so
1660 // rewriting it here was maintaining a copy for no reader.
1661 self.preview_search();
1662 }
1663 }
1664 Action::Pending => {}
1665 }
1666 // Widen the dirty region by what this action touched (M1). Content
1667 // mutations that changed the line count run to end-of-document (every
1668 // line below shifted); an in-place edit or a cursor move is local;
1669 // arbitrary commands are conservatively Full. Never narrows.
1670 let lines_after = self.active_line_count();
1671 let cline_after = self.cursor().line;
1672 let d = match action {
1673 // A search repaints every highlight in the viewport, not just the
1674 // line the cursor left — so it must widen to Full. Treating it as a
1675 // cursor move would leave stale highlights on untouched lines.
1676 Action::SearchOpen(_)
1677 | Action::PromptHistory { .. }
1678 | Action::PromptBackspace
1679 | Action::PromptCaret { .. }
1680 | Action::SearchPreviewStep { .. }
1681 | Action::PromptDelete
1682 | Action::PromptDeleteWord
1683 | Action::PromptClearToStart
1684 | Action::SearchRepeat { .. }
1685 | Action::SearchWord { .. }
1686 | Action::ClearSearchHighlight
1687 | Action::SearchSubmitOperated { .. }
1688 // A replayed change can edit anywhere the original could, and a
1689 // match object can be anywhere in the document.
1690 | Action::RepeatLastChange
1691 | Action::TextObject(_)
1692 | Action::ApplyOperatorObject { .. }
1693 // A jump can land anywhere, so the viewport may scroll wholesale.
1694 | Action::JumpBack
1695 | Action::JumpForward => Damage::Full,
1696 Action::InsertChar(_)
1697 | Action::Edit(_)
1698 | Action::Undo
1699 | Action::Redo
1700 | Action::ApplyOperator { .. } => {
1701 if lines_after == lines_before {
1702 Damage::span(cline_before, cline_after)
1703 } else {
1704 Damage::Lines {
1705 from: cline_before.min(cline_after),
1706 to: u32::MAX,
1707 }
1708 }
1709 }
1710 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
1711 Action::Save => Damage::Viewport,
1712 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
1713 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
1714 };
1715 self.damage = self.damage.join(d);
1716 // Remember this change for `.`.
1717 //
1718 // Recorded from an OBSERVED MUTATION, not from the action's variant.
1719 // `text_effect()` is the wrong predicate here even though it looks
1720 // like the right one: it exists to decide cache invalidation, where
1721 // OVER-reporting is the safe direction, and the dot register needs the
1722 // opposite bias. Leaning on it meant `last_change` was set by actions
1723 // that changed no text at all, with two measured consequences:
1724 //
1725 // `iZ<Esc>` then `/a<CR>` then `.` — did nothing; the register held
1726 // `SubmitCommand`, whose replay reads an already-cleared
1727 // minibuffer.
1728 // `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
1729 // abandoned prompt left the register holding `InsertChar('q')`,
1730 // and `.` in Normal mode routes that to the text. A corrupting
1731 // register, not merely a lost one.
1732 //
1733 // Comparing the buffer's `TextRev` across the action answers the only
1734 // question that matters — did this actually change the text — and gets
1735 // the failed-operator case (`dgn` with no pattern) right for free.
1736 if self.recording_insert {
1737 match action {
1738 Action::InsertChar(c) => {
1739 if let Some(lc) = self.last_change.as_mut() {
1740 lc.inserted.push(*c);
1741 }
1742 }
1743 // Leaving Insert ends the session; the change is now whole.
1744 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
1745 _ => {}
1746 }
1747 } else if self.text_rev() != rev_before
1748 && !matches!(
1749 action,
1750 Action::RepeatLastChange | Action::Undo | Action::Redo
1751 )
1752 {
1753 self.last_change = Some(LastChange {
1754 action: action.clone(),
1755 count: 1,
1756 inserted: String::new(),
1757 });
1758 self.recording_insert = self.modal.mode() == Mode::Insert;
1759 }
1760
1761 // The search is over the moment you move on or edit — clear the
1762 // highlight rather than leaving the buffer as confetti until an
1763 // explicit `:noh`, which is the remap nearly every vimrc carries.
1764 // Clearing suppresses without forgetting, so `n` still works.
1765 if action.highlight_effect() == HighlightEffect::Clear {
1766 self.search.clear_highlight();
1767 }
1768 // Text changed ⇒ every match offset cached against the old text is
1769 // wrong. `SearchState::refresh` existed for exactly this and had ZERO
1770 // callers, so inserting four characters left both renderers painting
1771 // the highlight four columns off.
1772 //
1773 // Gated on the typed classifier rather than on `bump_gen` (which fires
1774 // for pure cursor moves too): re-scanning the document on every `j`
1775 // would be a per-keystroke full pass for no reason.
1776 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
1777 let text = self.active_text();
1778 self.search.refresh(&text);
1779 // NO manual invalidation of `search_at` here, deliberately. It is
1780 // `Anchored` to the text revision, so an ordinal computed against
1781 // the old text now reads as `None` on its own. This is the line
1782 // that used to have to be remembered.
1783 }
1784 // An action reached the executor ⇒ visible state may have changed.
1785 // Advance the refresh generation so the renderer repaints (and
1786 // re-highlights) exactly once. A gated-out key never reaches here, so
1787 // a key-repeat storm does not spin the renderer.
1788 self.bump_gen();
1789 }
1790
1791 /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
1792 /// active buffer — **pure**: no cursor mutation, no side effects. This is
1793 /// the single motion-resolution source of truth that both [`apply_motion`]
1794 /// (move the cursor *to* the target) and [`apply_operator`] (use the target
1795 /// as the *other end* of an operated range) stand on. `None` only if there
1796 /// is no active buffer.
1797 ///
1798 /// [`apply_motion`]: Self::apply_motion
1799 /// [`apply_operator`]: Self::apply_operator
1800 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
1801 let buf = self.buffers.get(self.active)?;
1802 let pos = from;
1803 Some(match motion {
1804 // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
1805 // against the committed match list, so it is `None` (motion fails,
1806 // operator aborts, buffer untouched) when nothing is committed —
1807 // never a silent move to 0, which would delete to the file start.
1808 Motion::SearchNext | Motion::SearchPrev => {
1809 let at = buf.position_to_char(pos).ok()?;
1810 let step = self
1811 .search
1812 .repeat(at, matches!(motion, Motion::SearchPrev))?;
1813 buf.char_to_position(step.target.start)
1814 }
1815 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
1816 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
1817 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
1818 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
1819 Motion::LineStart => Position::new(pos.line, 0),
1820 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
1821 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
1822 Motion::DocStart => Position::ZERO,
1823 Motion::DocEnd => Position::new(
1824 buf.line_count().saturating_sub(1),
1825 buf.line_len_chars(buf.line_count().saturating_sub(1)),
1826 ),
1827 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
1828 Motion::WordStartPrev => word_prev(buf, pos),
1829 Motion::PageDown | Motion::HalfPageDown => {
1830 Position::new(pos.line.saturating_add(10), pos.column)
1831 }
1832 Motion::PageUp | Motion::HalfPageUp => {
1833 Position::new(pos.line.saturating_sub(10), pos.column)
1834 }
1835 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
1836 // Structural Lisp motions — stubs for phase 1.B; full paredit
1837 // semantics land when caixa-ast is wired to the active buffer.
1838 Motion::ForwardSexp
1839 | Motion::BackwardSexp
1840 | Motion::UpList
1841 | Motion::DownList
1842 | Motion::BeginningOfDefun
1843 | Motion::EndOfDefun
1844 | Motion::BeginningOfSexp
1845 | Motion::EndOfSexp => pos,
1846 })
1847 }
1848
1849 fn apply_motion(&mut self, motion: Motion) {
1850 // A bare search motion is a FAR JUMP and it REPORTS — it records into
1851 // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
1852 // when nothing matches. `resolve_motion` can do none of that: it is
1853 // deliberately pure because the OPERATOR path calls it to find a range
1854 // without moving the cursor. So `n` routes to the one executor that
1855 // owns those side effects, and `Action::SearchRepeat` routes to the
1856 // same place — one code path, two spellings.
1857 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1858 self.jump_search(matches!(motion, Motion::SearchPrev));
1859 return;
1860 }
1861 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
1862 return;
1863 };
1864 // The single cursor-mutation path clamps to the buffer and scrolls
1865 // the viewport to contain the cursor on both axes.
1866 self.set_cursor(pos);
1867 }
1868
1869 /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
1870 /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
1871 /// Composition is explicit: the motion resolves a target via
1872 /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
1873 /// `[cursor, target)` range. Register-leaving operators
1874 /// ([`Operator::leaves_register`]) capture the text first.
1875 fn apply_operator(&mut self, op: Operator, motion: Motion) {
1876 let from = self.cursor();
1877 let Some(to) = self.resolve_motion(from, motion) else {
1878 // A motion that cannot resolve aborts the operator with the buffer
1879 // untouched. A search motion says WHY — `dn` with no pattern armed
1880 // is otherwise indistinguishable from a dropped keystroke, which
1881 // is the same complaint that motivated E486 on the bare path.
1882 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
1883 if self.search.pattern().is_none() {
1884 self.messages
1885 .push("E35: No previous regular expression".to_string());
1886 } else {
1887 self.report_pattern_not_found();
1888 }
1889 }
1890 return;
1891 };
1892 self.apply_operator_to(op, to);
1893 }
1894
1895 /// Apply `op` over `[cursor, to)`.
1896 ///
1897 /// Split out of [`Self::apply_operator`] so the operated-search path can
1898 /// reach the same range machinery with a target it resolved itself — the
1899 /// alternative was a second copy of the delete/yank/register logic, which
1900 /// is how the two would drift.
1901 fn apply_operator_to(&mut self, op: Operator, to: Position) {
1902 let from = self.cursor();
1903 self.apply_operator_over(
1904 op,
1905 Range {
1906 start: from,
1907 end: to,
1908 },
1909 );
1910 }
1911
1912 /// Apply `op` over an explicit range.
1913 ///
1914 /// The object path needs this: `gn`'s extent need not begin at the cursor,
1915 /// so it cannot go through the `[cursor, target)` shape the motion path
1916 /// uses. One implementation of the delete/yank/register logic, reached two
1917 /// ways.
1918 fn apply_operator_over(&mut self, op: Operator, range: Range) {
1919 let range = range.normalized();
1920 if range.is_empty() {
1921 return;
1922 }
1923 // Capture the operated text (for the register) before mutating.
1924 let text = self
1925 .buffers
1926 .get(self.active)
1927 .and_then(|buf| buf.slice(range).ok());
1928 if op.leaves_register() {
1929 if let Some(t) = &text {
1930 self.register = Some(t.clone());
1931 }
1932 }
1933 match op {
1934 // Delete + Change remove the range; Change then enters Insert so
1935 // the operator pairs with immediate typing (`ciw`, `c$`).
1936 Operator::Delete | Operator::Change => {
1937 if let Some(buf) = self.buffers.get_mut(self.active) {
1938 let _ = buf.apply(&Edit::delete(range));
1939 }
1940 self.set_cursor(range.start);
1941 if op == Operator::Change {
1942 self.modal.enter(Mode::Insert);
1943 }
1944 }
1945 // Yank copies to the register without mutating the buffer; vim
1946 // leaves the cursor at the range start.
1947 Operator::Yank => {
1948 self.set_cursor(range.start);
1949 }
1950 // Indent/Format/structural operators are not yet wired — named,
1951 // not faked (no buffer mutation, register already captured for the
1952 // register-leaving ones above).
1953 _ => {
1954 self.messages
1955 .push("operator not yet implemented".to_owned());
1956 }
1957 }
1958 }
1959
1960 /// The text last yanked or deleted into the unnamed register, if any.
1961 /// The future `p`/`P` paste reads this.
1962 #[must_use]
1963 pub fn register(&self) -> Option<&str> {
1964 self.register.as_deref()
1965 }
1966
1967 fn insert_char(&mut self, c: char) {
1968 if self.modal.mode() == Mode::Command {
1969 // A search prompt and an ex-command share Command mode (vim's
1970 // cmdline). `search.is_prompting()` is the typed discriminator —
1971 // it can only be true when `/` or `?` actually opened a prompt.
1972 if self.search.is_prompting() {
1973 // The search prompt is the SOLE store while it is open.
1974 //
1975 // This used to also `push_minibuffer(c)`, and the two stores
1976 // insert differently — `search.push` at the caret, the
1977 // minibuffer always at the end — so `/fo<Left>X` left them
1978 // reading `fXo` and `foX`. That was one of FIVE desync paths;
1979 // the caret moves, forward-delete, delete-word and
1980 // clear-to-start never touched the shadow at all.
1981 //
1982 // Deleting the write costs nothing because `status_model`
1983 // already selects the minibuffer only on the `prompt == None`
1984 // branch — the shadow is the EX-LINE's store, and while a
1985 // search prompt is open nothing reads it.
1986 self.search.push(c);
1987 self.preview_search();
1988 } else {
1989 self.modal.push_minibuffer(c);
1990 }
1991 return;
1992 }
1993 let cursor = self.cursor();
1994 let Some(buf) = self.buffers.get_mut(self.active) else {
1995 return;
1996 };
1997 let edit = Edit::insert(cursor, c.to_string());
1998 if buf.apply(&edit).is_ok() {
1999 let next = if c == '\n' {
2000 Position::new(cursor.line.saturating_add(1), 0)
2001 } else {
2002 cursor.shift_right(1)
2003 };
2004 // Route through the single cursor-mutation path so the viewport
2005 // follows the cursor (both axes) and the cursor stays clamped.
2006 self.set_cursor(next);
2007 }
2008 }
2009
2010 /// Backspace inside a prompt. Keeps the search buffer and the displayed
2011 /// minibuffer in lockstep — if only one shrank, the pattern submitted
2012 /// would differ from the text on screen.
2013 fn prompt_backspace(&mut self) -> bool {
2014 if self.modal.mode() != Mode::Command {
2015 return false;
2016 }
2017 if self.search.is_prompting() {
2018 // Backspacing past the `/` closes the prompt, as vim does. No
2019 // `pop_minibuffer` here for the same reason as `insert_char`: the
2020 // shadow is the ex-line's, and popping its TAIL when the caret is
2021 // mid-pattern was another desync path.
2022 if self.search.backspace() {
2023 self.modal.clear_minibuffer();
2024 self.modal.enter(Mode::Normal);
2025 }
2026 // Never `pop_minibuffer` on the search path: it pops the TAIL,
2027 // while `search.backspace()` removes the char before the CARET.
2028 return true;
2029 }
2030 self.modal.pop_minibuffer();
2031 true
2032 }
2033
2034 fn submit_command(&mut self) {
2035 // Read the command line BEFORE leaving Command mode — the minibuffer
2036 // exists only in the `Command` variant, so the escape must come
2037 // after the capture.
2038 let line = self.modal.minibuffer().to_string();
2039 self.modal.escape();
2040 let (name, args) = parse_command_line(&line);
2041 if name.is_empty() {
2042 return;
2043 }
2044 self.run_command(&name, &args);
2045 }
2046
2047 fn run_command(&mut self, name: &str, args: &[String]) {
2048 // Bound the command -> RunCommand slip -> command cycle. Refused and
2049 // reported, never a stack overflow: an editor that dies under the
2050 // operator loses their buffer, and a script that loops is a mistake
2051 // they should be told about, not punished for.
2052 if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2053 let mut m = String::from("command recursion too deep at `");
2054 m.push_str(name);
2055 m.push_str("` — refusing");
2056 self.messages.push(m);
2057 self.damage = self.damage.join(Damage::Viewport);
2058 self.bump_gen();
2059 return;
2060 }
2061 self.dispatch_depth += 1;
2062 self.run_command_inner(name, args);
2063 self.dispatch_depth -= 1;
2064 }
2065
2066 /// How many nested command dispatches are allowed. Deep enough that no
2067 /// legitimate script notices, shallow enough to fail fast.
2068 const MAX_DISPATCH_DEPTH: u8 = 8;
2069
2070 fn run_command_inner(&mut self, name: &str, args: &[String]) {
2071 // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2072 // gated on `Command: <name>` has its entry applied the first time
2073 // that command runs, BEFORE dispatch — so the activated plugin
2074 // can register the very command being invoked and it resolves on
2075 // this same call.
2076 if self.plugin_host.pending() > 0 {
2077 let pending = self.plugin_host.pending_for_command(name);
2078 for src in pending {
2079 self.apply_plugin_entry(&src);
2080 }
2081 }
2082 // Read through the counter, then interpret. Two immutable borrows of
2083 // `self` (the window and the registry) coexist; the `&mut` comes
2084 // afterwards, once the outcome is owned. That sequencing IS the
2085 // seam: there is no moment where a command body and `&mut self` are
2086 // live at the same time.
2087 let outcome = {
2088 let window = self.window();
2089 self.commands.run(name, &window, args)
2090 };
2091 match outcome {
2092 Ok(o) => self.interpret(o),
2093 // Reported, never fatal (Phase 0). A failed command must not
2094 // take the editor down, but it must not be invisible either.
2095 Err(e) => {
2096 self.messages.push(describe_command_failure(name, &e));
2097 self.damage = self.damage.join(Damage::Viewport);
2098 self.bump_gen();
2099 }
2100 }
2101 }
2102
2103 // ── tatara-lisp runtime bridge (imperative programmability tier) ──
2104
2105 /// Capture a read snapshot of the editor for the tatara-lisp host.
2106 /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
2107 #[must_use]
2108 pub fn snapshot(&self) -> EditorSnapshot {
2109 let current_line = self
2110 .buffers
2111 .get(self.active)
2112 .and_then(|b| b.line(self.cursor().line))
2113 .map(|s| s.trim_end_matches('\n').to_string())
2114 .unwrap_or_default();
2115 let buffer_name = self
2116 .buffers
2117 .get(self.active)
2118 .and_then(|b| b.path.as_ref())
2119 .map(|p| p.display().to_string())
2120 .unwrap_or_else(|| "[scratch]".to_string());
2121 EditorSnapshot {
2122 cursor_line: i64::from(self.cursor().line),
2123 cursor_column: i64::from(self.cursor().column),
2124 current_line,
2125 mode: self.modal.mode().as_str().to_string(),
2126 buffer_name,
2127 }
2128 }
2129
2130 /// Evaluate tatara-lisp `src` against this editor: capture a
2131 /// snapshot, run it in the embedded VM, then apply the typed effects
2132 /// the program emitted. This is the imperative programmability tier
2133 /// — live Lisp that reads state and drives the editor through the
2134 /// sandboxed effect boundary.
2135 ///
2136 /// **Snapshot semantics:** the read snapshot is captured ONCE before
2137 /// eval, and effects are applied AFTER the program returns. So within
2138 /// a single `run_lisp` call a program cannot observe its own writes —
2139 /// `(insert "x") (cursor-column)` reads the pre-insert column. This
2140 /// snapshot-isolation is deliberate (it's what makes the effect
2141 /// boundary a clean sandbox seam); a program that must read its own
2142 /// effects splits the work across calls. The VM is cached
2143 /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
2144 /// `define`s persist across calls (REPL-like).
2145 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
2146 let mut host = EscribaHost::with_snapshot(self.snapshot());
2147 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
2148 vm.eval(src, &mut host)?;
2149 let effects = host.take_effects();
2150 self.apply_host_effects(effects);
2151 Ok(())
2152 }
2153
2154 /// Apply tatara-lisp effects to live editor state.
2155 ///
2156 /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
2157 /// implementation of message-push / option-insert / insert-text beside
2158 /// the Action executor and the slip interpreter — the same duplication
2159 /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
2160 /// hands them to the one interpreter.
2161 pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
2162 self.interpret(Outcome::did(effects));
2163 }
2164
2165 /// Insert a (possibly multi-line) string at the cursor and advance
2166 /// the cursor past it. Used by the `(insert …)` effect.
2167 fn insert_text(&mut self, text: &str) {
2168 if text.is_empty() {
2169 return;
2170 }
2171 let cursor = self.cursor();
2172 let Some(buf) = self.buffers.get_mut(self.active) else {
2173 return;
2174 };
2175 let edit = Edit::insert(cursor, text.to_string());
2176 if buf.apply(&edit).is_ok() {
2177 let next = if let Some(nl) = text.rfind('\n') {
2178 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
2179 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
2180 Position::new(cursor.line + added_lines, last_line_len)
2181 } else {
2182 let n = u32::try_from(text.chars().count()).unwrap_or(0);
2183 cursor.shift_right(n)
2184 };
2185 // Route through the single cursor-mutation path so the viewport
2186 // follows the cursor (both axes) and the cursor stays clamped.
2187 self.set_cursor(next);
2188 }
2189 }
2190}
2191
2192fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
2193 let Some(text) = buf.line(line) else {
2194 return Position::new(line, 0);
2195 };
2196 let col = text
2197 .chars()
2198 .take_while(|c| c.is_whitespace() && *c != '\n')
2199 .count();
2200 Position::new(line, u32::try_from(col).unwrap_or(0))
2201}
2202
2203fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2204 let Some(text) = buf.line(pos.line) else {
2205 return pos;
2206 };
2207 let chars: Vec<char> = text.chars().collect();
2208 let start = pos.column as usize;
2209 let mut i = start;
2210 while i < chars.len() && !chars[i].is_whitespace() {
2211 i += 1;
2212 }
2213 while i < chars.len() && chars[i].is_whitespace() {
2214 i += 1;
2215 }
2216 if i >= chars.len() {
2217 // No more words on this line — jump to next line.
2218 if pos.line + 1 < buf.line_count() {
2219 return Position::new(pos.line + 1, 0);
2220 }
2221 }
2222 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
2223}
2224
2225fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
2226 let Some(text) = buf.line(pos.line) else {
2227 return pos;
2228 };
2229 let chars: Vec<char> = text.chars().collect();
2230 let mut i = (pos.column as usize).min(chars.len());
2231 while i > 0 && chars[i - 1].is_whitespace() {
2232 i -= 1;
2233 }
2234 while i > 0 && !chars[i - 1].is_whitespace() {
2235 i -= 1;
2236 }
2237 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
2238}
2239
2240fn parse_command_line(line: &str) -> (String, Vec<String>) {
2241 let mut parts = line.split_whitespace();
2242 let Some(first) = parts.next() else {
2243 return (String::new(), Vec::new());
2244 };
2245 let head = first.strip_prefix(':').unwrap_or(first);
2246 let name = match head {
2247 "w" => "save",
2248 "q" => "quit",
2249 "u" => "undo",
2250 other => other,
2251 };
2252 (name.to_string(), parts.map(str::to_string).collect())
2253}
2254
2255#[cfg(test)]
2256mod tests {
2257 use super::*;
2258 use madori::event::{KeyCode, KeyEvent, Modifiers};
2259
2260 // ── search wiring (escriba-search integration) ────────────────────
2261 //
2262 // The engine is proven in escriba-search's own 61 tests. These prove the
2263 // WIRING: that keys reach it, that the cursor lands where it says, and
2264 // that a search prompt and an ex-command can share Command mode without
2265 // being confused for one another.
2266
2267 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
2268 st.apply(&Action::SearchOpen(dir));
2269 for c in pat.chars() {
2270 st.apply(&Action::InsertChar(c));
2271 }
2272 st.apply(&Action::SubmitCommand);
2273 }
2274
2275 #[test]
2276 fn slash_search_moves_the_cursor_to_the_match() {
2277 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2278 type_search(&mut st, SearchDirection::Forward, "charlie");
2279 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
2280 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
2281 assert_eq!(st.search.matches().len(), 1);
2282 }
2283
2284 #[test]
2285 // `N` is a DIFFERENT vim key from `n` — see escriba-search.
2286 #[allow(non_snake_case)]
2287 fn n_and_N_walk_matches_in_both_directions() {
2288 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
2289 type_search(&mut st, SearchDirection::Forward, "foo");
2290 let first = st.cursor().line;
2291 st.apply(&Action::SearchRepeat { reverse: false });
2292 let second = st.cursor().line;
2293 assert!(second > first, "n advances ({first} -> {second})");
2294 st.apply(&Action::SearchRepeat { reverse: true });
2295 assert_eq!(st.cursor().line, first, "N comes back");
2296 }
2297
2298 #[test]
2299 fn star_searches_the_word_under_the_cursor() {
2300 let mut st = new_state_with("needle\nhaystack\nneedle\n");
2301 st.apply(&Action::SearchWord { reverse: false });
2302 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
2303 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
2304 }
2305
2306 #[test]
2307 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
2308 let mut st = new_state_with("foo\nbar\nfoo\n");
2309 type_search(&mut st, SearchDirection::Forward, "foo");
2310 let matches_before = st.search.matches().len();
2311
2312 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2313 st.apply(&Action::InsertChar('z'));
2314 st.apply(&Action::ChangeMode(Mode::Normal));
2315
2316 assert!(!st.search.is_prompting(), "prompt gone");
2317 assert_eq!(
2318 st.search.pattern().unwrap().raw(),
2319 "foo",
2320 "old pattern survives"
2321 );
2322 assert_eq!(
2323 st.search.matches().len(),
2324 matches_before,
2325 "old highlights survive"
2326 );
2327 }
2328
2329 #[test]
2330 fn a_search_prompt_and_an_ex_command_are_not_confused() {
2331 let mut st = new_state_with("foo\n");
2332 // No `/` pressed: Command mode belongs to the ex-command line.
2333 st.apply(&Action::ChangeMode(Mode::Command));
2334 assert!(!st.search.is_prompting(), "`:` must not open a search");
2335 st.apply(&Action::InsertChar('w'));
2336 assert!(
2337 st.search.prompt().is_none(),
2338 "typed char went to the ex line"
2339 );
2340 }
2341
2342 #[test]
2343 fn a_missing_pattern_reports_instead_of_failing_silently() {
2344 let mut st = new_state_with("alpha\nbravo\n");
2345 type_search(&mut st, SearchDirection::Forward, "zzz");
2346 assert!(
2347 st.messages.iter().any(|m| m.contains("E486")),
2348 "must report not-found, got {:?}",
2349 st.messages
2350 );
2351 }
2352
2353 #[test]
2354 fn n_without_any_search_reports_rather_than_moving() {
2355 let mut st = new_state_with("alpha\nbravo\n");
2356 let before = st.cursor();
2357 st.apply(&Action::SearchRepeat { reverse: false });
2358 assert_eq!(st.cursor(), before, "cursor must not move");
2359 assert!(
2360 st.messages.iter().any(|m| m.contains("E35")),
2361 "got {:?}",
2362 st.messages
2363 );
2364 }
2365
2366 #[test]
2367 fn search_as_a_motion_composes_with_an_operator() {
2368 // The point of Motion::SearchNext: `d` + search deletes to the match.
2369 let mut st = new_state_with("alpha bravo charlie\n");
2370 type_search(&mut st, SearchDirection::Forward, "charlie");
2371 st.set_cursor(Position::new(0, 0));
2372 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
2373 assert!(target.is_some(), "search must resolve as a motion");
2374 assert_eq!(target.unwrap().column, 12, "at `charlie`");
2375 }
2376
2377 #[test]
2378 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
2379 // A silent fallback to offset 0 would make `d` + search delete to the
2380 // start of the file — the worst possible failure for an operator.
2381 let st = new_state_with("alpha bravo\n");
2382 assert!(
2383 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
2384 .is_none()
2385 );
2386 }
2387
2388 #[test]
2389 fn clear_highlight_keeps_the_pattern_usable() {
2390 let mut st = new_state_with("foo\nbar\nfoo\n");
2391 type_search(&mut st, SearchDirection::Forward, "foo");
2392 st.apply(&Action::ClearSearchHighlight);
2393 assert!(st.search.highlights().is_empty(), "nothing lit");
2394 st.apply(&Action::SearchRepeat { reverse: false });
2395 assert!(st.search.pattern().is_some(), "but n still works");
2396 }
2397
2398 #[test]
2399 fn typing_previews_incrementally_before_commit() {
2400 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
2401 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2402 for c in "charlie".chars() {
2403 st.apply(&Action::InsertChar(c));
2404 }
2405 // incsearch: the cursor has already moved, with nothing committed.
2406 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
2407 assert!(st.search.pattern().is_none(), "but nothing is committed");
2408 }
2409
2410 #[test]
2411 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
2412 let mut st = new_state_with("alpha\nbravo\n");
2413 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2414 for c in "bravox".chars() {
2415 st.apply(&Action::InsertChar(c));
2416 }
2417 assert_eq!(st.search.prompt().unwrap().text(), "bravox");
2418 st.apply(&Action::PromptBackspace);
2419 assert_eq!(
2420 st.search.prompt().unwrap().text(),
2421 "bravo",
2422 "typo corrected"
2423 );
2424 assert_eq!(
2425 st.status_model().prompt_text,
2426 "bravo",
2427 "the model reads the PROMPT — the minibuffer is the ex-line's store",
2428 );
2429 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
2430 }
2431
2432 #[test]
2433 fn backspacing_past_the_slash_closes_the_prompt() {
2434 let mut st = new_state_with("alpha\n");
2435 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2436 st.apply(&Action::InsertChar('a'));
2437 st.apply(&Action::PromptBackspace);
2438 st.apply(&Action::PromptBackspace);
2439 assert!(!st.search.is_prompting(), "prompt closed");
2440 assert_eq!(st.modal.mode(), Mode::Normal);
2441 }
2442
2443 #[test]
2444 fn noh_clears_highlights_and_keeps_the_pattern() {
2445 let mut st = new_state_with("foo\nbar\nfoo\n");
2446 type_search(&mut st, SearchDirection::Forward, "foo");
2447 assert!(!st.search.highlights().is_empty());
2448 st.run_command("noh", &[]);
2449 assert!(st.search.highlights().is_empty(), ":noh turns them off");
2450 assert!(st.search.pattern().is_some(), "but n still works");
2451 }
2452
2453 #[test]
2454 fn noh_accepts_the_vim_aliases() {
2455 for name in ["noh", "nohl", "nohlsearch"] {
2456 let mut st = new_state_with("foo\nfoo\n");
2457 type_search(&mut st, SearchDirection::Forward, "foo");
2458 st.run_command(name, &[]);
2459 assert!(st.search.highlights().is_empty(), "{name} must clear");
2460 }
2461 }
2462
2463 #[test]
2464 fn backspace_on_the_ex_line_does_not_touch_search_state() {
2465 let mut st = new_state_with("foo\n");
2466 st.apply(&Action::ChangeMode(Mode::Command));
2467 st.apply(&Action::InsertChar('w'));
2468 st.apply(&Action::InsertChar('q'));
2469 st.apply(&Action::PromptBackspace);
2470 assert_eq!(st.status_model().prompt_text, "w");
2471 assert!(st.search.prompt().is_none(), "no search was involved");
2472 }
2473
2474 #[test]
2475 fn up_arrow_recalls_the_previous_search() {
2476 let mut st = new_state_with("alpha\nbravo\n");
2477 type_search(&mut st, SearchDirection::Forward, "bravo");
2478 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2479 st.apply(&Action::PromptHistory { back: true });
2480 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2481 assert_eq!(
2482 st.status_model().prompt_text,
2483 "bravo",
2484 "display follows the prompt"
2485 );
2486 }
2487
2488 #[test]
2489 fn arrowing_back_down_restores_the_half_typed_pattern() {
2490 let mut st = new_state_with("alpha\nbravo\n");
2491 type_search(&mut st, SearchDirection::Forward, "bravo");
2492 st.apply(&Action::SearchOpen(SearchDirection::Forward));
2493 st.apply(&Action::InsertChar('a'));
2494 st.apply(&Action::PromptHistory { back: true });
2495 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
2496 st.apply(&Action::PromptHistory { back: false });
2497 assert_eq!(
2498 st.search.prompt().unwrap().text(),
2499 "a",
2500 "the draft comes back"
2501 );
2502 assert_eq!(st.status_model().prompt_text, "a");
2503 }
2504
2505 #[test]
2506 fn history_arrows_do_nothing_on_the_ex_line() {
2507 let mut st = new_state_with("alpha\n");
2508 st.apply(&Action::ChangeMode(Mode::Command));
2509 st.apply(&Action::InsertChar('w'));
2510 st.apply(&Action::PromptHistory { back: true });
2511 assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
2512 }
2513
2514 fn new_state_with(text: &str) -> EditorState {
2515 let mut bufs = BufferSet::new();
2516 let id = bufs.scratch(text);
2517 EditorState::new_with_buffer(bufs, id)
2518 }
2519
2520 /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
2521 /// action advances `edit_gen` (so the renderer repaints), and merely
2522 /// reading the generation does not. This is what lets `gpu.rs` gate the
2523 /// re-highlight/re-shape on a generation change — an idle frame observes an
2524 /// unchanged generation and reuses its cached buffer.
2525 #[test]
2526 fn edit_gen_advances_on_applied_action_not_on_read() {
2527 let mut s = new_state_with("hello\nworld\n");
2528 let g0 = s.edit_gen();
2529 s.apply(&Action::InsertChar('X'));
2530 assert_ne!(
2531 s.edit_gen(),
2532 g0,
2533 "an applied action must advance the refresh generation",
2534 );
2535 // Reading the generation is not a mutation — idle frames stay put.
2536 let g1 = s.edit_gen();
2537 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
2538 }
2539
2540 /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
2541 /// `Damage` to cover exactly what changed — local for an in-place edit,
2542 /// to-end-of-document when the line count shifts — and the renderer drains
2543 /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
2544 #[test]
2545 fn damage_tracks_edit_scope_and_drains() {
2546 let mut s = new_state_with("hello\nworld\n");
2547 assert!(s.damage().is_none(), "a fresh state has no damage");
2548
2549 s.apply(&Action::InsertChar('X')); // in-place edit on line 0
2550 assert_eq!(
2551 s.damage(),
2552 Damage::Lines { from: 0, to: 0 },
2553 "a local edit damages just its line",
2554 );
2555
2556 let drained = s.take_damage();
2557 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
2558 assert!(s.damage().is_none(), "take_damage drains to None");
2559
2560 s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
2561 assert_eq!(
2562 s.damage(),
2563 Damage::Lines {
2564 from: 0,
2565 to: u32::MAX,
2566 },
2567 "a line-count change damages to end-of-document",
2568 );
2569 }
2570
2571 /// A state whose active window is a deliberately tiny viewport
2572 /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
2573 /// invariant is exercised on small inputs.
2574 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
2575 let mut s = new_state_with(text);
2576 for w in &mut s.layout.windows {
2577 w.viewport.visible_lines = vis_lines;
2578 w.viewport.visible_columns = vis_cols;
2579 }
2580 s
2581 }
2582
2583 /// The core regression invariant: the active window's viewport CONTAINS
2584 /// the cursor on BOTH axes. This is the operator's exact complaint —
2585 /// "typing past the bottom (or right) leaves the cursor off-screen" —
2586 /// made into a checkable property.
2587 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
2588 let w = s.layout.active_window().expect("active window");
2589 let v = w.viewport;
2590 let c = s.cursor();
2591 assert!(
2592 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
2593 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
2594 c.line,
2595 v.top_line,
2596 v.top_line + v.visible_lines,
2597 );
2598 assert!(
2599 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
2600 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
2601 c.column,
2602 v.left_column,
2603 v.left_column + v.visible_columns,
2604 );
2605 }
2606
2607 fn press(kc: KeyCode) -> AppEvent {
2608 AppEvent::Key(KeyEvent {
2609 key: kc,
2610 pressed: true,
2611 modifiers: Modifiers::default(),
2612 text: None,
2613 })
2614 }
2615
2616 // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
2617
2618 fn line0_len(s: &EditorState) -> u32 {
2619 s.buffers.get(s.active).unwrap().line_len_chars(0)
2620 }
2621
2622 #[test]
2623 fn delete_to_line_end_clears_line_and_fills_register() {
2624 let mut s = new_state_with("hello world");
2625 s.apply(&Action::ApplyOperator {
2626 op: Operator::Delete,
2627 motion: Motion::LineEnd,
2628 });
2629 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
2630 assert_eq!(
2631 s.register(),
2632 Some("hello world"),
2633 "delete fills the register"
2634 );
2635 assert_eq!(
2636 s.cursor(),
2637 Position::ZERO,
2638 "cursor lands at the range start"
2639 );
2640 }
2641
2642 #[test]
2643 fn delete_over_right_motion_removes_one_char() {
2644 let mut s = new_state_with("abc");
2645 s.apply(&Action::ApplyOperator {
2646 op: Operator::Delete,
2647 motion: Motion::Right,
2648 });
2649 assert_eq!(
2650 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2651 Some("bc")
2652 );
2653 assert_eq!(s.register(), Some("a"));
2654 }
2655
2656 #[test]
2657 fn change_to_line_end_deletes_and_enters_insert() {
2658 let mut s = new_state_with("hello world");
2659 assert_eq!(s.modal.mode(), Mode::Normal);
2660 s.apply(&Action::ApplyOperator {
2661 op: Operator::Change,
2662 motion: Motion::LineEnd,
2663 });
2664 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
2665 assert_eq!(
2666 s.modal.mode(),
2667 Mode::Insert,
2668 "change enters Insert to type the replacement"
2669 );
2670 assert_eq!(
2671 s.register(),
2672 Some("hello world"),
2673 "change fills the register"
2674 );
2675 }
2676
2677 #[test]
2678 fn yank_to_line_end_fills_register_without_mutating() {
2679 let mut s = new_state_with("hello world");
2680 s.apply(&Action::ApplyOperator {
2681 op: Operator::Yank,
2682 motion: Motion::LineEnd,
2683 });
2684 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
2685 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
2686 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
2687 }
2688
2689 #[test]
2690 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
2691 // The encapsulation proof: apply_motion (cursor move) and
2692 // apply_operator (range end) BOTH stand on resolve_motion — so a move
2693 // to LineEnd lands at exactly the position the operator deletes to.
2694 let mut s = new_state_with("hello world");
2695 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
2696 assert_eq!(target, Position::new(0, 11));
2697 s.apply_motion(Motion::LineEnd);
2698 assert_eq!(
2699 s.cursor(),
2700 target,
2701 "the move path resolves the same target the operator uses"
2702 );
2703 }
2704
2705 #[test]
2706 fn empty_motion_range_is_a_no_op() {
2707 // An operator over a zero-width motion (cursor already at line start)
2708 // mutates nothing and leaves the register untouched.
2709 let mut s = new_state_with("abc");
2710 s.apply(&Action::ApplyOperator {
2711 op: Operator::Delete,
2712 motion: Motion::LineStart,
2713 });
2714 assert_eq!(
2715 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2716 Some("abc")
2717 );
2718 assert_eq!(s.register(), None);
2719 }
2720
2721 #[test]
2722 fn operator_then_motion_composes_through_the_pending_fsm() {
2723 // The full keymap→FSM→engine path: dispatching the `d` operator action
2724 // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
2725 // the operator key alone does nothing until the motion arrives.
2726 let mut s = new_state_with("hello world");
2727 s.apply(&Action::Operator(Operator::Delete));
2728 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
2729 s.apply(&Action::Move(Motion::LineEnd));
2730 assert_eq!(
2731 line0_len(&s),
2732 0,
2733 "d then $ composes d$ and deletes the line"
2734 );
2735 assert_eq!(s.register(), Some("hello world"));
2736 }
2737
2738 #[test]
2739 fn change_operator_through_fsm_enters_insert() {
2740 let mut s = new_state_with("hello world");
2741 s.apply(&Action::Operator(Operator::Change));
2742 s.apply(&Action::Move(Motion::LineEnd));
2743 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
2744 }
2745
2746 #[test]
2747 fn lone_motion_after_no_operator_just_moves() {
2748 // Without a preceding operator the motion passes through unchanged.
2749 let mut s = new_state_with("hello world");
2750 s.apply(&Action::Move(Motion::LineEnd));
2751 assert_eq!(s.cursor(), Position::new(0, 11));
2752 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
2753 }
2754
2755 #[test]
2756 fn counted_operator_deletes_count_times() {
2757 // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
2758 // flows through the FSM to the composed motion (the bug fix: previously
2759 // the count repeated the operator key and toggled the FSM).
2760 let mut s = new_state_with("abcdef");
2761 s.apply_counted(&Action::Operator(Operator::Delete), 3);
2762 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
2763 s.apply(&Action::Move(Motion::Right));
2764 assert_eq!(
2765 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2766 Some("def")
2767 );
2768 }
2769
2770 #[test]
2771 fn operator_and_motion_counts_multiply_end_to_end() {
2772 // `2d3l` = delete 2×3 = 6 chars.
2773 let mut s = new_state_with("abcdefgh");
2774 s.apply_counted(&Action::Operator(Operator::Delete), 2);
2775 s.apply_counted(&Action::Move(Motion::Right), 3);
2776 assert_eq!(
2777 s.buffers.get(s.active).unwrap().line(0).as_deref(),
2778 Some("gh")
2779 );
2780 }
2781
2782 #[test]
2783 fn bare_counted_motion_still_repeats_no_regression() {
2784 // `3j` still moves down 3 lines — the count passes through the FSM
2785 // unchanged when no operator is pending.
2786 let mut s = new_state_with("a\nb\nc\nd\ne");
2787 s.apply_counted(&Action::Move(Motion::Down), 3);
2788 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
2789 }
2790
2791 /// A monotonic clock for the key-repeat gate in tests — each `next()`
2792 /// jumps a full second past the previous, so every press it stamps is
2793 /// well outside the 80ms debounce window and therefore an INTENTIONAL
2794 /// press (never a storm tick). Used by tests that fire the *same*
2795 /// navigation key twice and assert editor logic, not debounce timing.
2796 struct SpacedClock(std::time::Instant);
2797 impl SpacedClock {
2798 fn new() -> Self {
2799 Self(std::time::Instant::now())
2800 }
2801 fn next(&mut self) -> std::time::Instant {
2802 self.0 += std::time::Duration::from_secs(1);
2803 self.0
2804 }
2805 }
2806
2807 #[test]
2808 fn hjkl_moves_cursor() {
2809 let mut s = new_state_with("hello\nworld");
2810 s.tick(&press(KeyCode::Char('l')));
2811 assert_eq!(s.cursor().column, 1);
2812 s.tick(&press(KeyCode::Char('j')));
2813 assert_eq!(s.cursor().line, 1);
2814 s.tick(&press(KeyCode::Char('h')));
2815 assert_eq!(s.cursor().column, 0);
2816 }
2817
2818 #[test]
2819 fn insert_mode_inserts_chars() {
2820 let mut s = new_state_with("");
2821 s.tick(&press(KeyCode::Char('i')));
2822 assert_eq!(s.modal.mode(), Mode::Insert);
2823 s.tick(&press(KeyCode::Char('h')));
2824 s.tick(&press(KeyCode::Char('i')));
2825 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
2826 assert_eq!(s.cursor().column, 2);
2827 }
2828
2829 #[test]
2830 fn esc_returns_to_normal() {
2831 let mut s = new_state_with("");
2832 s.tick(&press(KeyCode::Char('i')));
2833 s.tick(&press(KeyCode::Escape));
2834 assert_eq!(s.modal.mode(), Mode::Normal);
2835 }
2836
2837 #[test]
2838 fn count_prefix_repeats_motion() {
2839 let mut s = new_state_with("abcdefghij");
2840 s.tick(&press(KeyCode::Char('5')));
2841 s.tick(&press(KeyCode::Char('l')));
2842 assert_eq!(s.cursor().column, 5);
2843 }
2844
2845 #[test]
2846 fn close_event_requests_quit() {
2847 let mut s = new_state_with("");
2848 s.tick(&AppEvent::CloseRequested);
2849 assert!(s.quit_requested);
2850 }
2851
2852 #[test]
2853 fn word_next_jumps_past_whitespace() {
2854 let mut s = new_state_with("foo bar baz");
2855 // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
2856 // the gate passes both (a real user's two taps are ≥80ms apart).
2857 let mut clk = SpacedClock::new();
2858 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2859 assert_eq!(s.cursor().column, 4);
2860 s.tick_at(&press(KeyCode::Char('w')), clk.next());
2861 assert_eq!(s.cursor().column, 8);
2862 }
2863
2864 // ── Multi-key / leader pending-stroke ───────────────────────────
2865
2866 #[test]
2867 fn leader_sequence_holds_then_resolves() {
2868 let mut s = new_state_with("a\nbb\nccc");
2869 s.keymap.bind_sequence(
2870 Mode::Normal,
2871 vec![Key::Char(','), Key::Char('g')],
2872 Action::Move(Motion::DocEnd),
2873 "doc end",
2874 );
2875 // `,` begins the sequence — held pending, nothing applied yet.
2876 s.on_key(&Key::Char(','));
2877 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
2878 assert_eq!(s.cursor(), Position::ZERO);
2879 // `g` completes `<leader>g` → DocEnd; pending clears.
2880 s.on_key(&Key::Char('g'));
2881 assert!(s.pending_keys.is_empty());
2882 assert_eq!(s.cursor().line, 2);
2883 }
2884
2885 #[test]
2886 fn two_key_gg_jumps_doc_start() {
2887 let mut s = new_state_with("a\nbb\nccc");
2888 s.keymap.bind_sequence(
2889 Mode::Normal,
2890 vec![Key::Char('g'), Key::Char('g')],
2891 Action::Move(Motion::DocStart),
2892 "doc start",
2893 );
2894 let mut clk = SpacedClock::new();
2895 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2896 s.tick_at(&press(KeyCode::Char('j')), clk.next());
2897 assert_eq!(s.cursor().line, 2);
2898 s.on_key(&Key::Char('g')); // pending
2899 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2900 s.on_key(&Key::Char('g')); // resolve
2901 assert_eq!(s.cursor(), Position::ZERO);
2902 }
2903
2904 #[test]
2905 fn broken_sequence_aborts_and_clears_pending() {
2906 let mut s = new_state_with("hello");
2907 s.keymap.bind_sequence(
2908 Mode::Normal,
2909 vec![Key::Char('g'), Key::Char('g')],
2910 Action::Move(Motion::DocEnd),
2911 "doc end",
2912 );
2913 s.on_key(&Key::Char('g')); // pending [g]
2914 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
2915 s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
2916 assert!(s.pending_keys.is_empty());
2917 assert_eq!(s.cursor(), Position::ZERO);
2918 }
2919
2920 #[test]
2921 fn single_binding_wins_over_sequence_prefix() {
2922 // A key that is BOTH a complete single binding and the start of
2923 // a sequence fires the single binding immediately (no chord
2924 // timeout needed). Here `h` (move-left) also prefixes `hz`.
2925 let mut s = new_state_with("abcde");
2926 let mut clk = SpacedClock::new();
2927 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2928 s.tick_at(&press(KeyCode::Char('l')), clk.next());
2929 assert_eq!(s.cursor().column, 2);
2930 s.keymap.bind_sequence(
2931 Mode::Normal,
2932 vec![Key::Char('h'), Key::Char('z')],
2933 Action::Move(Motion::DocEnd),
2934 "shadowed",
2935 );
2936 s.on_key(&Key::Char('h'));
2937 assert!(s.pending_keys.is_empty(), "single binding should not pend");
2938 assert_eq!(s.cursor().column, 1, "h moved left immediately");
2939 }
2940
2941 // ── tatara-lisp runtime bridge (imperative programmability) ─────
2942
2943 #[test]
2944 fn lisp_set_option_writes_live_options() {
2945 let mut s = new_state_with("");
2946 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
2947 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
2948 }
2949
2950 #[test]
2951 fn lisp_insert_modifies_buffer_and_advances_cursor() {
2952 let mut s = new_state_with("");
2953 s.run_lisp(r#"(insert "abc")"#).unwrap();
2954 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2955 assert_eq!(s.cursor(), Position::new(0, 3));
2956 }
2957
2958 #[test]
2959 fn lisp_message_appends_to_messages() {
2960 let mut s = new_state_with("");
2961 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
2962 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
2963 }
2964
2965 #[test]
2966 fn lisp_reads_snapshot_and_branches_to_effect() {
2967 // Genuine programmability: Lisp reads the live cursor line and
2968 // an `if` decides which option to set.
2969 let mut s = new_state_with("one\ntwo\nthree");
2970 // cursor at line 0 → "top" branch
2971 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
2972 .unwrap();
2973 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
2974 }
2975
2976 #[test]
2977 fn lisp_run_command_effect_drives_registry() {
2978 // `(run-command "undo")` reaches the live command registry and
2979 // reverts a prior Lisp-driven insert — proving the RunCommand
2980 // effect dispatches through real editor commands.
2981 let mut s = new_state_with("");
2982 s.run_lisp(r#"(insert "abc")"#).unwrap();
2983 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
2984 s.run_lisp(r#"(run-command "undo")"#).unwrap();
2985 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
2986 }
2987
2988 #[test]
2989 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
2990 // The full imperative-quit path: (run-command "quit") routes
2991 // through the registry's typed `quit_requested` signal — no string
2992 // sentinel, and no minibuffer pollution (the editor stays in a
2993 // clean Normal state, which has no minibuffer at all).
2994 let mut s = new_state_with("");
2995 s.run_lisp(r#"(run-command "quit")"#).unwrap();
2996 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
2997 assert_eq!(
2998 s.modal.minibuffer(),
2999 "",
3000 "quit must not pollute any command line — Normal mode has no minibuffer",
3001 );
3002 }
3003
3004 // ── Lazy plugin activation (PluginHost) ────────────────────────
3005
3006 #[test]
3007 fn lazy_plugin_activates_on_command_trigger() {
3008 // A user plugin gated on `Command: LazyGo` has its entry applied
3009 // the first time that command runs — proving the lazy.nvim
3010 // `cmd =` model works end-to-end against live editor state.
3011 let mut s = new_state_with("");
3012 s.register_lazy_plugin(
3013 "user-lazy",
3014 vec![LazyTrigger::Command("LazyGo".into())],
3015 r#"(defoption :name "lazy-loaded" :value "yes")
3016 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
3017 );
3018 assert_eq!(s.plugin_host.pending(), 1);
3019 assert!(
3020 s.options.get("lazy-loaded").is_none(),
3021 "entry not applied yet"
3022 );
3023
3024 // Drive the command through the public imperative path.
3025 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
3026
3027 assert_eq!(
3028 s.options.get("lazy-loaded").map(String::as_str),
3029 Some("yes"),
3030 "the command trigger applied the plugin's entry",
3031 );
3032 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
3033 }
3034
3035 #[test]
3036 fn lazy_plugin_activates_on_filetype() {
3037 let mut s = new_state_with("");
3038 s.register_lazy_plugin(
3039 "user-rust",
3040 vec![LazyTrigger::FileType("rust".into())],
3041 r#"(defoption :name "rust-plugin" :value "on")"#,
3042 );
3043 let n = s.activate_filetype_plugins("rust");
3044 assert_eq!(n, 1);
3045 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
3046 // A second open of the same filetype is a no-op (one-shot).
3047 assert_eq!(s.activate_filetype_plugins("rust"), 0);
3048 }
3049
3050 #[test]
3051 fn cached_vm_serves_multiple_run_lisp_calls() {
3052 let mut s = new_state_with("");
3053 s.run_lisp(r#"(message "one")"#).unwrap();
3054 assert!(
3055 s.lisp_vm.is_some(),
3056 "VM should be cached after first run_lisp"
3057 );
3058 s.run_lisp(r#"(message "two")"#).unwrap();
3059 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
3060 }
3061
3062 #[test]
3063 fn lisp_define_persists_across_run_lisp_calls() {
3064 // The cached VM's top-level env persists across calls (REPL
3065 // semantics): a `define` in one call is visible in the next.
3066 let mut s = new_state_with("");
3067 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
3068 s.run_lisp(r#"(message greeting)"#).unwrap();
3069 assert_eq!(s.messages, vec!["hi".to_string()]);
3070 }
3071
3072 #[test]
3073 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
3074 // Within ONE call a program cannot observe its own writes — the
3075 // read snapshot is captured before eval, effects apply after. A
3076 // later call sees the refreshed snapshot.
3077 let mut s = new_state_with("");
3078 s.run_lisp(
3079 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
3080 )
3081 .unwrap();
3082 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
3083 assert_eq!(
3084 s.options.get("col").map(String::as_str),
3085 Some("stale-zero"),
3086 "cursor-column within the same call reads the pre-eval snapshot",
3087 );
3088 // After the first call the cursor advanced to column 2; the next
3089 // call's snapshot reflects it.
3090 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
3091 .unwrap();
3092 assert_eq!(
3093 s.options.get("col2").map(String::as_str),
3094 Some("live-two"),
3095 "a later call sees the refreshed snapshot",
3096 );
3097 }
3098
3099 #[test]
3100 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
3101 let mut s = new_state_with("");
3102 s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
3103 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
3104 assert_eq!(s.cursor(), Position::new(1, 3));
3105 }
3106
3107 #[test]
3108 fn visual_mode_sequence_resolves() {
3109 let mut s = new_state_with("abc");
3110 s.modal.enter(Mode::Visual);
3111 s.keymap.bind_sequence(
3112 Mode::Visual,
3113 vec![Key::Char('g'), Key::Char('e')],
3114 Action::Move(Motion::DocEnd),
3115 "ge",
3116 );
3117 s.on_key(&Key::Char('g'));
3118 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3119 s.on_key(&Key::Char('e'));
3120 assert!(s.pending_keys.is_empty());
3121 assert_eq!(
3122 s.cursor().column,
3123 3,
3124 "ge resolved to doc-end in visual mode"
3125 );
3126 }
3127
3128 #[test]
3129 fn sequence_abort_with_bound_breaking_key_redispatches() {
3130 // gg is a sequence; `l` (move-right) is a bound single key. After
3131 // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
3132 let mut s = new_state_with("abcde");
3133 s.keymap.bind_sequence(
3134 Mode::Normal,
3135 vec![Key::Char('g'), Key::Char('g')],
3136 Action::Move(Motion::DocEnd),
3137 "gg",
3138 );
3139 s.on_key(&Key::Char('g'));
3140 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
3141 s.on_key(&Key::Char('l'));
3142 assert!(s.pending_keys.is_empty());
3143 assert_eq!(
3144 s.cursor().column,
3145 1,
3146 "the breaking key l should re-dispatch as move-right",
3147 );
3148 }
3149
3150 // ── Viewport-follows-cursor invariant (both axes) ───────────────
3151
3152 #[test]
3153 fn viewport_contains_cursor_after_every_op() {
3154 // Tiny window: 5 visible lines × 10 visible columns. Drive a
3155 // representative scripted sequence and assert the viewport contains
3156 // the cursor after EVERY mutating step.
3157 let mut s = new_state_small_viewport("", 5, 10);
3158 assert_cursor_in_viewport(&s, "initial");
3159
3160 // Enter insert mode and type 30 newline-separated lines — this is
3161 // the exact "type past the bottom" complaint.
3162 s.tick(&press(KeyCode::Char('i')));
3163 assert_eq!(s.modal.mode(), Mode::Insert);
3164 for line in 0..30u32 {
3165 for c in "line".chars() {
3166 s.tick(&press(KeyCode::Char(c)));
3167 assert_cursor_in_viewport(&s, "typing chars");
3168 }
3169 s.tick(&press(KeyCode::Enter));
3170 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
3171 }
3172
3173 // Type a long (200-char) line — the "type past the right edge"
3174 // complaint. The cursor must stay horizontally visible the whole way.
3175 for i in 0..200u32 {
3176 s.tick(&press(KeyCode::Char('x')));
3177 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
3178 }
3179
3180 // Multi-line insert_text effect (the `(insert …)` Lisp path).
3181 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
3182 assert_cursor_in_viewport(&s, "insert_text multiline");
3183
3184 // Back to normal mode and move in all directions / to extremes.
3185 s.tick(&press(KeyCode::Escape));
3186 assert_eq!(s.modal.mode(), Mode::Normal);
3187 for m in [
3188 Motion::DocStart,
3189 Motion::DocEnd,
3190 Motion::Down,
3191 Motion::Down,
3192 Motion::Up,
3193 Motion::Right,
3194 Motion::Right,
3195 Motion::Left,
3196 Motion::LineEnd,
3197 Motion::LineStart,
3198 Motion::GotoLine(1),
3199 Motion::GotoLine(40),
3200 Motion::PageDown,
3201 Motion::PageUp,
3202 ] {
3203 s.apply_motion(m);
3204 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
3205 }
3206
3207 // Undo many times — the buffer shrinks; the viewport must re-follow
3208 // the (now clamped) cursor.
3209 for i in 0..50u32 {
3210 s.apply(&Action::Undo);
3211 assert_cursor_in_viewport(&s, &format!("undo {i}"));
3212 }
3213 // Redo back up.
3214 for i in 0..50u32 {
3215 s.apply(&Action::Redo);
3216 assert_cursor_in_viewport(&s, &format!("redo {i}"));
3217 }
3218 }
3219
3220 #[test]
3221 fn insert_at_eof_keeps_cursor_in_bounds() {
3222 // Inserting at the end of the buffer must leave the cursor clamped
3223 // to a valid position (and inside the viewport).
3224 let mut s = new_state_small_viewport("abc", 5, 10);
3225 s.apply_motion(Motion::DocEnd);
3226 s.tick(&press(KeyCode::Char('i')));
3227 s.tick(&press(KeyCode::Char('d')));
3228 let buf = s.buffers.get(s.active).unwrap();
3229 let clamped = buf.clamp(s.cursor());
3230 assert_eq!(
3231 s.cursor(),
3232 clamped,
3233 "cursor must be clamped in-bounds at EOF"
3234 );
3235 assert_cursor_in_viewport(&s, "insert at eof");
3236 }
3237
3238 #[test]
3239 fn count_prefix_then_sequence_repeats() {
3240 // `2` then `gj` (→ move-down) repeats the resolved action twice.
3241 let mut s = new_state_with("a\nb\nc\nd\ne");
3242 s.keymap.bind_sequence(
3243 Mode::Normal,
3244 vec![Key::Char('g'), Key::Char('j')],
3245 Action::Move(Motion::Down),
3246 "gj",
3247 );
3248 s.on_key(&Key::Char('2'));
3249 s.on_key(&Key::Char('g'));
3250 s.on_key(&Key::Char('j'));
3251 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
3252 }
3253
3254 // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
3255
3256 #[test]
3257 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
3258 // The audit's exact complaint: holding `j` floods motion events
3259 // and thrashes the viewport. Simulate an OS key-repeat storm — 20
3260 // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
3261 // — and assert only the gated subset (one per 80ms window) actually
3262 // moves the cursor.
3263 let mut s = new_state_with(&"x\n".repeat(40));
3264 let t0 = std::time::Instant::now();
3265 let mut delivered = 0u32;
3266 for i in 0..20u32 {
3267 let before = s.cursor().line;
3268 s.tick_at(
3269 &press(KeyCode::Char('j')),
3270 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
3271 );
3272 if s.cursor().line != before {
3273 delivered += 1;
3274 }
3275 }
3276 // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
3277 // fewer than the 20 the ungated path would have applied.
3278 assert!(
3279 (10..=14).contains(&delivered),
3280 "expected the storm debounced to ~13 moves, got {delivered}",
3281 );
3282 assert!(
3283 delivered < 20,
3284 "the gate must drop SOME storm ticks, not pass all 20",
3285 );
3286 }
3287
3288 #[test]
3289 fn spaced_intentional_taps_all_pass() {
3290 // Intentional taps spaced past the debounce window must ALL reach
3291 // the editor — the gate filters storms, never deliberate input.
3292 let mut s = new_state_with(&"x\n".repeat(10));
3293 let t0 = std::time::Instant::now();
3294 for i in 0..5u32 {
3295 s.tick_at(
3296 &press(KeyCode::Char('j')),
3297 // 100ms apart — comfortably past the 80ms window.
3298 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
3299 );
3300 }
3301 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
3302 }
3303
3304 #[test]
3305 fn distinct_keys_have_independent_clocks() {
3306 // Holding `j` must not block a simultaneous `l` — the gate keys on
3307 // the Key, so independent keys have independent windows.
3308 let mut s = new_state_with("abc\ndef\nghi");
3309 let t = std::time::Instant::now();
3310 s.tick_at(&press(KeyCode::Char('j')), t);
3311 // `j` again within the window is dropped…
3312 s.tick_at(
3313 &press(KeyCode::Char('j')),
3314 t + std::time::Duration::from_millis(10),
3315 );
3316 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
3317 // …but `l` at the same instant passes (its own clock).
3318 s.tick_at(
3319 &press(KeyCode::Char('l')),
3320 t + std::time::Duration::from_millis(10),
3321 );
3322 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
3323 }
3324
3325 // ── Cursors newtype is the single cursor home ──────────────────────
3326
3327 #[test]
3328 fn cursor_home_preserves_single_cursor_behavior() {
3329 // The typed `Cursors` wrapper behaves exactly like the old bare
3330 // `Position` field for single-cursor editing: the read accessor
3331 // tracks every mutation routed through `set_cursor`, and there is
3332 // exactly one caret.
3333 let mut s = new_state_with("hello\nworld\nthere");
3334 assert_eq!(s.cursor(), Position::ZERO);
3335 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
3336
3337 s.apply_motion(Motion::Down);
3338 s.apply_motion(Motion::Right);
3339 s.apply_motion(Motion::Right);
3340 assert_eq!(s.cursor(), Position::new(1, 2));
3341 // Still a single caret after a sequence of motions.
3342 assert_eq!(s.cursors.count(), 1);
3343
3344 // The accessor is the SAME value the viewport-follow path read.
3345 let w = s.layout.active_window().unwrap();
3346 assert!(w.viewport.top_line <= s.cursor().line);
3347 }
3348
3349 #[test]
3350 fn insert_mode_is_ungated_so_repeat_typing_works() {
3351 // Holding a key to repeat-type a character is intended in Insert
3352 // mode — the gate must NOT suppress it. 10 rapid identical `x`
3353 // keystrokes at the same instant must all land as text.
3354 let mut s = new_state_with("");
3355 s.tick(&press(KeyCode::Char('i')));
3356 assert_eq!(s.modal.mode(), Mode::Insert);
3357 let t = std::time::Instant::now();
3358 for _ in 0..10 {
3359 s.tick_at(&press(KeyCode::Char('x')), t);
3360 }
3361 assert_eq!(
3362 s.buffers.get(s.active).unwrap().to_string(),
3363 "xxxxxxxxxx",
3364 "insert-mode repeat typing is ungated",
3365 );
3366 }
3367}