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};
17
18/// What one key meant to the operator-pending object layer.
19enum ObjectKey {
20 /// Swallowed — the key began an object and nothing runs yet.
21 Consumed,
22 /// The object is complete; run this.
23 Compose(Action),
24}
25pub use status::{PromptKind, StatusModel};
26
27use std::collections::HashMap;
28
29use awase::KeyRepeatGate;
30use escriba_buffer::BufferSet;
31use escriba_buffer::TextRev;
32use escriba_command::CommandRegistry;
33use escriba_core::{
34 Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, JumpList,
35 Mode, Motion, Operator, Position, Range, TextEffect, WindowId,
36};
37use escriba_input::{InputOutcome, translate_app_event};
38use escriba_keymap::{Key, Keymap};
39use escriba_madoguchi::{Negai, Outcome};
40use escriba_mode::ModalState;
41use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
42use escriba_ui::chrome::{ChromePalette, FleetTheme};
43use escriba_ui::splash::Splash;
44use escriba_ui::{Layout, Viewport, Window};
45use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
46use madori::AppEvent;
47use std::time::Instant;
48
49/// Full editor state — the single Rust value the binary hands to the
50/// renderer each frame.
51pub struct EditorState {
52 pub buffers: BufferSet,
53 pub modal: ModalState,
54 /// Search session — the committed pattern, its matches, the live `/`
55 /// prompt and history. Owns no buffer or cursor; it answers questions
56 /// about text and this runtime applies the answers.
57 pub search: SearchState,
58 pub keymap: Keymap,
59 pub commands: CommandRegistry,
60 pub layout: Layout,
61 pub active: BufferId,
62 /// The single typed home for cursor state. Phase-1 holds one primary
63 /// [`Position`]; reads go through [`Self::cursor`], writes through
64 /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
65 /// `Position` field beside an unused multi-caret type to desync.
66 cursors: Cursors,
67 pub quit_requested: bool,
68 /// Messages surfaced to the user (status line / `:messages`) — the
69 /// sink for the tatara-lisp `(message …)` effect and other feedback.
70 pub messages: Vec<String>,
71 /// Which match the cursor last landed on (0-based) — the `[3/17]`
72 /// numerator, ANCHORED to the text revision it was computed against.
73 ///
74 /// The anchor is what removes the manual invalidation this field used to
75 /// need. An ordinal indexes a match set; when the text changes the set
76 /// changes underneath it and the number silently means something else.
77 /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
78 /// forgetting to clear is no longer a thing that can be forgotten.
79 search_at: Option<Anchored<usize, TextRev>>,
80 /// The last text change, for `.`.
81 ///
82 /// An action plus whatever was typed while it held Insert open. Both
83 /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
84 /// one — the text that followed is the rest, and replaying without it
85 /// would delete a word and leave the buffer in Insert.
86 last_change: Option<LastChange>,
87 /// True while an insert session belonging to `last_change` is open, so
88 /// typed characters are appended to it. Cleared on leaving Insert.
89 recording_insert: bool,
90
91 /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
92 /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
93 /// makes a search a place you can come back from.
94 pub jumps: JumpList,
95 /// Generic editor option store (name → value). Written by the
96 /// tatara-lisp `(set-option …)` effect and the declarative
97 /// `defoption` apply path; typed accessors layer on top later.
98 pub options: HashMap<String, String>,
99 /// Cached embedded tatara-lisp runtime, built lazily on first
100 /// `run_lisp`. Caching avoids re-installing the ~175-definition full
101 /// stdlib on every call; the interpreter's top-level env also
102 /// persists across calls, giving REPL-like session semantics (an
103 /// earlier `(define …)` is visible to a later `run_lisp`).
104 lisp_vm: Option<EscribaVm>,
105 /// Keys accumulated for an in-progress multi-key sequence — e.g.
106 /// holding `[,, f]` while waiting for the final key of
107 /// `<leader>ff`. Empty when not mid-sequence. Lives on
108 /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
109 /// depend on `escriba-keymap`'s `Key`.
110 pub pending_keys: Vec<Key>,
111 /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
112 /// the windowing system deliver one `KeyDown` per repeat tick
113 /// (~30-50ms); without a gate those flood the motion path and thrash
114 /// the viewport. The gate lets ONE event per `min_interval` (80ms
115 /// default — ~12 intentional taps/sec still pass) reach the editor in
116 /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
117 /// the same one mado uses) is reused — not reinvented.
118 repeat_gate: KeyRepeatGate<Key>,
119 /// Runtime lazy-activation host for USER plugin caixas (the bundled
120 /// default catalog is applied eagerly at boot, not through here).
121 /// A command / filetype-open / event fires the matching plugins'
122 /// entries through the escriba-lisp apply paths. See [`PluginHost`].
123 pub plugin_host: PluginHost,
124 /// The unnamed register — the home for text an operator yanks or
125 /// deletes (`Operator::leaves_register`). `None` until the first
126 /// register-leaving operator runs. Phase-1 holds the single unnamed
127 /// register; named registers (`"ay`) layer on later.
128 register: Option<String>,
129 /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
130 /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
131 /// action passes through it; only an operator-then-motion pair is rewritten
132 /// into an [`Action::ApplyOperator`].
133 op_pending: zenmai::Stateful<OperatorPending>,
134 /// Operator-pending OBJECT selection, held at the KEY layer.
135 ///
136 /// `Some(around)` means `d` + `i`/`a` have been pressed and the NEXT key
137 /// names the object. It lives here rather than in the operator FSM
138 /// because the FSM sees `Action`s and this decision needs the KEY: `a`
139 /// and every bracket are unbound in Normal, so they all arrive as
140 /// `Action::Pending` with the character already discarded. vim has a
141 /// whole operator-pending keymap for the same reason.
142 pending_object: Option<bool>,
143 /// Monotonic refresh-generation stamp — the root of the sealed refresh
144 /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
145 /// action + resize; the renderer gates on it so an idle frame does zero
146 /// re-highlight / re-shape, and a stale frame is unreachable.
147 edit_gen: EditGen,
148 /// The accumulated dirty region since the renderer last drained it (M1).
149 /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
150 /// always covers the changed region (`Damage ⊇ changed`); the renderer
151 /// drains it with [`take_damage`](Self::take_damage) to scope its work.
152 damage: Damage,
153 /// The theme every face paints with.
154 ///
155 /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
156 /// resolved to a real `FleetTheme` — and then nothing consumed it,
157 /// because each renderer called `ChromePalette::prescribed()` at every
158 /// paint site. The declaration was honoured on paper only. Holding it
159 /// here means a face reads the operator's theme the same way it reads
160 /// the cursor: from the state, per frame.
161 theme: FleetTheme,
162 /// `theme` resolved to concrete colours — cached because it is a plain
163 /// `Copy` struct read many times per frame, and re-derived only in
164 /// [`set_theme`](Self::set_theme), so the two cannot disagree.
165 chrome: ChromePalette,
166 /// How deep the current command dispatch is nested.
167 ///
168 /// `Negai::RunCommand` lets a command invoke a command, which is useful
169 /// and which can also recurse forever. The budget makes the runaway
170 /// bounded and REPORTED rather than a stack overflow — the difference
171 /// between a typed refusal and the editor dying under the operator.
172 dispatch_depth: u8,
173 /// Every live result list — diagnostics, hunks, grep hits, TODOs.
174 ///
175 /// Public so a producer outside the runtime can publish into it once the
176 /// courier lands; today the only producer is the marker scan.
177 pub results: escriba_shirube::ListRegistry,
178 /// The open picker, if any.
179 ///
180 /// `Option<Picker>` on the state, exactly like `splash` — deliberately
181 /// NOT a `Mode` variant. A mode is a state keys are interpreted IN; this
182 /// is a surface that OWNS keys while it is up, which is a different
183 /// thing and composes differently with the keymap.
184 picker: Option<escriba_ui::picker::Picker>,
185 /// The git-index generation. See [`world`](Self::world) — every axis the
186 /// world can move is emitted unconditionally, so a producer anchoring on
187 /// one is not born permanently stale.
188 index_rev: escriba_shirube::IndexRev,
189 /// The external-session generation (LSP restart, debug session, test run).
190 session_gen: escriba_shirube::SessionGen,
191 /// Extension → language facts, populated from `(defmode …)`.
192 ///
193 /// The consumer `:commentstring` never had. Public so the binary's apply
194 /// pass can fill it the way it fills the keymap and the option store.
195 pub filetypes: escriba_core::FiletypeTable,
196 /// The start screen, while it is up.
197 ///
198 /// `Some` only between boot and the first keypress, and only when the
199 /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
200 /// is a state keys are interpreted *in*, and the splash interprets
201 /// exactly one key before it is gone. Modelling it as `Option<Splash>`
202 /// keeps the modal state machine's variant set — and every exhaustive
203 /// match over it — untouched.
204 splash: Option<Splash>,
205}
206
207/// What the start screen did with a keypress.
208///
209/// Total, and matched exhaustively at its one call site, so a future
210/// outcome (a menu that opens a submenu, say) is a compile error rather
211/// than a key that silently falls through to the buffer.
212enum SplashKey {
213 /// No start screen is up — the key is the buffer's.
214 NotShowing,
215 /// The key selected a menu entry; run this.
216 Ran(Action),
217 /// The screen is gone and the key was not a menu key, so it still
218 /// means whatever it normally means. Anything else would make the
219 /// first keystroke after boot vanish.
220 Dismissed,
221}
222
223// ─── The counter, and the one place slips become mutations ───────────────
224
225/// `EditorState` read through the counter.
226///
227/// Borrowed, never copied: building it is free, so a command dispatch does
228/// not pay for a snapshot of the buffers.
229pub struct EditorWindow<'a> {
230 state: &'a EditorState,
231}
232
233impl escriba_madoguchi::CursorView for EditorWindow<'_> {
234 fn position(&self) -> Position {
235 self.state.cursor()
236 }
237 fn mode(&self) -> Mode {
238 self.state.modal.mode()
239 }
240}
241
242impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
243 fn filetype(&self) -> Option<&escriba_core::Filetype> {
244 let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
245 self.state.filetypes.resolve(path)
246 }
247}
248
249impl escriba_madoguchi::SearchView for EditorWindow<'_> {
250 fn pattern(&self) -> Option<&str> {
251 self.state.search.committed_pattern()
252 }
253 fn match_count(&self) -> Option<usize> {
254 // `None` means "nothing committed", which is not the same as zero
255 // matches — a distinction the status line already makes and that a
256 // handler must not have to re-derive.
257 self.state
258 .search
259 .committed_pattern()
260 .map(|_| self.state.search.match_count())
261 }
262 fn is_prompting(&self) -> bool {
263 self.state.search.is_prompting()
264 }
265}
266
267impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
268 fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
269 self.buffer(self.state.active)
270 }
271 fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
272 self.state
273 .buffers
274 .get(id)
275 .map(|b| b as &dyn escriba_madoguchi::BufferView)
276 }
277 fn buffer_ids(&self) -> Vec<BufferId> {
278 self.state.buffers.ids()
279 }
280 fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
281 self
282 }
283 fn option(&self, name: &str) -> Option<&str> {
284 self.state.options.get(name).map(String::as_str)
285 }
286 fn search(&self) -> &dyn escriba_madoguchi::SearchView {
287 self
288 }
289 fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
290 self
291 }
292}
293
294impl EditorState {
295 /// A read-only window onto this editor.
296 #[must_use]
297 pub fn window(&self) -> EditorWindow<'_> {
298 EditorWindow { state: self }
299 }
300
301 /// Honour an [`Outcome`] — the ONLY place slips become mutations.
302 ///
303 /// Every `&mut self` in the dispatch path lives here. A command cannot
304 /// reach editor state, so if the editor ends up in a state nobody
305 /// designed, this function is where it happened; that narrowing is the
306 /// whole return on the seam.
307 ///
308 /// A failed outcome's slips are DROPPED rather than half-applied: a
309 /// handler that reported failure has no business also mutating, and
310 /// applying part of what it asked for is how an editor reaches a state
311 /// nobody designed.
312 pub fn interpret(&mut self, outcome: Outcome) {
313 if let Some(m) = outcome.verdict.message() {
314 self.messages.push(m.to_string());
315 self.damage = self.damage.join(Damage::Viewport);
316 self.bump_gen();
317 }
318 if outcome.verdict.is_failure() {
319 return;
320 }
321 for slip in outcome.slips {
322 self.honour(slip);
323 }
324 }
325
326 /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
327 ///
328 /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
329 /// editing, the operator-pending FSM, motion resolution, the jumplist,
330 /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
331 /// one, and forcing them into `Negai` would put `PromptClearToStart` and
332 /// `SearchPreviewStep` in front of every plugin author and make the
333 /// capability question meaningless (what capability does a caret move
334 /// read?). One type serving two vocabularies is the mistake this avoids.
335 ///
336 /// The plan's M3 predicate was "apply_resolved contains zero `self.`
337 /// mutations", which would have forced exactly that. Amended: the
338 /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
339 /// vocabulary. See docs/backlog-plan.md §V Phase 1.
340 fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
341 Some(match action {
342 Action::Quit => vec![Negai::Quit],
343 Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
344 Action::Save => vec![Negai::Save { buffer: active }],
345 Action::Undo => vec![Negai::Undo { buffer: active }],
346 Action::Redo => vec![Negai::Redo { buffer: active }],
347 // `apply_edit` was a STUB that did nothing, so this action was a
348 // silent no-op while `Negai::Edit` applied for real. Lowering it
349 // makes keymap-originated edits work for the first time — and
350 // nothing binds it today, so the duplication goes away at zero
351 // risk.
352 Action::Edit(edit) => vec![Negai::Edit {
353 buffer: active,
354 edit: edit.clone(),
355 }],
356 _ => return None,
357 })
358 }
359
360 /// What the world currently is, for freshness.
361 ///
362 /// One text axis per open buffer. A list sealed against this is fresh
363 /// exactly while the buffers it depends on are unchanged — and a buffer
364 /// that has since CLOSED drops out, which makes lists about it stale
365 /// rather than silently kept.
366 #[must_use]
367 pub fn world(&self) -> escriba_shirube::Anchor {
368 let mut a = escriba_shirube::Anchor::new();
369 for id in self.buffers.ids() {
370 if let Some(b) = self.buffers.get(id) {
371 a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
372 }
373 }
374 // Every axis the world can move, ALWAYS present — not only the ones
375 // some producer happens to use today.
376 //
377 // `Anchor::is_fresh` treats an ABSENT axis as stale, deliberately:
378 // unknowable is not unchanged. The consequence, unnoticed until a
379 // recon pass went looking, is that a list anchored on an axis this
380 // function never emits is born PERMANENTLY stale — `]c` would answer
381 // "that list is out of date" forever, and nothing would say why. The
382 // two-axis model was built for git hunks and then only ever fed one
383 // axis.
384 //
385 // Emitting them unconditionally means a producer can anchor on any
386 // axis and get an honest answer. A counter that never moves reads as
387 // "unchanged", which is exactly right for a plane escriba does not
388 // track yet.
389 a = a.on(escriba_shirube::Axis::Index(self.index_rev));
390 a.on(escriba_shirube::Axis::Session(self.session_gen))
391 }
392
393 /// Where the cursor is, WITH the buffer it is in.
394 ///
395 /// Every jumplist push goes through this. A bare `Position` is what let
396 /// `<C-o>` return to the right line in the wrong file.
397 #[must_use]
398 pub fn spot(&self) -> escriba_core::Spot {
399 escriba_core::Spot::new(self.active, self.cursor())
400 }
401
402 /// Move to a `Spot`, switching buffer if it names another one.
403 ///
404 /// The read half of [`spot`](Self::spot). `<C-o>` and `<C-i>` both land
405 /// here so neither can forget the buffer.
406 fn goto_spot(&mut self, s: escriba_core::Spot) {
407 if s.buffer != self.active && self.buffers.get(s.buffer).is_some() {
408 self.active = s.buffer;
409 }
410 let clamped = self
411 .buffers
412 .get(self.active)
413 .map_or(s.pos, |b| b.clamp(s.pos));
414 self.set_cursor(clamped);
415 }
416
417 /// Advance the git-index generation — every list anchored on
418 /// `Axis::Index` goes stale.
419 ///
420 /// Not called yet; a git layer calls it after a stage/reset. Present so
421 /// the axis is WIRED rather than declared, because an axis nothing can
422 /// move is indistinguishable from an axis that does not exist.
423 pub fn bump_index_rev(&mut self) {
424 self.index_rev = escriba_shirube::IndexRev(self.index_rev.0.wrapping_add(1));
425 }
426
427 /// Advance the external-session generation — LSP restart, debug session,
428 /// test-runner invocation. See [`bump_index_rev`](Self::bump_index_rev).
429 pub fn bump_session_gen(&mut self) {
430 self.session_gen = escriba_shirube::SessionGen(self.session_gen.0.wrapping_add(1));
431 }
432
433 /// Move the cursor to the next/previous finding in `list`.
434 ///
435 /// Reports the wrap, because `n`/`N` do and a reader losing their place
436 /// in a long file is the same problem either way.
437 fn walk_list(&mut self, list: &str, forward: bool) {
438 let world = self.world();
439 let Some(result) = self.results.get(list) else {
440 let mut m = String::from("no list named ");
441 m.push_str(list);
442 self.messages.push(m);
443 return;
444 };
445 if result.is_stale(&world) {
446 self.messages
447 .push("that list is out of date — run it again".to_string());
448 return;
449 }
450 let here = (Some(self.active), self.cursor().line);
451 let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
452 else {
453 let mut m = String::from("no entries in ");
454 m.push_str(list);
455 self.messages.push(m);
456 return;
457 };
458 let site = found.site.clone();
459 let msg = found.message.clone();
460 self.jump_to_site(&site);
461 self.messages.push(msg);
462 }
463
464 /// Move the cursor to a located finding's SITE — the one operation that
465 /// cannot drop the buffer half of a location.
466 ///
467 /// A `Site` is `(buffer, range)`. Every jumper before this re-derived the
468 /// move itself and clamped against `self.active`, so a finding in another
469 /// file landed on the right LINE in the WRONG file. `on_line` and
470 /// `worst_on_line` already filter by buffer, so the gutter and the walker
471 /// disagreed — latent only because the first producer scanned one buffer.
472 ///
473 /// Every future producer (diagnostics, hunks, grep hits, test failures)
474 /// is cross-file by nature, which is why this is a shared operation
475 /// rather than a fix at the one call site that has it wrong today.
476 ///
477 /// Always a FAR jump: it pushes the jumplist, so `<C-o>` returns from a
478 /// `]t` exactly as it returns from an `n`.
479 pub fn jump_to_site(&mut self, site: &escriba_shirube::Site) {
480 self.jumps.push(self.spot());
481 // Switch buffers FIRST — clamping against the wrong buffer is how the
482 // position gets silently mangled before anyone can notice.
483 if let Some(target) = site.buffer {
484 if target != self.active && self.buffers.get(target).is_some() {
485 self.active = target;
486 self.refollow_cursor();
487 }
488 }
489 let to = site.range.start;
490 let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
491 self.set_cursor(clamped);
492 }
493
494 /// Close a buffer, keeping "there is always an active buffer" true.
495 ///
496 /// The invariant is the whole reason this is not just
497 /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
498 /// an `Option`, so a dangling active is not a degraded state — it is a
499 /// state where every read of the active buffer returns `None` and the
500 /// editor renders `<no buffer>` forever. Closing the last buffer opens a
501 /// scratch rather than emptying the set, which is what vim's `:bd` does
502 /// and what the type demands.
503 fn close_buffer(&mut self, id: BufferId) {
504 if self.buffers.close(id).is_none() {
505 self.messages.push("no such buffer".to_string());
506 return;
507 }
508 if self.active != id {
509 return;
510 }
511 // The active buffer went. Prefer the next one by id so repeated
512 // closes walk forward predictably rather than jumping around.
513 let next = self.buffers.ids().into_iter().find(|b| *b > id);
514 self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
515 Some(b) => b,
516 None => self.buffers.scratch(""),
517 };
518 self.set_cursor(Position::ZERO);
519 if let Some(w) = self.layout.active_window_mut() {
520 w.buffer_id = self.active;
521 }
522 }
523
524 /// Move to the next or previous buffer, wrapping.
525 fn cycle_buffer(&mut self, forward: bool) {
526 let ids = self.buffers.ids();
527 if ids.len() < 2 {
528 self.messages.push("only one buffer".to_string());
529 return;
530 }
531 let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
532 let next = if forward {
533 (at + 1) % ids.len()
534 } else {
535 (at + ids.len() - 1) % ids.len()
536 };
537 self.active = ids[next];
538 self.set_cursor(Position::ZERO);
539 if let Some(w) = self.layout.active_window_mut() {
540 w.buffer_id = self.active;
541 }
542 }
543
544 /// Re-clamp the cursor and re-contain the viewport after a buffer
545 /// mutation.
546 ///
547 /// An undo can SHRINK the buffer under a cursor that was legal a moment
548 /// ago, leaving it out of bounds and its viewport scrolled past the end.
549 /// The Action executor has always done this (`self.set_cursor(self.cursor())`
550 /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
551 /// and `:undo` did not — two implementations of one operation, already
552 /// drifted within one milestone of being written. Naming it once is the
553 /// fix; lowering the Action arms onto the same slips is what keeps it
554 /// fixed.
555 fn refollow(&mut self) {
556 self.set_cursor(self.cursor());
557 }
558
559 /// Apply one slip and record what it damaged.
560 ///
561 /// The bookkeeping wrapper. The Action executor calls
562 /// [`honour_one`](Self::honour_one) directly because it does its own,
563 /// wider bookkeeping (the dot register, the S3 damage seal) around a
564 /// whole action.
565 fn honour(&mut self, slip: Negai) {
566 let touches_text = slip.touches_text();
567 self.honour_one(slip);
568 self.damage = self.damage.join(if touches_text {
569 Damage::Full
570 } else {
571 Damage::Viewport
572 });
573 self.bump_gen();
574 }
575
576 /// Apply one slip. THE single implementation of every mutation a slip
577 /// can ask for.
578 ///
579 /// Total over `Negai`: a new request variant is a compile error here
580 /// rather than a request silently ignored — the same failure Phase 0
581 /// removed one layer up.
582 fn honour_one(&mut self, slip: Negai) {
583 match slip {
584 Negai::Edit { buffer, edit } => {
585 if let Some(b) = self.buffers.get_mut(buffer) {
586 let _ = b.apply(&edit);
587 }
588 self.refollow();
589 }
590 Negai::SetCursor { buffer, to } => {
591 // Clamping is the interpreter's job, exactly so that no
592 // handler has to re-implement it and get it wrong.
593 let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
594 self.set_cursor(clamped);
595 }
596 Negai::EnterMode(m) => self.modal.enter(m),
597 Negai::OpenPicker(source) => self.open_picker(source),
598 Negai::SplitWindow { stacked } => {
599 let axis = if stacked {
600 escriba_ui::shikiri::Axis::Stacked
601 } else {
602 escriba_ui::shikiri::Axis::SideBySide
603 };
604 self.layout.split_active(axis);
605 // The new pane is narrower/shorter than the old one, so the
606 // cursor can now be outside it. Every face re-reports its
607 // frame on the next draw, but the invariant must hold NOW —
608 // an operator who splits and immediately types should not be
609 // editing off-screen.
610 self.refollow_cursor();
611 self.damage = self.damage.join(Damage::Viewport);
612 }
613 Negai::CloseWindow => {
614 let id = self.layout.active();
615 if self.layout.close(id) {
616 self.refollow_cursor();
617 self.damage = self.damage.join(Damage::Viewport);
618 } else {
619 // vim's E444, and the same refusal: the last window is
620 // the editor. Closing it would mean "quit", which is a
621 // different verb the operator did not type.
622 self.messages
623 .push("E444: Cannot close last window".to_string());
624 }
625 }
626 Negai::FocusDir { dx, dy } => {
627 use escriba_ui::Dir;
628 let dir = match (dx, dy) {
629 (d, _) if d < 0 => Dir::Left,
630 (d, _) if d > 0 => Dir::Right,
631 (_, d) if d < 0 => Dir::Up,
632 _ => Dir::Down,
633 };
634 if let Some(id) = self.layout.neighbour(dir) {
635 self.layout.focus(id);
636 // The window we moved to has its OWN buffer; the editor's
637 // active buffer follows focus, or the next keystroke
638 // would edit the file we just navigated away from.
639 if let Some(w) = self.layout.active_window() {
640 self.active = w.buffer_id;
641 }
642 self.refollow_cursor();
643 self.damage = self.damage.join(Damage::Viewport);
644 }
645 // No neighbour is not an error — it is the edge of the
646 // layout, and vim says nothing there either.
647 }
648 Negai::GrepProject { pattern } => self.grep_project(&pattern),
649 Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
650 Negai::FocusBuffer(id) => {
651 if self.buffers.get(id).is_some() {
652 self.active = id;
653 }
654 }
655 Negai::OpenPath(path) => match self.buffers.open(&path) {
656 Ok(id) => self.active = id,
657 Err(e) => self.messages.push(e.to_string()),
658 },
659 Negai::CloseBuffer(id) => self.close_buffer(id),
660 Negai::Save { buffer } => {
661 if let Some(b) = self.buffers.get_mut(buffer) {
662 if let Err(e) = b.save() {
663 self.messages.push(e.to_string());
664 }
665 }
666 self.refollow();
667 }
668 Negai::Undo { buffer } => {
669 if let Some(b) = self.buffers.get_mut(buffer) {
670 let _ = b.undo();
671 }
672 self.refollow();
673 }
674 Negai::Redo { buffer } => {
675 if let Some(b) = self.buffers.get_mut(buffer) {
676 let _ = b.redo();
677 }
678 self.refollow();
679 }
680 Negai::Yank { text, .. } => self.register = Some(text),
681 Negai::ClearSearchHighlight => self.search.clear_highlight(),
682 Negai::SetOption { name, value } => {
683 self.options.insert(name, value);
684 }
685 Negai::InsertText(text) => self.insert_text(&text),
686 Negai::RunCommand { name, args } => self.run_command(&name, &args),
687 Negai::PublishFindings { list, findings } => {
688 let world = self.world();
689 self.results
690 .publish(list, escriba_shirube::ResultList::new(findings, world));
691 }
692 Negai::WalkList { list, forward } => self.walk_list(&list, forward),
693 Negai::Message(m) => self.messages.push(m),
694 Negai::Quit => self.quit_requested = true,
695 // Both suspend the dispatch and need machinery that does not
696 // exist yet — the courier (Phase 5) and the AwaitKey resume
697 // (M3). Announced, never silently dropped: a slip that vanishes
698 // is the class Phase 0 sealed.
699 Negai::Errand(_) | Negai::AwaitKey { .. } => {
700 self.messages
701 .push("deferred work is not wired yet".to_string());
702 }
703 }
704 }
705}
706
707/// Outcome of feeding one key to the multi-key pending-stroke loop.
708enum SeqStep {
709 /// Key consumed into an in-progress sequence; wait for the next.
710 Pending,
711 /// A full bound sequence resolved — run this action.
712 Resolved(Action),
713 /// Key is not part of any sequence; hand it to single-key dispatch.
714 Passthrough,
715}
716
717/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
718/// therefore exists to debounce.
719///
720/// This is an ALLOW-LIST, and it used to be the complement — an exception list
721/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
722/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
723/// swallowed and someone noticed. The third growth is the signal that the
724/// default was backwards: almost every key in a modal editor is a discrete,
725/// deliberate press, and only a handful are ones you HOLD.
726///
727/// Inverting it makes the failure mode safe. Forgetting to list a key here now
728/// means it is ungated — one extra keypress honoured — instead of silently
729/// dropped, and a dropped key is indistinguishable from a dead one.
730///
731/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
732/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
733/// is keyed by KEY and the two presses fell inside one debounce window.
734const fn is_repeat_storm_candidate(key: &Key) -> bool {
735 matches!(
736 key,
737 // The four navigation keys a user actually holds down. `h`/`l` and
738 // `j`/`k` flood the motion path and thrash the viewport; everything
739 // else is pressed once and meant once.
740 Key::Char('h')
741 | Key::Char('j')
742 | Key::Char('k')
743 | Key::Char('l')
744 | Key::Left
745 | Key::Right
746 | Key::Up
747 | Key::Down
748 )
749}
750
751/// Turn a command failure into the sentence an operator should read.
752///
753/// The two failures mean genuinely different things and must not be reported
754/// the same way:
755///
756/// - `:flurb` — the operator typed a name that does not exist. "command not
757/// found" is exactly right; it says *you* made a typo.
758/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
759/// declares this, `--list-rc` counts it, and it is not built yet. Telling
760/// the operator "command not found" blames them for a gap we shipped.
761///
762/// The discriminator is the dotted form. `:action` takes action SYMBOLS
763/// (`picker.files`), never command names — that boundary is already pinned by
764/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
765/// dotted name that reached dispatch and resolved to nothing is a declared
766/// capability with no implementation, which is precisely what the 85 entries
767/// in `escriba/tests/action_resolution.rs` are.
768fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
769 use escriba_command::CommandError as E;
770 match e {
771 // Already the right words — the registry knew it was declared.
772 E::Unhandled(_) => e.to_string(),
773 E::NotFound(n) if n.contains('.') => {
774 let mut m = String::with_capacity(n.len() + 48);
775 m.push('`');
776 m.push_str(n);
777 m.push_str("` is declared but not implemented yet");
778 m
779 }
780 _ => {
781 let _ = name;
782 e.to_string()
783 }
784 }
785}
786
787/// What committing the open search prompt did.
788///
789/// The two commit paths — bare `/` and operated `d/` — used to own private
790/// copies of the whole sequence (read origin+skip, `accept`, three-arm
791/// match, `commit_step_skipping`), and they drifted: the operated one
792/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
793/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
794///
795/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
796/// a compile error in two places rather than a case one path quietly
797/// forgets. It does not make divergence impossible — the two paths
798/// genuinely differ at the landing step — it makes FORGETTING A CASE
799/// impossible, which is the failure that actually happened.
800enum CommitOutcome {
801 /// The prompt committed and a match was found.
802 Landed {
803 origin: usize,
804 step: escriba_search::Step,
805 },
806 /// Committed, but nothing matched. E486 already reported.
807 NotFound,
808 /// Nothing typed and no previous pattern. E35 already reported.
809 NoPrevious,
810 /// No prompt was open.
811 NoPrompt,
812}
813
814/// A replayable text change.
815#[derive(Debug, Clone)]
816struct LastChange {
817 /// The action that began the change.
818 action: Action,
819 /// How many times it ran.
820 count: u32,
821 /// Characters typed while the change held Insert mode open.
822 inserted: String,
823}
824
825impl EditorState {
826 /// Build a fresh editor with one buffer (scratch or file-backed).
827 pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
828 let window = Window {
829 id: WindowId(1),
830 buffer_id: active,
831 viewport: Viewport {
832 top_line: 0,
833 left_column: 0,
834 visible_lines: 40,
835 visible_columns: 160,
836 },
837 };
838 Self {
839 buffers: initial,
840 modal: ModalState::new(),
841 search: SearchState::new(escriba_search::CaseMode::Smart),
842 search_at: None,
843 last_change: None,
844 recording_insert: false,
845 jumps: JumpList::new(),
846 keymap: Keymap::default_vim(),
847 commands: CommandRegistry::default_set(),
848 layout: Layout::single(window),
849 active,
850 cursors: Cursors::single(Position::ZERO),
851 quit_requested: false,
852 register: None,
853 op_pending: zenmai::Stateful::new(OpState::Resting),
854 pending_object: None,
855 messages: Vec::new(),
856 options: HashMap::new(),
857 lisp_vm: None,
858 pending_keys: Vec::new(),
859 repeat_gate: KeyRepeatGate::new(),
860 plugin_host: PluginHost::default(),
861 edit_gen: EditGen::default(),
862 damage: Damage::None,
863 // The FLEET default until an rc says otherwise — never a
864 // hand-written theme name, so a fleet re-point lands for free.
865 dispatch_depth: 0,
866 filetypes: escriba_core::FiletypeTable::new(),
867 results: escriba_shirube::ListRegistry::new(),
868 picker: None,
869 index_rev: escriba_shirube::IndexRev::default(),
870 session_gen: escriba_shirube::SessionGen::default(),
871 theme: FleetTheme::prescribed_default(),
872 chrome: ChromePalette::prescribed(),
873 splash: None,
874 }
875 }
876
877 /// The theme this editor is set to.
878 #[must_use]
879 pub const fn theme(&self) -> FleetTheme {
880 self.theme
881 }
882
883 /// The colours every face paints with — read once per frame.
884 #[must_use]
885 pub const fn chrome(&self) -> ChromePalette {
886 self.chrome
887 }
888
889 /// Point the editor at a theme. The wiring that makes
890 /// `(deftheme :preset …)` real.
891 ///
892 /// Bumps the refresh generation, because a theme change repaints
893 /// everything: the GPU face caches its shaped buffer against that
894 /// generation and would otherwise keep the old colours until an
895 /// unrelated edit happened to invalidate it.
896 pub fn set_theme(&mut self, theme: FleetTheme) {
897 if self.theme == theme {
898 return;
899 }
900 self.theme = theme;
901 self.chrome = ChromePalette::for_theme(theme);
902 self.damage = self.damage.join(Damage::Viewport);
903 self.bump_gen();
904 }
905
906 /// The start screen, if one is up. Renderers paint this INSTEAD of the
907 /// buffer pane; `None` is the ordinary editor.
908 #[must_use]
909 pub fn splash(&self) -> Option<&Splash> {
910 self.splash.as_ref()
911 }
912
913 /// Raise the start screen. The binary calls this at boot when no file
914 /// was named; an empty splash is refused so a face never has to render
915 /// a blank screen over a perfectly good buffer.
916 pub fn set_splash(&mut self, splash: Splash) {
917 if splash.is_empty() {
918 return;
919 }
920 self.splash = Some(splash);
921 self.damage = self.damage.join(Damage::Viewport);
922 self.bump_gen();
923 }
924
925 /// Take the start screen down. Idempotent; bumps the refresh generation
926 /// only when something actually changed, so dismissing twice does not
927 /// cost a repaint.
928 pub fn dismiss_splash(&mut self) {
929 if self.splash.take().is_some() {
930 self.damage = self.damage.join(Damage::Viewport);
931 self.bump_gen();
932 }
933 }
934
935 /// Offer `key` to the start screen.
936 ///
937 /// A menu key runs its entry; ANY other key simply takes the screen
938 /// down and is then handled normally — so the first thing an operator
939 /// types is never swallowed.
940 /// The open picker, for a face to paint.
941 #[must_use]
942 pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
943 self.picker.as_ref()
944 }
945
946 /// Give an open picker the key.
947 ///
948 /// Runs BEFORE the keymap, and before the sequence stepper: while a
949 /// picker is up it owns every key, including ones it has no meaning for.
950 /// An overlay that let unknown keys fall through would edit the file
951 /// behind itself.
952 fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
953 use escriba_ui::picker::Consumed;
954 let Some(p) = self.picker.as_mut() else {
955 return Consumed::NotShowing;
956 };
957 let outcome = p.on_key(key);
958 match &outcome {
959 Consumed::Dismissed | Consumed::Chose(_) => {
960 self.picker = None;
961 self.bump_gen();
962 }
963 Consumed::Held => self.bump_gen(),
964 Consumed::NotShowing => {}
965 }
966 outcome
967 }
968
969 /// Lower an accepted pick into the ONE interpreter.
970 ///
971 /// The whole reason `Choice` is a closed enum: a new source must decide
972 /// here, and the compiler says so.
973 fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
974 use escriba_ui::picker::Choice;
975 let slip = match choice {
976 Choice::Buffer(id) => Negai::FocusBuffer(id),
977 Choice::Command(name) => Negai::RunCommand {
978 name,
979 args: Vec::new(),
980 },
981 Choice::OpenFile(path) => Negai::OpenPath(path),
982 Choice::Location { path, line } => {
983 // Open FIRST, then jump: the buffer may not exist yet, and
984 // `jump_to_site` needs a BufferId. Two slips, one interpret.
985 self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
986 let site = escriba_shirube::Site::in_buffer(
987 self.active,
988 escriba_core::Range::new(
989 escriba_core::Position::new(line, 0),
990 escriba_core::Position::new(line, 1),
991 ),
992 );
993 self.jump_to_site(&site);
994 return;
995 }
996 };
997 self.interpret(Outcome::did(vec![slip]));
998 }
999
1000 /// How many files a project grep will read, and how many hits it keeps.
1001 ///
1002 /// BOUNDED, and the bound is here rather than hidden, because this is a
1003 /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
1004 /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
1005 /// is not new — but those touch one file and this walks a tree, which is
1006 /// the first one big enough to freeze the editor.
1007 ///
1008 /// The DESTINATION is the courier: an errand that scans off-thread and
1009 /// delivers results as they arrive, with no ceiling at all. This bound is
1010 /// the interim that ships a working grep meanwhile, and it is a real
1011 /// limit — a match past the ceiling is NOT found, and the picker says so
1012 /// rather than presenting a truncated list as complete.
1013 const GREP_FILE_LIMIT: usize = 2_000;
1014 const GREP_HIT_LIMIT: usize = 500;
1015
1016 /// Walk the working directory, bounded, returning `(files, truncated)`.
1017 ///
1018 /// ONE walker. grep, files and project each need to enumerate the tree,
1019 /// and three copies of a bounded traversal is three places to get the
1020 /// ceiling, the skip-list, or the truncation report subtly different.
1021 ///
1022 /// Skips dotfiles, `target` and `node_modules`. That is NOT a gitignore
1023 /// implementation and does not pretend to be — a real ignore crate comes
1024 /// with the courier.
1025 fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1026 Self::walk_from(std::path::Path::new("."), limit)
1027 }
1028
1029 /// The same bounded walk, from an explicit root.
1030 ///
1031 /// `walk_project` is this with `.` — one traversal, two callers, rather
1032 /// than a second copy for "browse from somewhere else".
1033 fn walk_from(root: &std::path::Path, limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1034 let mut out = Vec::new();
1035 let mut truncated = false;
1036 let mut stack = vec![root.to_path_buf()];
1037 while let Some(dir) = stack.pop() {
1038 let Ok(entries) = std::fs::read_dir(&dir) else {
1039 continue;
1040 };
1041 for entry in entries.flatten() {
1042 let name = entry.file_name();
1043 let name = name.to_string_lossy();
1044 if name.starts_with('.') || name == "target" || name == "node_modules" {
1045 continue;
1046 }
1047 let path = entry.path();
1048 if entry.file_type().is_ok_and(|t| t.is_dir()) {
1049 stack.push(path);
1050 continue;
1051 }
1052 if out.len() >= limit {
1053 truncated = true;
1054 return (out, truncated);
1055 }
1056 out.push(path);
1057 }
1058 }
1059 (out, truncated)
1060 }
1061
1062 /// Say plainly when a bounded scan stopped short.
1063 ///
1064 /// A truncated list presented as complete is the failure this codebase
1065 /// keeps finding in itself; it does not get to ship one.
1066 fn report_truncation(&mut self, truncated: bool) {
1067 if truncated {
1068 self.messages
1069 .push("scan stopped at the limit — results are INCOMPLETE".to_string());
1070 }
1071 }
1072
1073 /// Scan the working directory for `pattern`, bounded.
1074 fn grep_project(&mut self, pattern: &str) {
1075 use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1076 if pattern.is_empty() {
1077 self.messages.push("grep: empty pattern".to_string());
1078 return;
1079 }
1080 let (files, mut truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1081 let mut items: Vec<PickerItem<Choice>> = Vec::new();
1082 'outer: for path in files {
1083 let Ok(text) = std::fs::read_to_string(&path) else {
1084 continue; // binary or unreadable — not an error worth reporting
1085 };
1086 for (n, line) in text.lines().enumerate() {
1087 if !line.contains(pattern) {
1088 continue;
1089 }
1090 if items.len() >= Self::GREP_HIT_LIMIT {
1091 truncated = true;
1092 break 'outer;
1093 }
1094 let Ok(n) = u32::try_from(n) else { break };
1095 let mut label = String::with_capacity(80);
1096 label.push_str(&path.to_string_lossy());
1097 label.push(':');
1098 label.push_str(&(n + 1).to_string());
1099 label.push_str(" ");
1100 label.push_str(line.trim());
1101 items.push(PickerItem::new(
1102 Choice::Location {
1103 path: path.clone(),
1104 line: n,
1105 },
1106 label,
1107 ));
1108 }
1109 }
1110 if items.is_empty() {
1111 let mut m = String::from("grep: no matches for ");
1112 m.push_str(pattern);
1113 self.messages.push(m);
1114 return;
1115 }
1116 if truncated {
1117 // Stated, never silent. A truncated list presented as complete is
1118 // the failure this whole codebase keeps finding.
1119 self.messages
1120 .push("grep: stopped at the scan limit — results are INCOMPLETE".to_string());
1121 }
1122 self.picker = Some(Picker::open(Source::Grep, items));
1123 self.bump_gen();
1124 }
1125
1126 /// Where accepting a finding should take the operator.
1127 ///
1128 /// Returns `None` for a finding whose site names neither a path nor a
1129 /// live buffer — that is a finding about nowhere, and offering it would
1130 /// give the operator a row that does nothing when pressed.
1131 fn finding_choice(&self, f: &escriba_shirube::Finding) -> Option<escriba_ui::picker::Choice> {
1132 use escriba_ui::picker::Choice;
1133 let line = f.site.range.start.line;
1134 if let Some(p) = &f.site.path {
1135 return Some(Choice::Location {
1136 path: p.clone(),
1137 line,
1138 });
1139 }
1140 let id = f.site.buffer?;
1141 let b = self.buffers.get(id)?;
1142 // A buffer WITH a path becomes a Location, so the line survives. A
1143 // scratch buffer has no path to name, so the best available answer
1144 // is the buffer itself — and the line is lost. Stated rather than
1145 // hidden: a `Choice` that carried a BufferId AND a line would be
1146 // the fix, and it belongs with the picker, not here.
1147 b.path.as_ref().map_or(Some(Choice::Buffer(id)), |p| {
1148 Some(Choice::Location {
1149 path: p.clone(),
1150 line,
1151 })
1152 })
1153 }
1154
1155 /// How a finding's location reads in a list row.
1156 fn finding_label(&self, f: &escriba_shirube::Finding) -> String {
1157 if let Some(p) = &f.site.path {
1158 return p.to_string_lossy().into_owned();
1159 }
1160 f.site
1161 .buffer
1162 .and_then(|id| self.buffers.get(id))
1163 .and_then(|b| b.path.as_ref())
1164 .map_or_else(
1165 || String::from("[scratch]"),
1166 |p| p.to_string_lossy().into_owned(),
1167 )
1168 }
1169
1170 /// Picker rows for every file under `root`.
1171 ///
1172 /// `picker.files` and `files.open-parent` differ only in the root, so
1173 /// they share this rather than carrying two copies of the same body —
1174 /// which is what they did for one commit, and what the line-count lint
1175 /// correctly complained about.
1176 fn file_items(
1177 &mut self,
1178 root: &std::path::Path,
1179 ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1180 use escriba_ui::picker::{Choice, PickerItem};
1181 let (files, truncated) = Self::walk_from(root, Self::GREP_FILE_LIMIT);
1182 self.report_truncation(truncated);
1183 files
1184 .into_iter()
1185 .map(|p| {
1186 let label = p.to_string_lossy().into_owned();
1187 PickerItem::new(Choice::OpenFile(p), label)
1188 })
1189 .collect()
1190 }
1191
1192 /// Picker rows for the located findings the `trouble.*` verbs show.
1193 ///
1194 /// Freshness is asked of the registry, not assumed: `fresh` filters
1195 /// against the CURRENT world, so a list anchored to a revision the
1196 /// buffer has moved past contributes nothing rather than offering a
1197 /// line that has since shifted.
1198 fn finding_items(
1199 &self,
1200 workspace: bool,
1201 ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1202 use escriba_ui::picker::PickerItem;
1203 let world = self.world();
1204 let active = Some(self.active);
1205 let mut items = Vec::new();
1206 for name in self.results.names() {
1207 let Some(list) = self.results.get(name) else {
1208 continue;
1209 };
1210 for f in list.fresh(&world) {
1211 // `trouble.document` narrows to the buffer in front of the
1212 // operator. A finding that names only a path is
1213 // workspace-scoped by construction — it has no buffer to be
1214 // "this" one.
1215 if !workspace && f.site.buffer != active {
1216 continue;
1217 }
1218 let Some(choice) = self.finding_choice(f) else {
1219 continue;
1220 };
1221 let line = f.site.range.start.line;
1222 let mut label = String::with_capacity(64);
1223 label.push_str(f.severity.label());
1224 label.push_str(" ");
1225 label.push_str(&self.finding_label(f));
1226 label.push(':');
1227 label.push_str(&(line + 1).to_string());
1228 label.push_str(" ");
1229 label.push_str(&f.message);
1230 items.push(PickerItem::new(choice, label));
1231 }
1232 }
1233 items
1234 }
1235
1236 /// Build and open a picker over `source`.
1237 fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1238 use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1239 let (src, items) = match source {
1240 escriba_madoguchi::PickerSource::Buffers => (
1241 Source::Buffers,
1242 self.buffers
1243 .ids()
1244 .into_iter()
1245 .filter_map(|id| {
1246 let b = self.buffers.get(id)?;
1247 let label = b.path.as_ref().map_or_else(
1248 || String::from("[scratch]"),
1249 |p| p.to_string_lossy().into_owned(),
1250 );
1251 Some(PickerItem::new(Choice::Buffer(id), label))
1252 })
1253 .collect::<Vec<_>>(),
1254 ),
1255 escriba_madoguchi::PickerSource::Help => (
1256 Source::Help,
1257 self.keymap
1258 .entries_sorted()
1259 .into_iter()
1260 .map(|(mode, key, b)| {
1261 // "NORMAL gd goto definition" — searchable by key,
1262 // by mode, or by what it does, because a reader
1263 // arrives from any of the three.
1264 let mut label = String::with_capacity(48);
1265 label.push_str(mode.as_str());
1266 label.push_str(" ");
1267 // `{key:?}` because there is no shared key FORMATTER
1268 // in the fleet — awase owns the chord vocabulary but
1269 // escriba-keymap's `Key` has no Display. That gap
1270 // belongs to the keymap consolidation, not here, and
1271 // inventing a fourth spelling would make it worse.
1272 label.push_str(&format!("{key:?}"));
1273 label.push_str(" ");
1274 label.push_str(&b.description);
1275 // Accepting runs the binding's action if it names a
1276 // command; a typed Action has no name to run, so it
1277 // reports rather than pretending.
1278 let choice = match &b.action {
1279 escriba_core::Action::Command { name, .. } => {
1280 Choice::Command(name.clone())
1281 }
1282 other => Choice::Command(format!("{other:?}")),
1283 };
1284 PickerItem::new(choice, label)
1285 })
1286 .collect::<Vec<_>>(),
1287 ),
1288 escriba_madoguchi::PickerSource::Files => {
1289 (Source::Files, self.file_items(std::path::Path::new(".")))
1290 }
1291 escriba_madoguchi::PickerSource::Project => {
1292 // A project root is a directory carrying a marker. Derived
1293 // from the SAME walk rather than a second traversal — the
1294 // markers are files, so the walker already visited them.
1295 const MARKERS: &[&str] = &[
1296 "Cargo.toml",
1297 "flake.nix",
1298 "package.json",
1299 "go.mod",
1300 "pyproject.toml",
1301 ];
1302 let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1303 self.report_truncation(truncated);
1304 let mut roots: Vec<std::path::PathBuf> = files
1305 .into_iter()
1306 .filter(|p| {
1307 p.file_name()
1308 .is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
1309 })
1310 .filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
1311 .collect();
1312 roots.sort();
1313 roots.dedup();
1314 (
1315 Source::Project,
1316 roots
1317 .into_iter()
1318 .map(|p| {
1319 let label = p.to_string_lossy().into_owned();
1320 PickerItem::new(Choice::OpenFile(p), label)
1321 })
1322 .collect::<Vec<_>>(),
1323 )
1324 }
1325 escriba_madoguchi::PickerSource::Commands => (
1326 Source::Commands,
1327 self.commands
1328 .names()
1329 .into_iter()
1330 .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1331 .collect::<Vec<_>>(),
1332 ),
1333 escriba_madoguchi::PickerSource::FilesUnder(root) => {
1334 (Source::Files, self.file_items(&root))
1335 }
1336 escriba_madoguchi::PickerSource::Findings { workspace } => {
1337 (Source::Findings, self.finding_items(workspace))
1338 }
1339 };
1340 if items.is_empty() {
1341 self.messages.push("nothing to pick from".to_string());
1342 return;
1343 }
1344 self.picker = Some(Picker::open(src, items));
1345 self.bump_gen();
1346 }
1347
1348 /// Read one key as operator-pending object selection.
1349 ///
1350 /// Returns `None` when the key is nothing to do with objects, so the
1351 /// ordinary path runs untouched.
1352 fn consume_object_key(&mut self, key: Key) -> Option<ObjectKey> {
1353 use escriba_core::TextObject as O;
1354 let Key::Char(c) = key else {
1355 // Esc (or anything non-printable) abandons a half-typed object
1356 // rather than leaving the editor silently armed.
1357 if self.pending_object.take().is_some() {
1358 self.op_pending
1359 .dispatch((Action::ChangeMode(Mode::Normal), 1));
1360 return Some(ObjectKey::Consumed);
1361 }
1362 return None;
1363 };
1364
1365 // Second key: it names the object.
1366 if let Some(around) = self.pending_object.take() {
1367 let object = match c {
1368 'w' => Some(O::Word { around }),
1369 // vim's `b` and `B` aliases for the bracket pairs, plus the
1370 // brackets themselves in both directions.
1371 '(' | ')' | 'b' => Some(O::Delimited {
1372 open: '(',
1373 close: ')',
1374 around,
1375 }),
1376 '{' | '}' | 'B' => Some(O::Delimited {
1377 open: '{',
1378 close: '}',
1379 around,
1380 }),
1381 '[' | ']' => Some(O::Delimited {
1382 open: '[',
1383 close: ']',
1384 around,
1385 }),
1386 '<' | '>' => Some(O::Delimited {
1387 open: '<',
1388 close: '>',
1389 around,
1390 }),
1391 // Quotes: `open == close`, which is what tells the resolver
1392 // not to count nesting.
1393 '"' => Some(O::Delimited {
1394 open: '"',
1395 close: '"',
1396 around,
1397 }),
1398 '\'' => Some(O::Delimited {
1399 open: '\'',
1400 close: '\'',
1401 around,
1402 }),
1403 '`' => Some(O::Delimited {
1404 open: '`',
1405 close: '`',
1406 around,
1407 }),
1408 _ => None,
1409 };
1410 let OpState::Awaiting { op, count } = *self.op_pending.state() else {
1411 return Some(ObjectKey::Consumed);
1412 };
1413 // Disarm either way: an unknown object key cancels the operator,
1414 // it does not leave it armed for the next unrelated keystroke.
1415 self.op_pending
1416 .dispatch((Action::ChangeMode(Mode::Normal), 1));
1417 let Some(object) = object else {
1418 return Some(ObjectKey::Consumed);
1419 };
1420 let composed = Action::ApplyOperatorObject { op, object };
1421 // `2diw` applies the object twice. The caller runs it once, so
1422 // the extra repeats happen here.
1423 for _ in 1..count {
1424 self.apply(&composed);
1425 }
1426 return Some(ObjectKey::Compose(composed));
1427 }
1428
1429 // First key: `i` or `a` while an operator waits.
1430 if matches!(c, 'i' | 'a') && matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
1431 self.pending_object = Some(c == 'a');
1432 return Some(ObjectKey::Consumed);
1433 }
1434 None
1435 }
1436
1437 fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
1438 let Some(splash) = self.splash.as_ref() else {
1439 return SplashKey::NotShowing;
1440 };
1441 let chosen = match key {
1442 Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
1443 _ => None,
1444 };
1445 self.dismiss_splash();
1446 chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
1447 }
1448
1449 /// The current refresh generation. A renderer caches its products against
1450 /// this; equality is the freshness test (an unchanged generation ⇒ the
1451 /// last frame is still valid, so skip the re-highlight + re-shape).
1452 #[must_use]
1453 pub fn edit_gen(&self) -> EditGen {
1454 self.edit_gen
1455 }
1456
1457 /// Advance the refresh generation (a mutation happened).
1458 fn bump_gen(&mut self) {
1459 self.edit_gen = self.edit_gen.next();
1460 }
1461
1462 /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
1463 #[must_use]
1464 pub fn damage(&self) -> Damage {
1465 self.damage
1466 }
1467
1468 /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
1469 /// renderer calls this once per frame to learn what to repaint, then the
1470 /// accumulator restarts — so damage never double-counts across frames.
1471 pub fn take_damage(&mut self) -> Damage {
1472 std::mem::replace(&mut self.damage, Damage::None)
1473 }
1474
1475 /// The line count of the active buffer (0 if none) — used to compute the
1476 /// [`Damage`] scope of a mutation.
1477 fn active_line_count(&self) -> u32 {
1478 self.buffers
1479 .get(self.active)
1480 .map_or(0, escriba_buffer::Buffer::line_count)
1481 }
1482
1483 /// Register a lazy USER plugin: its escriba entry is deferred until
1484 /// one of its `triggers` fires. Bundled defaults do NOT go through
1485 /// here — they are applied eagerly at boot. Empty `triggers` means
1486 /// the plugin never lazily activates (the binary applies eager
1487 /// plugins directly).
1488 pub fn register_lazy_plugin(
1489 &mut self,
1490 name: impl Into<String>,
1491 triggers: Vec<LazyTrigger>,
1492 entry_src: impl Into<String>,
1493 ) {
1494 self.plugin_host.register(name, triggers, entry_src);
1495 }
1496
1497 /// Apply a plugin entry's escriba-lisp to live state — the same
1498 /// keymap / command / option apply paths a user rc uses. Options are
1499 /// applied before keybinds so a plugin that sets `mapleader` resolves
1500 /// `<leader>` correctly. Returns the count of commands + keybinds it
1501 /// registered (best-effort; a malformed entry is skipped, not fatal).
1502 fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
1503 let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
1504 return 0;
1505 };
1506 let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
1507 escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
1508 if let Some(value) = self.options.get("mapleader") {
1509 if let Some(key) = escriba_lisp::parse_leader_key(value) {
1510 self.keymap.set_leader(key);
1511 }
1512 }
1513 let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
1514 (cmd.registered + km.keybinds_applied) as usize
1515 }
1516
1517 /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
1518 /// Returns the number of plugins activated. Call when a buffer of a
1519 /// known filetype is opened.
1520 pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
1521 let pending = self.plugin_host.pending_for_filetype(filetype);
1522 let n = pending.len();
1523 for src in pending {
1524 self.apply_plugin_entry(&src);
1525 }
1526 n
1527 }
1528
1529 /// Fire any lazy plugin gated on an `Event` trigger for `event`.
1530 /// Returns the number of plugins activated.
1531 pub fn activate_event_plugins(&mut self, event: &str) -> usize {
1532 let pending = self.plugin_host.pending_for_event(event);
1533 let n = pending.len();
1534 for src in pending {
1535 self.apply_plugin_entry(&src);
1536 }
1537 n
1538 }
1539
1540 /// Advance one frame's worth of state given a raw madori event.
1541 ///
1542 /// Key events pass through the [`KeyRepeatGate`] first (see
1543 /// [`Self::tick_at`]); everything else is handled directly.
1544 pub fn tick(&mut self, event: &AppEvent) {
1545 self.tick_at(event, Instant::now());
1546 }
1547
1548 /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
1549 /// lets tests drive the debounce window without depending on the
1550 /// wall clock.
1551 pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
1552 match translate_app_event(event) {
1553 InputOutcome::Key(k) => {
1554 if self.gate_key(&k, now) {
1555 self.on_key(&k);
1556 }
1557 }
1558 InputOutcome::Resized { .. } => {
1559 // Damage only. Each face owns its own geometry: the GPU
1560 // backend derives the grid in `RenderCallback::resize`, and
1561 // the ratatui face reads its area every frame. This arm used
1562 // to write `Window.rect`, which nothing ever read — so the
1563 // resize path was already doing no real work, it just looked
1564 // like it was.
1565 self.damage = self.damage.join(Damage::Viewport);
1566 self.bump_gen();
1567 }
1568 InputOutcome::Quit => self.quit_requested = true,
1569 InputOutcome::Focus(_) | InputOutcome::None => {}
1570 }
1571 }
1572
1573 /// Decide whether `key` survives the key-repeat gate at time `now`.
1574 ///
1575 /// Returns `true` when the key should be processed, `false` when it is
1576 /// an OS key-repeat storm tick that should be dropped. Gating applies
1577 /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
1578 /// are where a held `j`/`l` floods the motion path and thrashes the
1579 /// viewport. Insert and Command modes pass every key through ungated,
1580 /// because there "hold a key to repeat the character" is the intended
1581 /// behavior, not a storm to suppress.
1582 fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
1583 match self.modal.mode() {
1584 Mode::Normal | Mode::Visual | Mode::VisualLine => {
1585 // The gate exists for HELD keys that flood the motion path and
1586 // thrash the viewport (`j`, `l`). It is wrong for the discrete
1587 // jumps: two `n` presses 10 ms apart mean two matches, and
1588 // swallowing the second is indistinguishable from a dead key —
1589 // the exact symptom the gate was added to prevent elsewhere.
1590 if is_repeat_storm_candidate(key) {
1591 return self.repeat_gate.try_pass_at(*key, now);
1592 }
1593 true
1594 }
1595 Mode::Insert | Mode::Command => true,
1596 }
1597 }
1598
1599 /// Dispatch a single key through the keymap + apply the resulting action.
1600 pub fn on_key(&mut self, key: &Key) {
1601 // An open picker owns EVERY key while it is up — before the splash,
1602 // before the sequence stepper, before the keymap.
1603 match self.consume_picker_key(key) {
1604 escriba_ui::picker::Consumed::NotShowing => {}
1605 escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
1606 escriba_ui::picker::Consumed::Chose(c) => {
1607 self.honour_choice(c);
1608 return;
1609 }
1610 }
1611 // The start screen owns the first keypress and nothing after it.
1612 match self.consume_splash_key(key) {
1613 SplashKey::NotShowing | SplashKey::Dismissed => {}
1614 SplashKey::Ran(action) => {
1615 self.apply(&action);
1616 return;
1617 }
1618 }
1619 // Operator-pending OBJECT selection runs before EVERYTHING, because
1620 // `di(` must not be read as `d` then `i` (insert) then `(`.
1621 if let Some(action) = self.consume_object_key(*key) {
1622 match action {
1623 ObjectKey::Consumed => return,
1624 ObjectKey::Compose(a) => {
1625 self.apply(&a);
1626 return;
1627 }
1628 }
1629 }
1630 // Multi-key sequence resolution runs first: a key that begins or
1631 // continues a bound sequence (`<leader>ff`, `gg`) is held or
1632 // resolved here before the single-key path sees it.
1633 match self.step_sequence(key) {
1634 SeqStep::Pending => return,
1635 SeqStep::Resolved(action) => {
1636 let count = self.modal.pending_count().unwrap_or(1);
1637 self.modal.clear_count();
1638 for _ in 0..count {
1639 self.apply(&action);
1640 if self.quit_requested {
1641 return;
1642 }
1643 }
1644 return;
1645 }
1646 SeqStep::Passthrough => {}
1647 }
1648 let counted = self.keymap.dispatch(&self.modal, key);
1649 // Count prefixes accumulate into modal state.
1650 if matches!(counted.action, Action::Pending) {
1651 if let Key::Char(c) = key {
1652 if c.is_ascii_digit() {
1653 let d = u32::from(*c as u8 - b'0');
1654 self.modal.append_count(d);
1655 }
1656 }
1657 return;
1658 }
1659 // The count flows through the operator-pending FSM (apply_counted), which
1660 // owns repetition: a bare motion runs count× , an operator captures its
1661 // count, and an operated motion multiplies the two. No naive outer loop.
1662 self.apply_counted(&counted.action, counted.count);
1663 // After applying, reset pending count.
1664 self.modal.clear_count();
1665 }
1666
1667 /// Advance the multi-key pending-stroke state machine for `key`.
1668 ///
1669 /// Sequences only apply in normal / visual modes — insert and
1670 /// command modes treat keys as literal text. Rules:
1671 /// - Mid-sequence: extend the pending prefix. Exact match →
1672 /// [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
1673 /// otherwise abort the sequence and re-process this key fresh.
1674 /// - Not mid-sequence: if `key` begins a bound sequence AND is not
1675 /// itself a complete single binding (single bindings win, so no
1676 /// chord timeout is needed) → start pending. Otherwise
1677 /// [`SeqStep::Passthrough`] to the single-key dispatcher.
1678 fn step_sequence(&mut self, key: &Key) -> SeqStep {
1679 let mode = self.modal.mode();
1680 if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
1681 return SeqStep::Passthrough;
1682 }
1683 if !self.pending_keys.is_empty() {
1684 let mut seq = self.pending_keys.clone();
1685 seq.push(key.clone());
1686 if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
1687 let action = b.action.clone();
1688 self.pending_keys.clear();
1689 return SeqStep::Resolved(action);
1690 }
1691 if self.keymap.is_sequence_prefix(mode, &seq) {
1692 self.pending_keys = seq;
1693 return SeqStep::Pending;
1694 }
1695 // The key broke the in-progress sequence — abort it and let
1696 // the key be re-processed as a fresh stroke below.
1697 self.pending_keys.clear();
1698 }
1699 let start = [key.clone()];
1700 if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
1701 self.pending_keys = start.to_vec();
1702 return SeqStep::Pending;
1703 }
1704 SeqStep::Passthrough
1705 }
1706
1707 /// The primary cursor position. The single read accessor — every
1708 /// renderer + motion path goes through it, so the underlying
1709 /// representation (today a single-cursor [`Cursors`]) can grow to
1710 /// multi-caret without changing read sites.
1711 #[must_use]
1712 pub fn cursor(&self) -> Position {
1713 self.cursors.primary()
1714 }
1715
1716 /// The **single** cursor-mutation path. Clamp the requested position to
1717 /// the active buffer's bounds, then scroll the active window's viewport
1718 /// to contain it on BOTH axes. Routing every cursor change through this
1719 /// (and through [`Cursors::set_primary`]) makes "cursor outside its
1720 /// viewport" an unrepresentable state, AND keeps cursor state in ONE
1721 /// typed home — there is no code path that advances the cursor without
1722 /// re-deriving the viewport from it, and no second `Position` field to
1723 /// fall out of sync.
1724 /// Re-assert the cursor-visibility invariant against the CURRENT
1725 /// viewport.
1726 ///
1727 /// A resize changes how much a face can show without moving the cursor,
1728 /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
1729 /// would sit off-screen until the operator happened to move it. Every
1730 /// face calls this after telling the runtime its new size.
1731 pub fn refollow_cursor(&mut self) {
1732 self.set_cursor(self.cursors.primary());
1733 }
1734
1735 fn set_cursor(&mut self, pos: Position) {
1736 let clamped = if let Some(buf) = self.buffers.get(self.active) {
1737 buf.clamp(pos)
1738 } else {
1739 pos
1740 };
1741 self.cursors.set_primary(clamped);
1742 if let Some(w) = self.layout.active_window_mut() {
1743 w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
1744 }
1745 }
1746
1747 /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
1748 fn apply(&mut self, action: &Action) {
1749 self.apply_counted(action, 1);
1750 }
1751
1752 /// Dispatch one resolved action with its count. Routes `(action, count)`
1753 /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
1754 /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
1755 /// their count (so `5j` runs the motion 5×), an operator key is held, and an
1756 /// operator-then-motion pair is rewritten into a counted
1757 /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
1758 /// composition — there is no naive outer repeat loop.
1759 fn apply_counted(&mut self, action: &Action, count: u32) {
1760 // An uncompilable pattern must not reach the operator machine.
1761 //
1762 // `SearchState::accept` puts the prompt BACK on a compile error so the
1763 // typed text is not lost — but the FSM had already transitioned out of
1764 // `AwaitingSearch` on the way in, so the prompt survived and the
1765 // OPERATOR did not, with nothing said about it. The `d` was simply
1766 // gone, and the corrected pattern then ran as a bare search.
1767 //
1768 // The machine is a pure `(State, Event) -> (State, effects)` and
1769 // cannot observe the result of an effect, so it cannot decide this
1770 // itself. The fix is to stop handing it an event it has no business
1771 // deciding: the runtime classifies the submit first, from state it
1772 // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
1773 // the bare-`/<CR>` reuse path is untouched.
1774 //
1775 // Tier-honest: parse-rejected at the boundary, not
1776 // truly-unrepresentable.
1777 if matches!(action, Action::SubmitCommand) {
1778 if let Some(e) = self.search.prompt_error() {
1779 let mut m = String::from("E383: Invalid search string: ");
1780 m.push_str(&e.to_string());
1781 self.messages.push(m);
1782 return;
1783 }
1784 }
1785
1786 for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
1787 // `3dw` is ONE delete of three words as far as the register is
1788 // concerned, not three deletes of one. Each repetition emits its
1789 // own `Negai::Yank`, and each used to overwrite the register — so
1790 // `3dwP` put back only the third word and silently lost two.
1791 //
1792 // Repetitions of a REGISTER-LEAVING operator therefore append,
1793 // and the flag is cleared after the group so an unrelated later
1794 // yank still replaces rather than growing forever.
1795 // A COUNTED operator-over-motion is ONE operation over a motion
1796 // resolved `times` over, not `times` operations over one motion.
1797 //
1798 // That distinction is not pedantry. Repeating the operation works
1799 // by accident for delete — the text vanishes, so the cursor ends
1800 // up somewhere new each round — and is simply wrong for yank,
1801 // which does not move the cursor: `2yw` re-yanked the FIRST word
1802 // twice and put "one one " in the register. Resolving the motion
1803 // twice and yanking once gives "one two ", and the register needs
1804 // no accumulation because there was only ever one yank.
1805 if let Action::ApplyOperator { op, motion } = resolved {
1806 self.apply_operator_n(op, motion, times);
1807 if self.quit_requested {
1808 return;
1809 }
1810 continue;
1811 }
1812 for _ in 0..times {
1813 self.apply_resolved(&resolved);
1814 if self.quit_requested {
1815 return;
1816 }
1817 }
1818 }
1819 }
1820
1821 /// The active buffer's text. Search is a pure function of it.
1822 /// The active buffer's text revision — the token an offset measured
1823 /// against it should carry.
1824 #[must_use]
1825 fn text_rev(&self) -> TextRev {
1826 self.buffers
1827 .get(self.active)
1828 .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
1829 }
1830
1831 fn active_text(&self) -> String {
1832 self.buffers
1833 .get(self.active)
1834 .map(escriba_buffer::Buffer::to_string)
1835 .unwrap_or_default()
1836 }
1837
1838 /// The cursor as a char offset — the coordinate search speaks.
1839 fn cursor_char(&self) -> usize {
1840 self.buffers
1841 .get(self.active)
1842 .and_then(|b| b.position_to_char(self.cursor()).ok())
1843 .unwrap_or(0)
1844 }
1845
1846 /// Move the cursor onto a match and report a wrap the way vim does.
1847 /// The status line as data — what every face draws.
1848 ///
1849 /// One model, so the two faces can only disagree about styling. Before
1850 /// this existed the GPU face built its own line from a fixed `format!()`
1851 /// and drew neither the prompt nor any message, which made a fully
1852 /// working `/` look like a dead key on escriba's default renderer.
1853 #[must_use]
1854 pub fn status_model(&self) -> StatusModel<'_> {
1855 let cursor = self.cursor();
1856 let prompt = self.search.prompt();
1857
1858 let kind = match prompt.map(|p| p.direction) {
1859 Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
1860 Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
1861 // Command mode with no search prompt open is an ex-command; the
1862 // typed `Option<Prompt>` is the discriminator, never a mode flag.
1863 None if self.modal.mode() == Mode::Command => PromptKind::Ex,
1864 None => PromptKind::None,
1865 };
1866
1867 StatusModel {
1868 mode: self.modal.mode(),
1869 line: cursor.line.saturating_add(1) as usize,
1870 column: cursor.column.saturating_add(1) as usize,
1871 prompt: kind,
1872 prompt_text: prompt
1873 .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
1874 prompt_caret: prompt.map_or_else(
1875 || self.modal.minibuffer_caret(),
1876 escriba_search::Prompt::caret,
1877 ),
1878 count: self.match_count(),
1879 message: self.messages.last().map(String::as_str),
1880 }
1881 }
1882
1883 /// `[3/17]` for the current pattern.
1884 ///
1885 /// While a prompt is open the count describes the PREVIEW — the answer to
1886 /// "what would Enter do", which is the question being asked mid-typing.
1887 /// Once committed it describes where the cursor actually is.
1888 #[must_use]
1889 fn match_count(&self) -> MatchCount {
1890 if self.search.is_prompting() {
1891 let text = self.active_text();
1892 // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
1893 // the same `None`, so a half-typed character class reported
1894 // `[0/0]` — telling the user their pattern matches nothing while
1895 // they are still writing it.
1896 return match self.search.preview(&text) {
1897 escriba_search::Preview::Landed { step, total } => {
1898 MatchCount::new(step.index, total)
1899 }
1900 escriba_search::Preview::NoMatch => MatchCount::None,
1901 escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
1902 MatchCount::Idle
1903 }
1904 };
1905 }
1906 if self.search.pattern().is_none() {
1907 return MatchCount::Idle;
1908 }
1909 let total = self.search.matches().len();
1910 // Read THROUGH the anchor: an ordinal computed against text that has
1911 // since changed reads as absent, so a stale count cannot be displayed.
1912 let rev = self.text_rev();
1913 self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
1914 if total == 0 {
1915 MatchCount::None
1916 } else {
1917 MatchCount::Idle
1918 },
1919 |&i| MatchCount::new(i, total),
1920 )
1921 }
1922
1923 /// `.` — replay the last change at the cursor.
1924 ///
1925 /// Two steps, because a change can be two: run the action, then re-type
1926 /// whatever followed it. `cgn` + `.` is exactly this — change the next
1927 /// match, then repeat that whole gesture on the one after.
1928 fn repeat_last_change(&mut self) {
1929 let Some(change) = self.last_change.clone() else {
1930 self.messages
1931 .push("E32: No previous change to repeat".to_string());
1932 return;
1933 };
1934
1935 for _ in 0..change.count.max(1) {
1936 self.apply_resolved(&change.action);
1937 }
1938 for c in change.inserted.chars() {
1939 self.apply_resolved(&Action::InsertChar(c));
1940 }
1941 if self.modal.mode() == Mode::Insert {
1942 // A replayed change must not leave the editor in Insert — the
1943 // original ended with an Esc the recording deliberately does not
1944 // store, since it is punctuation rather than part of the change.
1945 self.apply_resolved(&Action::ChangeMode(Mode::Normal));
1946 }
1947 // The replay wrote through `apply_resolved`, which re-records
1948 // `last_change` from the inner action. Put the ORIGINAL back so a
1949 // second `.` repeats the same change rather than a fragment of it.
1950 self.last_change = Some(change);
1951 self.recording_insert = false;
1952 }
1953
1954 /// Resolve a text object to the range it names.
1955 ///
1956 /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
1957 /// match operates on THAT match rather than skipping to the next — which
1958 /// is what makes `cgn` then `.` walk matches one at a time instead of
1959 /// every other one.
1960 /// `dd` — the current line INCLUDING its terminator.
1961 ///
1962 /// Taking the newline is what makes `dd` remove a line rather than blank
1963 /// it. On the last line there is no following newline to take, so it
1964 /// falls back to the preceding one — otherwise `dd` on the final line
1965 /// leaves an empty line behind, which is the one case a naive
1966 /// "start-of-line to start-of-next-line" range gets wrong.
1967 fn object_line(&self) -> Option<Range> {
1968 let buf = self.buffers.get(self.active)?;
1969 let line = self.cursor().line;
1970 let last = buf.line_count().saturating_sub(1);
1971 if line < last {
1972 Some(Range::new(
1973 Position::new(line, 0),
1974 Position::new(line + 1, 0),
1975 ))
1976 } else if line > 0 {
1977 // Final line: swallow the PRECEDING newline instead.
1978 Some(Range::new(
1979 Position::new(line - 1, buf.line_len_chars(line - 1)),
1980 Position::new(line, buf.line_len_chars(line)),
1981 ))
1982 } else {
1983 // The only line in the buffer: clear it, keep the line itself.
1984 Some(Range::new(
1985 Position::new(0, 0),
1986 Position::new(0, buf.line_len_chars(0)),
1987 ))
1988 }
1989 }
1990
1991 /// `iw` / `aw` — the word under the cursor.
1992 ///
1993 /// vim's `w` classes are word / punctuation / whitespace, and a text
1994 /// object never crosses a line. `around` additionally takes the trailing
1995 /// whitespace run, falling back to LEADING whitespace when there is none
1996 /// after — which is what vim does at end of line.
1997 fn object_word(&self, around: bool) -> Option<Range> {
1998 let buf = self.buffers.get(self.active)?;
1999 let pos = self.cursor();
2000 let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2001 if text.is_empty() {
2002 return None;
2003 }
2004 let col = (pos.column as usize).min(text.len().saturating_sub(1));
2005
2006 #[derive(PartialEq, Clone, Copy)]
2007 enum Class {
2008 Word,
2009 Punct,
2010 Space,
2011 }
2012 let class = |c: char| {
2013 if c.is_alphanumeric() || c == '_' {
2014 Class::Word
2015 } else if c.is_whitespace() {
2016 Class::Space
2017 } else {
2018 Class::Punct
2019 }
2020 };
2021
2022 let here = class(text[col]);
2023 let mut start = col;
2024 while start > 0 && class(text[start - 1]) == here {
2025 start -= 1;
2026 }
2027 let mut end = col + 1;
2028 while end < text.len() && class(text[end]) == here {
2029 end += 1;
2030 }
2031
2032 if around {
2033 let after = end;
2034 while end < text.len() && class(text[end]) == Class::Space {
2035 end += 1;
2036 }
2037 // No trailing run: take the leading one instead, as vim does.
2038 if end == after {
2039 while start > 0 && class(text[start - 1]) == Class::Space {
2040 start -= 1;
2041 }
2042 }
2043 }
2044
2045 Some(Range::new(
2046 Position::new(pos.line, start as u32),
2047 Position::new(pos.line, end as u32),
2048 ))
2049 }
2050
2051 /// `i(` / `a"` … — the region between a matched pair, on one line.
2052 ///
2053 /// Brackets NEST and quotes do not, and that is the only difference:
2054 /// with `open == close` the scan cannot count depth, so it takes the
2055 /// nearest delimiter on each side instead.
2056 fn object_delimited(&self, open: char, close: char, around: bool) -> Option<Range> {
2057 let buf = self.buffers.get(self.active)?;
2058 let pos = self.cursor();
2059 let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2060 if text.is_empty() {
2061 return None;
2062 }
2063 let col = (pos.column as usize).min(text.len().saturating_sub(1));
2064
2065 let (l, r) = if open == close {
2066 // Quotes: nearest on each side, no nesting to track.
2067 let l = (0..=col).rev().find(|&i| text[i] == open)?;
2068 let r = ((col.max(l) + 1)..text.len()).find(|&i| text[i] == close)?;
2069 (l, r)
2070 } else {
2071 // Brackets: walk out counting depth, so an inner pair does not
2072 // terminate the search for the enclosing one.
2073 let mut depth = 0i32;
2074 let l = (0..=col).rev().find(|&i| {
2075 if text[i] == close && i != col {
2076 depth += 1;
2077 false
2078 } else if text[i] == open {
2079 if depth == 0 {
2080 true
2081 } else {
2082 depth -= 1;
2083 false
2084 }
2085 } else {
2086 false
2087 }
2088 })?;
2089 depth = 0;
2090 let r = ((l + 1)..text.len()).find(|&i| {
2091 if text[i] == open {
2092 depth += 1;
2093 false
2094 } else if text[i] == close {
2095 if depth == 0 {
2096 true
2097 } else {
2098 depth -= 1;
2099 false
2100 }
2101 } else {
2102 false
2103 }
2104 })?;
2105 (l, r)
2106 };
2107
2108 // `i` is strictly between the delimiters; `a` includes them.
2109 let (s, e) = if around { (l, r + 1) } else { (l + 1, r) };
2110 Some(Range::new(
2111 Position::new(pos.line, s as u32),
2112 Position::new(pos.line, e as u32),
2113 ))
2114 }
2115
2116 fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
2117 use escriba_core::TextObject as O;
2118
2119 // The text-scanning objects resolve against the BUFFER; the two
2120 // search objects resolve against the match set. Splitting here keeps
2121 // the search logic below exactly as it was rather than threading a
2122 // second concern through it.
2123 match object {
2124 O::Line => return self.object_line(),
2125 O::Word { around } => return self.object_word(around),
2126 O::Delimited {
2127 open,
2128 close,
2129 around,
2130 } => return self.object_delimited(open, close, around),
2131 O::NextMatch | O::PrevMatch => {}
2132 }
2133
2134 let at = self.cursor_char();
2135 let matches = self.search.matches();
2136
2137 // A match CONTAINING the cursor wins outright, whichever direction the
2138 // object names.
2139 //
2140 // Comparing only against `m.start` — which is what a `starts`-vector
2141 // plus `Bound::Inclusive` does — is right only when the cursor sits on
2142 // a match's FIRST character. One column further in, `start < at` and
2143 // the match is rejected, so `cgn` skipped the very instance the
2144 // operator was standing in and the rename silently missed it. vim
2145 // operates on the containing match from every interior column, and the
2146 // `starts`-only comparison cannot express "contains" because it never
2147 // looks at `m.end`.
2148 let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
2149 let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
2150 match object {
2151 O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
2152 // Every other variant returned above; `NextMatch` is the only
2153 // one that can reach here besides `PrevMatch`.
2154 _ => Bound::Inclusive.first_matching(&starts, at, true),
2155 }
2156 })?;
2157
2158 let m = matches.get(idx)?;
2159 let buf = self.buffers.get(self.active)?;
2160 Some(Range {
2161 start: buf.char_to_position(m.start),
2162 end: buf.char_to_position(m.end),
2163 })
2164 }
2165
2166 fn land_on(&mut self, step: escriba_search::Step) {
2167 if let Some(buf) = self.buffers.get(self.active) {
2168 let pos = buf.char_to_position(step.target.start);
2169 self.set_cursor(pos);
2170 }
2171 // The `[3/17]` numerator. `Step` has carried this index since the
2172 // engine was written — `engine.rs` even names the counter as the
2173 // reason it exists — and every consumer discarded it until now.
2174 self.search_at = Some(Anchored::new(step.index, self.text_rev()));
2175 }
2176
2177 /// vim's "search hit BOTTOM, continuing at TOP".
2178 ///
2179 /// One reporter, called by the two places a search can wrap: the shared
2180 /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
2181 /// commit would say it twice.
2182 fn report_wrap(&mut self, step: &escriba_search::Step) {
2183 if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
2184 self.messages.push(msg.to_string());
2185 }
2186 }
2187
2188 /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
2189 /// than failing silently — a search that appears to do nothing is
2190 /// indistinguishable from a dropped keystroke.
2191 fn jump_search(&mut self, reverse: bool) {
2192 // Using the matches re-lights them: `n` after an auto-clear shows you
2193 // what you are walking through.
2194 self.search.relight();
2195 // `n` is a far jump — record where we leave from so `<C-o>` works.
2196 self.jumps.push(self.spot());
2197 let at = self.cursor_char();
2198 match self.search.repeat(at, reverse) {
2199 Some(step) => {
2200 // `n` wrapping the file says so, same as a commit does.
2201 self.report_wrap(&step);
2202 self.land_on(step);
2203 }
2204 None => {
2205 let msg = self.search.pattern().map_or_else(
2206 || "E35: No previous regular expression".to_string(),
2207 |p| {
2208 let mut m = String::from("E486: Pattern not found: ");
2209 m.push_str(p.raw());
2210 m
2211 },
2212 );
2213 self.messages.push(msg);
2214 }
2215 }
2216 }
2217
2218 /// Move the cursor to where the in-progress pattern would land, without
2219 /// committing anything. vim's `incsearch`.
2220 ///
2221 /// A pattern that does not compile yet (`/a[`, mid-typing) previews
2222 /// nothing and reports nothing — an error toast on every keystroke of a
2223 /// character class would be unusable.
2224 fn preview_search(&mut self) {
2225 let text = self.active_text();
2226 let Some(origin) = self.search.prompt().map(|p| p.origin) else {
2227 return;
2228 };
2229 let target = match self.search.preview(&text) {
2230 escriba_search::Preview::Landed { step, .. } => step.target.start,
2231 // Nothing to show: back to where the search started. Covers a
2232 // half-typed pattern and a pattern that finds nothing alike —
2233 // both mean "there is no match to preview".
2234 escriba_search::Preview::Idle
2235 | escriba_search::Preview::Incomplete
2236 | escriba_search::Preview::NoMatch => origin,
2237 };
2238 // A pattern that STOPS matching returns the cursor to the origin.
2239 //
2240 // Preview used to only ever move forward, so typing `ch` (a match) and
2241 // then `chz` (none) left the cursor parked on the `ch` match — a
2242 // preview showing a position the pattern no longer justifies, while
2243 // the count beside it read `[0/0]`. Restoring is also what makes
2244 // Escape's promise legible: at every keystroke the cursor is either on
2245 // a real match or back where you started, never on a stale one.
2246 if let Some(buf) = self.buffers.get(self.active) {
2247 let pos = buf.char_to_position(target);
2248 self.set_cursor(pos);
2249 }
2250 }
2251
2252 /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
2253 /// where the search lands, as ONE action.
2254 ///
2255 /// Split from [`Self::submit_search`] rather than sharing it because the
2256 /// two want opposite things from the commit: the bare `/` MOVES the cursor
2257 /// to the match, and an operated `/` must NOT — the cursor is the
2258 /// operator's start point, and moving it first would leave the operator
2259 /// with a zero-width range.
2260 /// Commit the open search prompt. The ONE copy of the sequence.
2261 ///
2262 /// Reports its own failures (E486 / E35) so neither caller has to carry a
2263 /// third copy of the message strings. `Accepted::Invalid` cannot reach
2264 /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
2265 /// boundary before the FSM or this method ever sees the submit.
2266 fn commit_search_prompt(&mut self) -> CommitOutcome {
2267 let text = self.active_text();
2268 let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
2269 else {
2270 return CommitOutcome::NoPrompt;
2271 };
2272
2273 match self.search.accept(&text) {
2274 escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
2275 self.modal.clear_minibuffer();
2276 self.modal.enter(Mode::Normal);
2277 match self.search.commit_step_skipping(origin, skip) {
2278 Some(step) => {
2279 // The wrap notice belongs HERE, once, for both commit
2280 // paths. Reporting it in each caller is what let the
2281 // operated path lose it in the first place — and my
2282 // first attempt at this refactor duplicated it again
2283 // rather than moving it, which the red proof caught.
2284 self.report_wrap(&step);
2285 CommitOutcome::Landed { origin, step }
2286 }
2287 None => {
2288 self.report_pattern_not_found();
2289 CommitOutcome::NotFound
2290 }
2291 }
2292 }
2293 escriba_search::Accepted::NothingToRepeat => {
2294 self.modal.clear_minibuffer();
2295 self.modal.enter(Mode::Normal);
2296 self.messages
2297 .push("E35: No previous regular expression".to_string());
2298 CommitOutcome::NoPrevious
2299 }
2300 // Unreachable: the boundary guard in `apply_counted` returns early
2301 // on an uncompilable pattern, leaving the prompt open. Reported
2302 // rather than `unreachable!()` — a panic in the editor's commit
2303 // path is a worse failure than a duplicate message.
2304 escriba_search::Accepted::Invalid(e) => {
2305 let mut m = String::from("E383: Invalid search string: ");
2306 m.push_str(&e.to_string());
2307 self.messages.push(m);
2308 CommitOutcome::NoPrompt
2309 }
2310 }
2311 }
2312
2313 /// vim's E486, with the pattern named. One place, so every path that fails
2314 /// to find reports identically.
2315 fn report_pattern_not_found(&mut self) {
2316 let mut m = String::from("E486: Pattern not found");
2317 if let Some(p) = self.search.pattern() {
2318 m.push_str(": ");
2319 m.push_str(p.raw());
2320 }
2321 self.messages.push(m);
2322 }
2323
2324 /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
2325 ///
2326 /// The only difference from the operated path is that this one lands;
2327 /// everything else lives in `commit_search_prompt`.
2328 fn submit_search(&mut self) {
2329 match self.commit_search_prompt() {
2330 CommitOutcome::Landed { origin, step } => {
2331 if let Some(buf) = self.buffers.get(self.active) {
2332 let from = buf.char_to_position(origin);
2333 self.jumps.push(escriba_core::Spot::new(self.active, from));
2334 }
2335 self.land_on(step);
2336 }
2337 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2338 }
2339 }
2340
2341 /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
2342 /// search lands, as ONE action.
2343 ///
2344 /// The cursor must NOT move to the match first: it is the operator's start
2345 /// point. That is the whole reason this differs from the bare path, and
2346 /// now the only reason.
2347 fn submit_search_operated(&mut self, op: Operator) {
2348 match self.commit_search_prompt() {
2349 CommitOutcome::Landed { origin, step } => {
2350 if let Some(buf) = self.buffers.get(self.active) {
2351 let from = buf.char_to_position(origin);
2352 let target = buf.char_to_position(step.target.start);
2353 // Operating over a search is itself a far jump.
2354 self.jumps.push(escriba_core::Spot::new(self.active, from));
2355 self.set_cursor(from);
2356 self.apply_operator_to(op, target);
2357 }
2358 }
2359 CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
2360 }
2361 }
2362
2363 fn apply_resolved(&mut self, action: &Action) {
2364 // Snapshot the scope inputs before the mutation so the resulting
2365 // Damage covers the changed region (the S3 seal — conservative widen).
2366 let lines_before = self.active_line_count();
2367 // Snapshot for the dot register: the only reliable witness that this
2368 // action changed text is that the buffer's revision moved.
2369 let rev_before = self.text_rev();
2370 let cline_before = self.cursor().line;
2371 match action {
2372 // Every action with an exact slip equivalent goes through the
2373 // interpreter, so "undo" has ONE implementation rather than one
2374 // per entry point. These had already drifted: the executor
2375 // re-followed the viewport after undo and the M1 interpreter did
2376 // not, so `u` and `:undo` behaved differently within a milestone
2377 // of each other.
2378 // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
2379 // guard: a guard arm does not count toward exhaustiveness, so the
2380 // guarded form silently gave up the total match — the compiler
2381 // said so, and it was right. `lowering_and_dispatch_agree` pins
2382 // that this list and `lower` stay the same set.
2383 Action::Quit
2384 | Action::ClearSearchHighlight
2385 | Action::Save
2386 | Action::Undo
2387 | Action::Redo
2388 | Action::Edit(_) => {
2389 for slip in Self::lower(action, self.active).unwrap_or_default() {
2390 self.honour_one(slip);
2391 }
2392 }
2393 Action::Move(m) => self.apply_motion(*m),
2394 Action::SearchOpen(dir) => {
2395 // vim's `/` is the command-line with a different prompt char,
2396 // so we reuse Command mode; `search.prompt` is what tells a
2397 // later <CR> this is a search and not an ex-command.
2398 let origin = self.cursor_char();
2399 self.search.open(*dir, origin);
2400 self.modal.enter(Mode::Command);
2401 }
2402 Action::SearchRepeat { reverse } => self.jump_search(*reverse),
2403 Action::SearchWord { reverse } => {
2404 let dir = if *reverse {
2405 SearchDirection::Backward
2406 } else {
2407 SearchDirection::Forward
2408 };
2409 let (text, at) = (self.active_text(), self.cursor_char());
2410 // `*` jumps, so it records too.
2411 self.jumps.push(self.spot());
2412 match self.search.search_word(&text, at, dir) {
2413 Some(step) => self.land_on(step),
2414 // vim beeps and stays put when there is no word under the
2415 // cursor; a silent no-op would look like a broken key.
2416 None => self
2417 .messages
2418 .push("E348: No string under cursor".to_string()),
2419 }
2420 }
2421 Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
2422 Action::TextObject(object) => {
2423 // Bare `gn` moves onto the match. vim additionally starts a
2424 // Visual selection of it; escriba's Visual plumbing does not
2425 // carry a selection an operator can consume yet, so this
2426 // stops at the jump rather than faking a selection that
2427 // nothing would honour.
2428 if let Some(range) = self.resolve_object(*object) {
2429 self.jumps.push(self.spot());
2430 self.set_cursor(range.start);
2431 } else {
2432 self.report_pattern_not_found();
2433 }
2434 }
2435 Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
2436 Some(range) => self.apply_operator_over(*op, range),
2437 None => self.report_pattern_not_found(),
2438 },
2439 Action::RepeatLastChange => self.repeat_last_change(),
2440 Action::JumpBack => {
2441 let here = self.spot();
2442 if let Some(spot) = self.jumps.back(here) {
2443 self.goto_spot(spot);
2444 } else {
2445 self.messages
2446 .push("E662: At start of changelist".to_string());
2447 }
2448 }
2449 Action::JumpForward => {
2450 if let Some(spot) = self.jumps.forward() {
2451 self.goto_spot(spot);
2452 } else {
2453 self.messages.push("E663: At end of changelist".to_string());
2454 }
2455 }
2456 Action::ChangeMode(m) => {
2457 // Leaving the cmdline abandons any open search prompt and
2458 // returns the cursor home. The COMMITTED pattern survives —
2459 // cancelling a new search must not erase the old highlights.
2460 if *m == Mode::Normal && self.search.is_prompting() {
2461 if let Some(origin) = self.search.cancel() {
2462 if let Some(buf) = self.buffers.get(self.active) {
2463 let pos = buf.char_to_position(origin);
2464 self.set_cursor(pos);
2465 }
2466 }
2467 }
2468 self.modal.enter(*m);
2469 }
2470 Action::InsertChar(c) => self.insert_char(*c),
2471
2472 Action::SubmitCommand => {
2473 if self.search.is_prompting() {
2474 self.submit_search();
2475 } else {
2476 self.submit_command();
2477 }
2478 }
2479 Action::Command { name, args } => self.run_command(name, args),
2480 Action::ApplyOperator { op, motion } => self.apply_operator(*op, *motion),
2481 // The operator-pending FSM consumes Operator keys (begins pending);
2482 // they never reach the executor. Defensive no-op for exhaustiveness.
2483 Action::Operator(_) => {}
2484 Action::PromptCaret { to } => {
2485 // Both prompts have a caret now, and the same keys move it.
2486 if self.search.is_prompting() {
2487 self.search.move_caret(*to);
2488 } else {
2489 self.modal.move_minibuffer_caret(*to);
2490 }
2491 }
2492 Action::SearchPreviewStep { forward } => {
2493 if self.search.is_prompting() {
2494 self.search.preview_step(*forward);
2495 self.preview_search();
2496 }
2497 }
2498 Action::PromptDelete => {
2499 if self.search.is_prompting() {
2500 self.search.delete_at_caret();
2501 self.preview_search();
2502 } else {
2503 self.modal.delete_minibuffer_at_caret();
2504 }
2505 }
2506 Action::PromptDeleteWord => {
2507 if self.search.is_prompting() {
2508 self.search.delete_word_before_caret();
2509 self.preview_search();
2510 }
2511 }
2512 Action::PromptClearToStart => {
2513 if self.search.is_prompting() {
2514 self.search.clear_before_caret();
2515 self.preview_search();
2516 }
2517 }
2518 Action::PromptBackspace => {
2519 self.prompt_backspace();
2520 // Shortening the pattern changes which matches exist, so the
2521 // preview must re-run — otherwise the cursor sits on a match
2522 // of a pattern that is no longer typed.
2523 if self.search.is_prompting() {
2524 self.preview_search();
2525 }
2526 }
2527 Action::PromptHistory { back } => {
2528 if self.search.is_prompting() {
2529 self.search.history_step(*back);
2530 // No minibuffer resync: the shadow is the ex-line's store
2531 // and nothing reads it while a search prompt is open, so
2532 // rewriting it here was maintaining a copy for no reader.
2533 self.preview_search();
2534 }
2535 }
2536 Action::Pending => {}
2537 }
2538 // Widen the dirty region by what this action touched (M1). Content
2539 // mutations that changed the line count run to end-of-document (every
2540 // line below shifted); an in-place edit or a cursor move is local;
2541 // arbitrary commands are conservatively Full. Never narrows.
2542 let lines_after = self.active_line_count();
2543 let cline_after = self.cursor().line;
2544 let d = match action {
2545 // A search repaints every highlight in the viewport, not just the
2546 // line the cursor left — so it must widen to Full. Treating it as a
2547 // cursor move would leave stale highlights on untouched lines.
2548 Action::SearchOpen(_)
2549 | Action::PromptHistory { .. }
2550 | Action::PromptBackspace
2551 | Action::PromptCaret { .. }
2552 | Action::SearchPreviewStep { .. }
2553 | Action::PromptDelete
2554 | Action::PromptDeleteWord
2555 | Action::PromptClearToStart
2556 | Action::SearchRepeat { .. }
2557 | Action::SearchWord { .. }
2558 | Action::ClearSearchHighlight
2559 | Action::SearchSubmitOperated { .. }
2560 // A replayed change can edit anywhere the original could, and a
2561 // match object can be anywhere in the document.
2562 | Action::RepeatLastChange
2563 | Action::TextObject(_)
2564 | Action::ApplyOperatorObject { .. }
2565 // A jump can land anywhere, so the viewport may scroll wholesale.
2566 | Action::JumpBack
2567 | Action::JumpForward => Damage::Full,
2568 Action::InsertChar(_)
2569 | Action::Edit(_)
2570 | Action::Undo
2571 | Action::Redo
2572 | Action::ApplyOperator { .. } => {
2573 if lines_after == lines_before {
2574 Damage::span(cline_before, cline_after)
2575 } else {
2576 Damage::Lines {
2577 from: cline_before.min(cline_after),
2578 to: u32::MAX,
2579 }
2580 }
2581 }
2582 Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
2583 Action::Save => Damage::Viewport,
2584 Action::Command { .. } | Action::SubmitCommand => Damage::Full,
2585 Action::Quit | Action::Operator(_) | Action::Pending => Damage::None,
2586 };
2587 self.damage = self.damage.join(d);
2588 // Remember this change for `.`.
2589 //
2590 // Recorded from an OBSERVED MUTATION, not from the action's variant.
2591 // `text_effect()` is the wrong predicate here even though it looks
2592 // like the right one: it exists to decide cache invalidation, where
2593 // OVER-reporting is the safe direction, and the dot register needs the
2594 // opposite bias. Leaning on it meant `last_change` was set by actions
2595 // that changed no text at all, with two measured consequences:
2596 //
2597 // `iZ<Esc>` then `/a<CR>` then `.` — did nothing; the register held
2598 // `SubmitCommand`, whose replay reads an already-cleared
2599 // minibuffer.
2600 // `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
2601 // abandoned prompt left the register holding `InsertChar('q')`,
2602 // and `.` in Normal mode routes that to the text. A corrupting
2603 // register, not merely a lost one.
2604 //
2605 // Comparing the buffer's `TextRev` across the action answers the only
2606 // question that matters — did this actually change the text — and gets
2607 // the failed-operator case (`dgn` with no pattern) right for free.
2608 if self.recording_insert {
2609 match action {
2610 Action::InsertChar(c) => {
2611 if let Some(lc) = self.last_change.as_mut() {
2612 lc.inserted.push(*c);
2613 }
2614 }
2615 // Leaving Insert ends the session; the change is now whole.
2616 Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
2617 _ => {}
2618 }
2619 } else if self.text_rev() != rev_before
2620 && !matches!(
2621 action,
2622 Action::RepeatLastChange | Action::Undo | Action::Redo
2623 )
2624 {
2625 self.last_change = Some(LastChange {
2626 action: action.clone(),
2627 count: 1,
2628 inserted: String::new(),
2629 });
2630 self.recording_insert = self.modal.mode() == Mode::Insert;
2631 }
2632
2633 // The search is over the moment you move on or edit — clear the
2634 // highlight rather than leaving the buffer as confetti until an
2635 // explicit `:noh`, which is the remap nearly every vimrc carries.
2636 // Clearing suppresses without forgetting, so `n` still works.
2637 if action.highlight_effect() == HighlightEffect::Clear {
2638 self.search.clear_highlight();
2639 }
2640 // Text changed ⇒ every match offset cached against the old text is
2641 // wrong. `SearchState::refresh` existed for exactly this and had ZERO
2642 // callers, so inserting four characters left both renderers painting
2643 // the highlight four columns off.
2644 //
2645 // Gated on the typed classifier rather than on `bump_gen` (which fires
2646 // for pure cursor moves too): re-scanning the document on every `j`
2647 // would be a per-keystroke full pass for no reason.
2648 if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
2649 let text = self.active_text();
2650 self.search.refresh(&text);
2651 // NO manual invalidation of `search_at` here, deliberately. It is
2652 // `Anchored` to the text revision, so an ordinal computed against
2653 // the old text now reads as `None` on its own. This is the line
2654 // that used to have to be remembered.
2655 }
2656 // An action reached the executor ⇒ visible state may have changed.
2657 // Advance the refresh generation so the renderer repaints (and
2658 // re-highlights) exactly once. A gated-out key never reaches here, so
2659 // a key-repeat storm does not spin the renderer.
2660 self.bump_gen();
2661 }
2662
2663 /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
2664 /// active buffer — **pure**: no cursor mutation, no side effects. This is
2665 /// the single motion-resolution source of truth that both [`apply_motion`]
2666 /// (move the cursor *to* the target) and [`apply_operator`] (use the target
2667 /// as the *other end* of an operated range) stand on. `None` only if there
2668 /// is no active buffer.
2669 ///
2670 /// [`apply_motion`]: Self::apply_motion
2671 /// [`apply_operator`]: Self::apply_operator
2672 fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
2673 let buf = self.buffers.get(self.active)?;
2674 let pos = from;
2675 Some(match motion {
2676 // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
2677 // against the committed match list, so it is `None` (motion fails,
2678 // operator aborts, buffer untouched) when nothing is committed —
2679 // never a silent move to 0, which would delete to the file start.
2680 Motion::SearchNext | Motion::SearchPrev => {
2681 let at = buf.position_to_char(pos).ok()?;
2682 let step = self
2683 .search
2684 .repeat(at, matches!(motion, Motion::SearchPrev))?;
2685 buf.char_to_position(step.target.start)
2686 }
2687 Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
2688 Motion::Right => Position::new(pos.line, pos.column.saturating_add(1)),
2689 Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
2690 Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
2691 Motion::LineStart => Position::new(pos.line, 0),
2692 Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
2693 Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
2694 Motion::DocStart => Position::ZERO,
2695 Motion::DocEnd => Position::new(
2696 buf.line_count().saturating_sub(1),
2697 buf.line_len_chars(buf.line_count().saturating_sub(1)),
2698 ),
2699 Motion::WordStartNext | Motion::WordEndNext => word_next(buf, pos),
2700 Motion::WordStartPrev => word_prev(buf, pos),
2701 Motion::PageDown | Motion::HalfPageDown => {
2702 Position::new(pos.line.saturating_add(10), pos.column)
2703 }
2704 Motion::PageUp | Motion::HalfPageUp => {
2705 Position::new(pos.line.saturating_sub(10), pos.column)
2706 }
2707 Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
2708 // Structural Lisp motions — stubs for phase 1.B; full paredit
2709 // semantics land when caixa-ast is wired to the active buffer.
2710 Motion::ForwardSexp
2711 | Motion::BackwardSexp
2712 | Motion::UpList
2713 | Motion::DownList
2714 | Motion::BeginningOfDefun
2715 | Motion::EndOfDefun
2716 | Motion::BeginningOfSexp
2717 | Motion::EndOfSexp => pos,
2718 })
2719 }
2720
2721 fn apply_motion(&mut self, motion: Motion) {
2722 // A bare search motion is a FAR JUMP and it REPORTS — it records into
2723 // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
2724 // when nothing matches. `resolve_motion` can do none of that: it is
2725 // deliberately pure because the OPERATOR path calls it to find a range
2726 // without moving the cursor. So `n` routes to the one executor that
2727 // owns those side effects, and `Action::SearchRepeat` routes to the
2728 // same place — one code path, two spellings.
2729 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2730 self.jump_search(matches!(motion, Motion::SearchPrev));
2731 return;
2732 }
2733 let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
2734 return;
2735 };
2736 // The single cursor-mutation path clamps to the buffer and scrolls
2737 // the viewport to contain the cursor on both axes.
2738 self.set_cursor(pos);
2739 }
2740
2741 /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
2742 /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
2743 /// Composition is explicit: the motion resolves a target via
2744 /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
2745 /// `[cursor, target)` range. Register-leaving operators
2746 /// ([`Operator::leaves_register`]) capture the text first.
2747 /// Apply `op` over `motion` resolved `n` times from the cursor.
2748 ///
2749 /// `n == 1` is the ordinary path. Larger `n` walks the motion forward
2750 /// first and operates over the whole span in one go, which is what vim
2751 /// means by `3dw` — and the only way a non-moving operator like yank can
2752 /// honour a count at all.
2753 fn apply_operator_n(&mut self, op: Operator, motion: Motion, n: u32) {
2754 if n <= 1 {
2755 self.apply_operator(op, motion);
2756 return;
2757 }
2758 let from = self.cursor();
2759 let mut to = from;
2760 for _ in 0..n {
2761 match self.resolve_motion(to, motion) {
2762 Some(next) if next != to => to = next,
2763 // The motion stopped making progress (start/end of buffer):
2764 // operate over what we reached rather than aborting, which is
2765 // what vim does for `999dw` near the end of a file.
2766 _ => break,
2767 }
2768 }
2769 if to == from {
2770 // Nothing to operate over. Fall through to the single-step path
2771 // so its error reporting (E35, pattern-not-found) still runs.
2772 self.apply_operator(op, motion);
2773 return;
2774 }
2775 self.apply_operator_to(op, to);
2776 }
2777
2778 fn apply_operator(&mut self, op: Operator, motion: Motion) {
2779 let from = self.cursor();
2780 let Some(to) = self.resolve_motion(from, motion) else {
2781 // A motion that cannot resolve aborts the operator with the buffer
2782 // untouched. A search motion says WHY — `dn` with no pattern armed
2783 // is otherwise indistinguishable from a dropped keystroke, which
2784 // is the same complaint that motivated E486 on the bare path.
2785 if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
2786 if self.search.pattern().is_none() {
2787 self.messages
2788 .push("E35: No previous regular expression".to_string());
2789 } else {
2790 self.report_pattern_not_found();
2791 }
2792 }
2793 return;
2794 };
2795 self.apply_operator_to(op, to);
2796 }
2797
2798 /// Apply `op` over `[cursor, to)`.
2799 ///
2800 /// Split out of [`Self::apply_operator`] so the operated-search path can
2801 /// reach the same range machinery with a target it resolved itself — the
2802 /// alternative was a second copy of the delete/yank/register logic, which
2803 /// is how the two would drift.
2804 fn apply_operator_to(&mut self, op: Operator, to: Position) {
2805 let from = self.cursor();
2806 self.apply_operator_over(
2807 op,
2808 Range {
2809 start: from,
2810 end: to,
2811 },
2812 );
2813 }
2814
2815 /// Apply `op` over an explicit range.
2816 ///
2817 /// The object path needs this: `gn`'s extent need not begin at the cursor,
2818 /// so it cannot go through the `[cursor, target)` shape the motion path
2819 /// uses. One implementation of the delete/yank/register logic, reached two
2820 /// ways.
2821 fn apply_operator_over(&mut self, op: Operator, range: Range) {
2822 let range = range.normalized();
2823 if range.is_empty() {
2824 return;
2825 }
2826 // Capture the operated text (for the register) before mutating.
2827 let text = self
2828 .buffers
2829 .get(self.active)
2830 .and_then(|buf| buf.slice(range).ok());
2831 if op.leaves_register() {
2832 if let Some(t) = &text {
2833 self.register = Some(t.clone());
2834 }
2835 }
2836 match op {
2837 // Delete + Change remove the range; Change then enters Insert so
2838 // the operator pairs with immediate typing (`ciw`, `c$`).
2839 Operator::Delete | Operator::Change => {
2840 if let Some(buf) = self.buffers.get_mut(self.active) {
2841 let _ = buf.apply(&Edit::delete(range));
2842 }
2843 self.set_cursor(range.start);
2844 if op == Operator::Change {
2845 self.modal.enter(Mode::Insert);
2846 }
2847 }
2848 // Yank copies to the register without mutating the buffer; vim
2849 // leaves the cursor at the range start.
2850 Operator::Yank => {
2851 self.set_cursor(range.start);
2852 }
2853 // Indent/Format/structural operators are not yet wired — named,
2854 // not faked (no buffer mutation, register already captured for the
2855 // register-leaving ones above).
2856 _ => {
2857 self.messages
2858 .push("operator not yet implemented".to_owned());
2859 }
2860 }
2861 }
2862
2863 /// The text last yanked or deleted into the unnamed register, if any.
2864 /// The future `p`/`P` paste reads this.
2865 #[must_use]
2866 pub fn register(&self) -> Option<&str> {
2867 self.register.as_deref()
2868 }
2869
2870 fn insert_char(&mut self, c: char) {
2871 if self.modal.mode() == Mode::Command {
2872 // A search prompt and an ex-command share Command mode (vim's
2873 // cmdline). `search.is_prompting()` is the typed discriminator —
2874 // it can only be true when `/` or `?` actually opened a prompt.
2875 if self.search.is_prompting() {
2876 // The search prompt is the SOLE store while it is open.
2877 //
2878 // This used to also `push_minibuffer(c)`, and the two stores
2879 // insert differently — `search.push` at the caret, the
2880 // minibuffer always at the end — so `/fo<Left>X` left them
2881 // reading `fXo` and `foX`. That was one of FIVE desync paths;
2882 // the caret moves, forward-delete, delete-word and
2883 // clear-to-start never touched the shadow at all.
2884 //
2885 // Deleting the write costs nothing because `status_model`
2886 // already selects the minibuffer only on the `prompt == None`
2887 // branch — the shadow is the EX-LINE's store, and while a
2888 // search prompt is open nothing reads it.
2889 self.search.push(c);
2890 self.preview_search();
2891 } else {
2892 self.modal.push_minibuffer(c);
2893 }
2894 return;
2895 }
2896 let cursor = self.cursor();
2897 let Some(buf) = self.buffers.get_mut(self.active) else {
2898 return;
2899 };
2900 let edit = Edit::insert(cursor, c.to_string());
2901 if buf.apply(&edit).is_ok() {
2902 let next = if c == '\n' {
2903 Position::new(cursor.line.saturating_add(1), 0)
2904 } else {
2905 cursor.shift_right(1)
2906 };
2907 // Route through the single cursor-mutation path so the viewport
2908 // follows the cursor (both axes) and the cursor stays clamped.
2909 self.set_cursor(next);
2910 }
2911 }
2912
2913 /// Backspace inside a prompt. Keeps the search buffer and the displayed
2914 /// minibuffer in lockstep — if only one shrank, the pattern submitted
2915 /// would differ from the text on screen.
2916 fn prompt_backspace(&mut self) -> bool {
2917 if self.modal.mode() != Mode::Command {
2918 return false;
2919 }
2920 if self.search.is_prompting() {
2921 // Backspacing past the `/` closes the prompt, as vim does. No
2922 // `pop_minibuffer` here for the same reason as `insert_char`: the
2923 // shadow is the ex-line's, and popping its TAIL when the caret is
2924 // mid-pattern was another desync path.
2925 if self.search.backspace() {
2926 self.modal.clear_minibuffer();
2927 self.modal.enter(Mode::Normal);
2928 }
2929 // Never `pop_minibuffer` on the search path: it pops the TAIL,
2930 // while `search.backspace()` removes the char before the CARET.
2931 return true;
2932 }
2933 self.modal.pop_minibuffer();
2934 true
2935 }
2936
2937 fn submit_command(&mut self) {
2938 // Read the command line BEFORE leaving Command mode — the minibuffer
2939 // exists only in the `Command` variant, so the escape must come
2940 // after the capture.
2941 let line = self.modal.minibuffer().to_string();
2942 self.modal.escape();
2943 let (name, args) = parse_command_line(&line);
2944 if name.is_empty() {
2945 return;
2946 }
2947 self.run_command(&name, &args);
2948 }
2949
2950 fn run_command(&mut self, name: &str, args: &[String]) {
2951 // Bound the command -> RunCommand slip -> command cycle. Refused and
2952 // reported, never a stack overflow: an editor that dies under the
2953 // operator loses their buffer, and a script that loops is a mistake
2954 // they should be told about, not punished for.
2955 if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
2956 let mut m = String::from("command recursion too deep at `");
2957 m.push_str(name);
2958 m.push_str("` — refusing");
2959 self.messages.push(m);
2960 self.damage = self.damage.join(Damage::Viewport);
2961 self.bump_gen();
2962 return;
2963 }
2964 self.dispatch_depth += 1;
2965 self.run_command_inner(name, args);
2966 self.dispatch_depth -= 1;
2967 }
2968
2969 /// How many nested command dispatches are allowed. Deep enough that no
2970 /// legitimate script notices, shallow enough to fail fast.
2971 const MAX_DISPATCH_DEPTH: u8 = 8;
2972
2973 fn run_command_inner(&mut self, name: &str, args: &[String]) {
2974 // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
2975 // gated on `Command: <name>` has its entry applied the first time
2976 // that command runs, BEFORE dispatch — so the activated plugin
2977 // can register the very command being invoked and it resolves on
2978 // this same call.
2979 if self.plugin_host.pending() > 0 {
2980 let pending = self.plugin_host.pending_for_command(name);
2981 for src in pending {
2982 self.apply_plugin_entry(&src);
2983 }
2984 }
2985 // Read through the counter, then interpret. Two immutable borrows of
2986 // `self` (the window and the registry) coexist; the `&mut` comes
2987 // afterwards, once the outcome is owned. That sequencing IS the
2988 // seam: there is no moment where a command body and `&mut self` are
2989 // live at the same time.
2990 let outcome = {
2991 let window = self.window();
2992 self.commands.run(name, &window, args)
2993 };
2994 match outcome {
2995 Ok(o) => self.interpret(o),
2996 // Reported, never fatal (Phase 0). A failed command must not
2997 // take the editor down, but it must not be invisible either.
2998 Err(e) => {
2999 self.messages.push(describe_command_failure(name, &e));
3000 self.damage = self.damage.join(Damage::Viewport);
3001 self.bump_gen();
3002 }
3003 }
3004 }
3005
3006 // ── tatara-lisp runtime bridge (imperative programmability tier) ──
3007
3008 /// Capture a read snapshot of the editor for the tatara-lisp host.
3009 /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
3010 #[must_use]
3011 pub fn snapshot(&self) -> EditorSnapshot {
3012 let current_line = self
3013 .buffers
3014 .get(self.active)
3015 .and_then(|b| b.line(self.cursor().line))
3016 .map(|s| s.trim_end_matches('\n').to_string())
3017 .unwrap_or_default();
3018 let buffer_name = self
3019 .buffers
3020 .get(self.active)
3021 .and_then(|b| b.path.as_ref())
3022 .map(|p| p.display().to_string())
3023 .unwrap_or_else(|| "[scratch]".to_string());
3024 EditorSnapshot {
3025 cursor_line: i64::from(self.cursor().line),
3026 cursor_column: i64::from(self.cursor().column),
3027 current_line,
3028 mode: self.modal.mode().as_str().to_string(),
3029 buffer_name,
3030 }
3031 }
3032
3033 /// Evaluate tatara-lisp `src` against this editor: capture a
3034 /// snapshot, run it in the embedded VM, then apply the typed effects
3035 /// the program emitted. This is the imperative programmability tier
3036 /// — live Lisp that reads state and drives the editor through the
3037 /// sandboxed effect boundary.
3038 ///
3039 /// **Snapshot semantics:** the read snapshot is captured ONCE before
3040 /// eval, and effects are applied AFTER the program returns. So within
3041 /// a single `run_lisp` call a program cannot observe its own writes —
3042 /// `(insert "x") (cursor-column)` reads the pre-insert column. This
3043 /// snapshot-isolation is deliberate (it's what makes the effect
3044 /// boundary a clean sandbox seam); a program that must read its own
3045 /// effects splits the work across calls. The VM is cached
3046 /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
3047 /// `define`s persist across calls (REPL-like).
3048 pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
3049 let mut host = EscribaHost::with_snapshot(self.snapshot());
3050 let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
3051 vm.eval(src, &mut host)?;
3052 let effects = host.take_effects();
3053 self.apply_host_effects(effects);
3054 Ok(())
3055 }
3056
3057 /// Apply tatara-lisp effects to live editor state.
3058 ///
3059 /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
3060 /// implementation of message-push / option-insert / insert-text beside
3061 /// the Action executor and the slip interpreter — the same duplication
3062 /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
3063 /// hands them to the one interpreter.
3064 pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
3065 self.interpret(Outcome::did(effects));
3066 }
3067
3068 /// Insert a (possibly multi-line) string at the cursor and advance
3069 /// the cursor past it. Used by the `(insert …)` effect.
3070 fn insert_text(&mut self, text: &str) {
3071 if text.is_empty() {
3072 return;
3073 }
3074 let cursor = self.cursor();
3075 let Some(buf) = self.buffers.get_mut(self.active) else {
3076 return;
3077 };
3078 let edit = Edit::insert(cursor, text.to_string());
3079 if buf.apply(&edit).is_ok() {
3080 let next = if let Some(nl) = text.rfind('\n') {
3081 let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
3082 let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
3083 Position::new(cursor.line + added_lines, last_line_len)
3084 } else {
3085 let n = u32::try_from(text.chars().count()).unwrap_or(0);
3086 cursor.shift_right(n)
3087 };
3088 // Route through the single cursor-mutation path so the viewport
3089 // follows the cursor (both axes) and the cursor stays clamped.
3090 self.set_cursor(next);
3091 }
3092 }
3093}
3094
3095fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
3096 let Some(text) = buf.line(line) else {
3097 return Position::new(line, 0);
3098 };
3099 let col = text
3100 .chars()
3101 .take_while(|c| c.is_whitespace() && *c != '\n')
3102 .count();
3103 Position::new(line, u32::try_from(col).unwrap_or(0))
3104}
3105
3106fn word_next(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
3107 let Some(text) = buf.line(pos.line) else {
3108 return pos;
3109 };
3110 let chars: Vec<char> = text.chars().collect();
3111 let start = pos.column as usize;
3112 let mut i = start;
3113 while i < chars.len() && !chars[i].is_whitespace() {
3114 i += 1;
3115 }
3116 while i < chars.len() && chars[i].is_whitespace() {
3117 i += 1;
3118 }
3119 if i >= chars.len() {
3120 // No more words on this line — jump to next line.
3121 if pos.line + 1 < buf.line_count() {
3122 return Position::new(pos.line + 1, 0);
3123 }
3124 }
3125 Position::new(pos.line, u32::try_from(i).unwrap_or(pos.column))
3126}
3127
3128fn word_prev(buf: &escriba_buffer::Buffer, pos: Position) -> Position {
3129 let Some(text) = buf.line(pos.line) else {
3130 return pos;
3131 };
3132 let chars: Vec<char> = text.chars().collect();
3133 let mut i = (pos.column as usize).min(chars.len());
3134 while i > 0 && chars[i - 1].is_whitespace() {
3135 i -= 1;
3136 }
3137 while i > 0 && !chars[i - 1].is_whitespace() {
3138 i -= 1;
3139 }
3140 Position::new(pos.line, u32::try_from(i).unwrap_or(0))
3141}
3142
3143fn parse_command_line(line: &str) -> (String, Vec<String>) {
3144 let mut parts = line.split_whitespace();
3145 let Some(first) = parts.next() else {
3146 return (String::new(), Vec::new());
3147 };
3148 let head = first.strip_prefix(':').unwrap_or(first);
3149 let name = match head {
3150 "w" => "save",
3151 "q" => "quit",
3152 "u" => "undo",
3153 other => other,
3154 };
3155 (name.to_string(), parts.map(str::to_string).collect())
3156}
3157
3158#[cfg(test)]
3159mod tests {
3160 use super::*;
3161 use madori::event::{KeyCode, KeyEvent, Modifiers};
3162
3163 // ── search wiring (escriba-search integration) ────────────────────
3164 //
3165 // The engine is proven in escriba-search's own 61 tests. These prove the
3166 // WIRING: that keys reach it, that the cursor lands where it says, and
3167 // that a search prompt and an ex-command can share Command mode without
3168 // being confused for one another.
3169
3170 fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
3171 st.apply(&Action::SearchOpen(dir));
3172 for c in pat.chars() {
3173 st.apply(&Action::InsertChar(c));
3174 }
3175 st.apply(&Action::SubmitCommand);
3176 }
3177
3178 #[test]
3179 fn slash_search_moves_the_cursor_to_the_match() {
3180 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3181 type_search(&mut st, SearchDirection::Forward, "charlie");
3182 assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
3183 assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
3184 assert_eq!(st.search.matches().len(), 1);
3185 }
3186
3187 #[test]
3188 // `N` is a DIFFERENT vim key from `n` — see escriba-search.
3189 #[allow(non_snake_case)]
3190 fn n_and_N_walk_matches_in_both_directions() {
3191 let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
3192 type_search(&mut st, SearchDirection::Forward, "foo");
3193 let first = st.cursor().line;
3194 st.apply(&Action::SearchRepeat { reverse: false });
3195 let second = st.cursor().line;
3196 assert!(second > first, "n advances ({first} -> {second})");
3197 st.apply(&Action::SearchRepeat { reverse: true });
3198 assert_eq!(st.cursor().line, first, "N comes back");
3199 }
3200
3201 #[test]
3202 fn star_searches_the_word_under_the_cursor() {
3203 let mut st = new_state_with("needle\nhaystack\nneedle\n");
3204 st.apply(&Action::SearchWord { reverse: false });
3205 assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
3206 assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
3207 }
3208
3209 #[test]
3210 fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
3211 let mut st = new_state_with("foo\nbar\nfoo\n");
3212 type_search(&mut st, SearchDirection::Forward, "foo");
3213 let matches_before = st.search.matches().len();
3214
3215 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3216 st.apply(&Action::InsertChar('z'));
3217 st.apply(&Action::ChangeMode(Mode::Normal));
3218
3219 assert!(!st.search.is_prompting(), "prompt gone");
3220 assert_eq!(
3221 st.search.pattern().unwrap().raw(),
3222 "foo",
3223 "old pattern survives"
3224 );
3225 assert_eq!(
3226 st.search.matches().len(),
3227 matches_before,
3228 "old highlights survive"
3229 );
3230 }
3231
3232 #[test]
3233 fn a_search_prompt_and_an_ex_command_are_not_confused() {
3234 let mut st = new_state_with("foo\n");
3235 // No `/` pressed: Command mode belongs to the ex-command line.
3236 st.apply(&Action::ChangeMode(Mode::Command));
3237 assert!(!st.search.is_prompting(), "`:` must not open a search");
3238 st.apply(&Action::InsertChar('w'));
3239 assert!(
3240 st.search.prompt().is_none(),
3241 "typed char went to the ex line"
3242 );
3243 }
3244
3245 #[test]
3246 fn a_missing_pattern_reports_instead_of_failing_silently() {
3247 let mut st = new_state_with("alpha\nbravo\n");
3248 type_search(&mut st, SearchDirection::Forward, "zzz");
3249 assert!(
3250 st.messages.iter().any(|m| m.contains("E486")),
3251 "must report not-found, got {:?}",
3252 st.messages
3253 );
3254 }
3255
3256 #[test]
3257 fn n_without_any_search_reports_rather_than_moving() {
3258 let mut st = new_state_with("alpha\nbravo\n");
3259 let before = st.cursor();
3260 st.apply(&Action::SearchRepeat { reverse: false });
3261 assert_eq!(st.cursor(), before, "cursor must not move");
3262 assert!(
3263 st.messages.iter().any(|m| m.contains("E35")),
3264 "got {:?}",
3265 st.messages
3266 );
3267 }
3268
3269 #[test]
3270 fn search_as_a_motion_composes_with_an_operator() {
3271 // The point of Motion::SearchNext: `d` + search deletes to the match.
3272 let mut st = new_state_with("alpha bravo charlie\n");
3273 type_search(&mut st, SearchDirection::Forward, "charlie");
3274 st.set_cursor(Position::new(0, 0));
3275 let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
3276 assert!(target.is_some(), "search must resolve as a motion");
3277 assert_eq!(target.unwrap().column, 12, "at `charlie`");
3278 }
3279
3280 #[test]
3281 fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
3282 // A silent fallback to offset 0 would make `d` + search delete to the
3283 // start of the file — the worst possible failure for an operator.
3284 let st = new_state_with("alpha bravo\n");
3285 assert!(
3286 st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
3287 .is_none()
3288 );
3289 }
3290
3291 #[test]
3292 fn clear_highlight_keeps_the_pattern_usable() {
3293 let mut st = new_state_with("foo\nbar\nfoo\n");
3294 type_search(&mut st, SearchDirection::Forward, "foo");
3295 st.apply(&Action::ClearSearchHighlight);
3296 assert!(st.search.highlights().is_empty(), "nothing lit");
3297 st.apply(&Action::SearchRepeat { reverse: false });
3298 assert!(st.search.pattern().is_some(), "but n still works");
3299 }
3300
3301 #[test]
3302 fn typing_previews_incrementally_before_commit() {
3303 let mut st = new_state_with("alpha\nbravo\ncharlie\n");
3304 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3305 for c in "charlie".chars() {
3306 st.apply(&Action::InsertChar(c));
3307 }
3308 // incsearch: the cursor has already moved, with nothing committed.
3309 assert_eq!(st.cursor().line, 2, "preview moved the cursor");
3310 assert!(st.search.pattern().is_none(), "but nothing is committed");
3311 }
3312
3313 #[test]
3314 fn backspace_corrects_the_prompt_and_reruns_the_preview() {
3315 let mut st = new_state_with("alpha\nbravo\n");
3316 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3317 for c in "bravox".chars() {
3318 st.apply(&Action::InsertChar(c));
3319 }
3320 assert_eq!(st.search.prompt().unwrap().text(), "bravox");
3321 st.apply(&Action::PromptBackspace);
3322 assert_eq!(
3323 st.search.prompt().unwrap().text(),
3324 "bravo",
3325 "typo corrected"
3326 );
3327 assert_eq!(
3328 st.status_model().prompt_text,
3329 "bravo",
3330 "the model reads the PROMPT — the minibuffer is the ex-line's store",
3331 );
3332 assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
3333 }
3334
3335 #[test]
3336 fn backspacing_past_the_slash_closes_the_prompt() {
3337 let mut st = new_state_with("alpha\n");
3338 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3339 st.apply(&Action::InsertChar('a'));
3340 st.apply(&Action::PromptBackspace);
3341 st.apply(&Action::PromptBackspace);
3342 assert!(!st.search.is_prompting(), "prompt closed");
3343 assert_eq!(st.modal.mode(), Mode::Normal);
3344 }
3345
3346 #[test]
3347 fn noh_clears_highlights_and_keeps_the_pattern() {
3348 let mut st = new_state_with("foo\nbar\nfoo\n");
3349 type_search(&mut st, SearchDirection::Forward, "foo");
3350 assert!(!st.search.highlights().is_empty());
3351 st.run_command("noh", &[]);
3352 assert!(st.search.highlights().is_empty(), ":noh turns them off");
3353 assert!(st.search.pattern().is_some(), "but n still works");
3354 }
3355
3356 #[test]
3357 fn noh_accepts_the_vim_aliases() {
3358 for name in ["noh", "nohl", "nohlsearch"] {
3359 let mut st = new_state_with("foo\nfoo\n");
3360 type_search(&mut st, SearchDirection::Forward, "foo");
3361 st.run_command(name, &[]);
3362 assert!(st.search.highlights().is_empty(), "{name} must clear");
3363 }
3364 }
3365
3366 #[test]
3367 fn backspace_on_the_ex_line_does_not_touch_search_state() {
3368 let mut st = new_state_with("foo\n");
3369 st.apply(&Action::ChangeMode(Mode::Command));
3370 st.apply(&Action::InsertChar('w'));
3371 st.apply(&Action::InsertChar('q'));
3372 st.apply(&Action::PromptBackspace);
3373 assert_eq!(st.status_model().prompt_text, "w");
3374 assert!(st.search.prompt().is_none(), "no search was involved");
3375 }
3376
3377 #[test]
3378 fn up_arrow_recalls_the_previous_search() {
3379 let mut st = new_state_with("alpha\nbravo\n");
3380 type_search(&mut st, SearchDirection::Forward, "bravo");
3381 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3382 st.apply(&Action::PromptHistory { back: true });
3383 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3384 assert_eq!(
3385 st.status_model().prompt_text,
3386 "bravo",
3387 "display follows the prompt"
3388 );
3389 }
3390
3391 #[test]
3392 fn arrowing_back_down_restores_the_half_typed_pattern() {
3393 let mut st = new_state_with("alpha\nbravo\n");
3394 type_search(&mut st, SearchDirection::Forward, "bravo");
3395 st.apply(&Action::SearchOpen(SearchDirection::Forward));
3396 st.apply(&Action::InsertChar('a'));
3397 st.apply(&Action::PromptHistory { back: true });
3398 assert_eq!(st.search.prompt().unwrap().text(), "bravo");
3399 st.apply(&Action::PromptHistory { back: false });
3400 assert_eq!(
3401 st.search.prompt().unwrap().text(),
3402 "a",
3403 "the draft comes back"
3404 );
3405 assert_eq!(st.status_model().prompt_text, "a");
3406 }
3407
3408 #[test]
3409 fn history_arrows_do_nothing_on_the_ex_line() {
3410 let mut st = new_state_with("alpha\n");
3411 st.apply(&Action::ChangeMode(Mode::Command));
3412 st.apply(&Action::InsertChar('w'));
3413 st.apply(&Action::PromptHistory { back: true });
3414 assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
3415 }
3416
3417 // ── trouble.* — the findings view ────────────────────────────────
3418 //
3419 // These assert on the ROWS the picker would be built from, not on the
3420 // registry: the registry is already tested, and what could be wrong
3421 // here is the projection — scoping, freshness, and whether a row goes
3422 // anywhere when pressed.
3423
3424 fn finding_at(buffer: BufferId, line: u32, msg: &str) -> escriba_shirube::Finding {
3425 use escriba_core::{Position, Range};
3426 escriba_shirube::Finding::new(
3427 escriba_shirube::Site::in_buffer(
3428 buffer,
3429 Range::new(Position::new(line, 0), Position::new(line, 1)),
3430 ),
3431 escriba_shirube::Severity::Error,
3432 msg.to_string(),
3433 escriba_shirube::Origin::Text("test"),
3434 )
3435 }
3436
3437 #[test]
3438 fn published_findings_become_picker_rows() {
3439 let mut st = new_state_with("a\nb\nc\n");
3440 let world = st.world();
3441 st.results.publish(
3442 "test",
3443 escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3444 );
3445 let rows = st.finding_items(true);
3446 assert_eq!(rows.len(), 1, "the published finding produces a row");
3447 // The row must SAY something an operator can act on: severity,
3448 // 1-based line, and the message.
3449 let label = &rows[0].label;
3450 assert!(label.contains("ERROR"), "{label}");
3451 assert!(label.contains(":2"), "lines are 1-based on screen: {label}");
3452 assert!(label.contains("boom"), "{label}");
3453 }
3454
3455 #[test]
3456 fn a_stale_list_contributes_no_rows() {
3457 // THE load-bearing one. A list anchored to a revision the buffer has
3458 // moved past must vanish from the view rather than offer a line that
3459 // has since shifted — which is the whole reason findings carry an
3460 // anchor instead of just a position.
3461 let mut st = new_state_with("a\nb\nc\n");
3462 let world = st.world();
3463 st.results.publish(
3464 "test",
3465 escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
3466 );
3467 assert_eq!(st.finding_items(true).len(), 1, "fresh to begin with");
3468
3469 st.apply(&Action::InsertChar('x'));
3470 assert!(
3471 st.finding_items(true).is_empty(),
3472 "an edit moved the text on; the list is stale and must not be shown"
3473 );
3474 }
3475
3476 #[test]
3477 fn document_scope_excludes_another_buffer() {
3478 // `trouble.document` vs `trouble.workspace` is one bool, so this is
3479 // the only thing that can distinguish them.
3480 let mut st = new_state_with("a\nb\n");
3481 let other = st.buffers.scratch("z\n");
3482 let world = st.world();
3483 st.results.publish(
3484 "test",
3485 escriba_shirube::ResultList::new(
3486 vec![
3487 finding_at(st.active, 0, "mine"),
3488 finding_at(other, 0, "theirs"),
3489 ],
3490 world,
3491 ),
3492 );
3493 let ws = st.finding_items(true);
3494 assert_eq!(ws.len(), 2, "workspace scope shows both");
3495 let doc = st.finding_items(false);
3496 assert_eq!(doc.len(), 1, "document scope shows only the active buffer");
3497 assert!(doc[0].label.contains("mine"), "{}", doc[0].label);
3498 }
3499
3500 #[test]
3501 fn files_under_a_root_produces_rows() {
3502 // `files.open-parent` differs from `files.open` only in the root, so
3503 // what must hold is that a root is actually honoured.
3504 let mut st = new_state_with("");
3505 let rows = st.file_items(std::path::Path::new("."));
3506 assert!(!rows.is_empty(), "the working directory has files");
3507 }
3508
3509 // ── vim text objects ─────────────────────────────────────────────
3510 //
3511 // Asserted through `apply` on real buffer text, so a wrong RANGE shows
3512 // up as wrong text rather than as a range that merely looks plausible.
3513
3514 fn after(text: &str, line: u32, col: u32, act: Action) -> String {
3515 let mut st = new_state_with(text);
3516 st.set_cursor(Position::new(line, col));
3517 st.apply(&act);
3518 st.buffers
3519 .get(st.active)
3520 .map(|b| b.to_string())
3521 .unwrap_or_default()
3522 }
3523
3524 fn del_obj(o: escriba_core::TextObject) -> Action {
3525 Action::ApplyOperatorObject {
3526 op: escriba_core::Operator::Delete,
3527 object: o,
3528 }
3529 }
3530
3531 #[test]
3532 fn dd_removes_the_line_not_just_its_contents() {
3533 // The distinction the newline makes: without it, `dd` blanks a line
3534 // and leaves it behind.
3535 let got = after("a\nb\nc\n", 1, 0, del_obj(escriba_core::TextObject::Line));
3536 assert_eq!(got, "a\nc\n");
3537 }
3538
3539 #[test]
3540 fn dd_on_the_last_line_leaves_no_blank_behind() {
3541 // The case a naive start-of-line..start-of-next range gets wrong:
3542 // there is no following newline to take, so it must take the
3543 // preceding one.
3544 let got = after("a\nb\nc\n", 2, 0, del_obj(escriba_core::TextObject::Line));
3545 assert_eq!(got, "a\nb\n", "no trailing empty line: {got:?}");
3546 }
3547
3548 #[test]
3549 fn dd_on_the_only_line_clears_it_but_keeps_the_line() {
3550 let got = after("solo\n", 0, 2, del_obj(escriba_core::TextObject::Line));
3551 assert!(got.starts_with('\n') || got.is_empty(), "{got:?}");
3552 }
3553
3554 #[test]
3555 fn diw_takes_the_word_and_daw_takes_its_trailing_space() {
3556 let inner = after(
3557 "one two three\n",
3558 0,
3559 5,
3560 del_obj(escriba_core::TextObject::Word { around: false }),
3561 );
3562 assert_eq!(inner, "one three\n", "iw leaves both spaces");
3563 let around = after(
3564 "one two three\n",
3565 0,
3566 5,
3567 del_obj(escriba_core::TextObject::Word { around: true }),
3568 );
3569 assert_eq!(around, "one three\n", "aw takes the trailing space");
3570 }
3571
3572 #[test]
3573 fn iw_from_any_column_inside_the_word_takes_the_whole_word() {
3574 for col in 4..=6 {
3575 let got = after(
3576 "one two three\n",
3577 0,
3578 col,
3579 del_obj(escriba_core::TextObject::Word { around: false }),
3580 );
3581 assert_eq!(got, "one three\n", "from column {col}");
3582 }
3583 }
3584
3585 #[test]
3586 fn iw_on_punctuation_takes_the_punctuation_run() {
3587 // vim's three classes: word / punctuation / whitespace. A `::` is a
3588 // run of punctuation, not part of either identifier.
3589 let got = after(
3590 "foo::bar\n",
3591 0,
3592 3,
3593 del_obj(escriba_core::TextObject::Word { around: false }),
3594 );
3595 assert_eq!(got, "foobar\n");
3596 }
3597
3598 #[test]
3599 fn i_paren_takes_the_inside_and_a_paren_takes_the_brackets_too() {
3600 let inner = after(
3601 "f(a, b)\n",
3602 0,
3603 3,
3604 del_obj(escriba_core::TextObject::Delimited {
3605 open: '(',
3606 close: ')',
3607 around: false,
3608 }),
3609 );
3610 assert_eq!(inner, "f()\n");
3611 let around = after(
3612 "f(a, b)\n",
3613 0,
3614 3,
3615 del_obj(escriba_core::TextObject::Delimited {
3616 open: '(',
3617 close: ')',
3618 around: true,
3619 }),
3620 );
3621 assert_eq!(around, "f\n");
3622 }
3623
3624 #[test]
3625 fn nested_brackets_resolve_to_the_enclosing_pair() {
3626 // THE reason the bracket scan counts depth: an inner pair must not
3627 // terminate the search for the one the cursor is actually inside.
3628 let got = after(
3629 "f(g(x), y)\n",
3630 0,
3631 8,
3632 del_obj(escriba_core::TextObject::Delimited {
3633 open: '(',
3634 close: ')',
3635 around: false,
3636 }),
3637 );
3638 assert_eq!(got, "f()\n", "took the outer pair");
3639 }
3640
3641 #[test]
3642 fn quotes_do_not_nest_so_the_nearest_pair_wins() {
3643 let got = after(
3644 r#"say "hi there" ok"#,
3645 0,
3646 7,
3647 del_obj(escriba_core::TextObject::Delimited {
3648 open: '"',
3649 close: '"',
3650 around: false,
3651 }),
3652 );
3653 assert_eq!(got, "say \"\" ok");
3654 }
3655
3656 #[test]
3657 fn an_unmatched_delimiter_resolves_to_nothing_rather_than_guessing() {
3658 let mut st = new_state_with("f(a, b\n");
3659 st.set_cursor(Position::new(0, 3));
3660 let before = st
3661 .buffers
3662 .get(st.active)
3663 .map(|b| b.to_string())
3664 .unwrap_or_default();
3665 st.apply(&del_obj(escriba_core::TextObject::Delimited {
3666 open: '(',
3667 close: ')',
3668 around: false,
3669 }));
3670 let got_after = st
3671 .buffers
3672 .get(st.active)
3673 .map(|b| b.to_string())
3674 .unwrap_or_default();
3675 assert_eq!(got_after, before, "no closing bracket: change nothing");
3676 }
3677
3678 fn new_state_with(text: &str) -> EditorState {
3679 let mut bufs = BufferSet::new();
3680 let id = bufs.scratch(text);
3681 EditorState::new_with_buffer(bufs, id)
3682 }
3683
3684 /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
3685 /// action advances `edit_gen` (so the renderer repaints), and merely
3686 /// reading the generation does not. This is what lets `gpu.rs` gate the
3687 /// re-highlight/re-shape on a generation change — an idle frame observes an
3688 /// unchanged generation and reuses its cached buffer.
3689 #[test]
3690 fn edit_gen_advances_on_applied_action_not_on_read() {
3691 let mut s = new_state_with("hello\nworld\n");
3692 let g0 = s.edit_gen();
3693 s.apply(&Action::InsertChar('X'));
3694 assert_ne!(
3695 s.edit_gen(),
3696 g0,
3697 "an applied action must advance the refresh generation",
3698 );
3699 // Reading the generation is not a mutation — idle frames stay put.
3700 let g1 = s.edit_gen();
3701 assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
3702 }
3703
3704 /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
3705 /// `Damage` to cover exactly what changed — local for an in-place edit,
3706 /// to-end-of-document when the line count shifts — and the renderer drains
3707 /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
3708 #[test]
3709 fn damage_tracks_edit_scope_and_drains() {
3710 let mut s = new_state_with("hello\nworld\n");
3711 assert!(s.damage().is_none(), "a fresh state has no damage");
3712
3713 s.apply(&Action::InsertChar('X')); // in-place edit on line 0
3714 assert_eq!(
3715 s.damage(),
3716 Damage::Lines { from: 0, to: 0 },
3717 "a local edit damages just its line",
3718 );
3719
3720 let drained = s.take_damage();
3721 assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
3722 assert!(s.damage().is_none(), "take_damage drains to None");
3723
3724 s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
3725 assert_eq!(
3726 s.damage(),
3727 Damage::Lines {
3728 from: 0,
3729 to: u32::MAX,
3730 },
3731 "a line-count change damages to end-of-document",
3732 );
3733 }
3734
3735 /// A state whose active window is a deliberately tiny viewport
3736 /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
3737 /// invariant is exercised on small inputs.
3738 fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
3739 let mut s = new_state_with(text);
3740 for w in s.layout.windows_mut() {
3741 w.viewport.visible_lines = vis_lines;
3742 w.viewport.visible_columns = vis_cols;
3743 }
3744 s
3745 }
3746
3747 /// The core regression invariant: the active window's viewport CONTAINS
3748 /// the cursor on BOTH axes. This is the operator's exact complaint —
3749 /// "typing past the bottom (or right) leaves the cursor off-screen" —
3750 /// made into a checkable property.
3751 fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
3752 let w = s.layout.active_window().expect("active window");
3753 let v = w.viewport;
3754 let c = s.cursor();
3755 assert!(
3756 v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
3757 "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
3758 c.line,
3759 v.top_line,
3760 v.top_line + v.visible_lines,
3761 );
3762 assert!(
3763 v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
3764 "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
3765 c.column,
3766 v.left_column,
3767 v.left_column + v.visible_columns,
3768 );
3769 }
3770
3771 /// `dd` from the KEYBOARD, not from a synthesized action.
3772 ///
3773 /// The FSM composition is unit-tested, but what an operator actually
3774 /// does is press `d` twice — and that path goes through the keymap and
3775 /// the sequence stepper, either of which could swallow the second `d`.
3776 #[test]
3777 fn pressing_d_twice_deletes_the_line() {
3778 let mut st = new_state_with("alpha\nbeta\ngamma\n");
3779 st.set_cursor(Position::new(1, 0));
3780 st.tick(&press(KeyCode::Char('d')));
3781 st.tick(&press(KeyCode::Char('d')));
3782 let got = st
3783 .buffers
3784 .get(st.active)
3785 .map(|b| b.to_string())
3786 .unwrap_or_default();
3787 assert_eq!(got, "alpha\ngamma\n", "dd from the keyboard");
3788 }
3789
3790 #[test]
3791 fn pressing_2_d_d_deletes_two_lines() {
3792 let mut st = new_state_with("a\nb\nc\nd\n");
3793 st.set_cursor(Position::new(0, 0));
3794 for k in ['2', 'd', 'd'] {
3795 st.tick(&press(KeyCode::Char(k)));
3796 }
3797 let got = st
3798 .buffers
3799 .get(st.active)
3800 .map(|b| b.to_string())
3801 .unwrap_or_default();
3802 assert_eq!(got, "c\nd\n", "count applies to the doubled operator");
3803 }
3804
3805 /// Text objects FROM THE KEYBOARD — the layer the last commit said was
3806 /// missing. `i` is `ChangeMode(Insert)` in Normal and `a` and every
3807 /// bracket are unbound, so all of this had to be decided on the KEY.
3808
3809 fn keys(text: &str, line: u32, col: u32, seq: &str) -> String {
3810 let mut st = new_state_with(text);
3811 st.set_cursor(Position::new(line, col));
3812 for c in seq.chars() {
3813 st.tick(&press(KeyCode::Char(c)));
3814 }
3815 st.buffers
3816 .get(st.active)
3817 .map(|b| b.to_string())
3818 .unwrap_or_default()
3819 }
3820
3821 #[test]
3822 fn diw_from_the_keyboard() {
3823 assert_eq!(keys("one two three\n", 0, 5, "diw"), "one three\n");
3824 }
3825
3826 #[test]
3827 fn daw_from_the_keyboard_takes_the_space() {
3828 assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
3829 }
3830
3831 #[test]
3832 fn ciw_deletes_and_enters_insert() {
3833 let mut st = new_state_with("one two\n");
3834 st.set_cursor(Position::new(0, 5));
3835 for c in "ciw".chars() {
3836 st.tick(&press(KeyCode::Char(c)));
3837 }
3838 assert_eq!(st.modal.mode(), Mode::Insert, "change leaves you inserting");
3839 let got = st
3840 .buffers
3841 .get(st.active)
3842 .map(|b| b.to_string())
3843 .unwrap_or_default();
3844 assert_eq!(got, "one \n");
3845 }
3846
3847 #[test]
3848 fn di_paren_and_da_paren_from_the_keyboard() {
3849 assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
3850 assert_eq!(keys("f(a, b)\n", 0, 3, "da("), "f\n");
3851 }
3852
3853 #[test]
3854 fn the_closing_bracket_and_b_are_aliases() {
3855 // vim accepts `i(`, `i)` and `ib` for the same object.
3856 for sel in ["di(", "di)", "dib"] {
3857 assert_eq!(keys("f(a, b)\n", 0, 3, sel), "f()\n", "{sel}");
3858 }
3859 }
3860
3861 #[test]
3862 fn di_quote_from_the_keyboard() {
3863 assert_eq!(keys("say \"hi\" ok\n", 0, 6, "di\""), "say \"\" ok\n");
3864 }
3865
3866 #[test]
3867 fn i_alone_still_enters_insert_when_no_operator_is_pending() {
3868 // The load-bearing negative: the object layer must not steal `i`
3869 // from ordinary use.
3870 let mut st = new_state_with("abc\n");
3871 st.tick(&press(KeyCode::Char('i')));
3872 assert_eq!(st.modal.mode(), Mode::Insert);
3873 }
3874
3875 #[test]
3876 fn an_unknown_object_key_cancels_rather_than_staying_armed() {
3877 // `diz` is not an object. The operator must disarm, and the buffer
3878 // must be untouched — not left waiting to eat the next keystroke.
3879 let mut st = new_state_with("one two\n");
3880 st.set_cursor(Position::new(0, 5));
3881 for c in "diz".chars() {
3882 st.tick(&press(KeyCode::Char(c)));
3883 }
3884 let got = st
3885 .buffers
3886 .get(st.active)
3887 .map(|b| b.to_string())
3888 .unwrap_or_default();
3889 assert_eq!(got, "one two\n", "nothing was deleted");
3890 assert_eq!(*st.op_pending.state(), OpState::Resting, "and it disarmed");
3891 }
3892
3893 // ── the register under a count ───────────────────────────────────
3894
3895 #[test]
3896 fn a_counted_delete_puts_ALL_of_it_in_the_register() {
3897 // `3dw` is one delete of three words as far as the register is
3898 // concerned. Each repetition emits its own Yank, and each used to
3899 // overwrite — so `3dwP` put back only the third word and silently
3900 // lost two.
3901 let mut st = new_state_with("one two three four\n");
3902 st.set_cursor(Position::new(0, 0));
3903 for c in "3dw".chars() {
3904 st.tick(&press(KeyCode::Char(c)));
3905 }
3906 assert_eq!(
3907 st.register.as_deref(),
3908 Some("one two three "),
3909 "all three words, in the order they were deleted"
3910 );
3911 }
3912
3913 #[test]
3914 fn an_uncounted_delete_still_replaces_the_register() {
3915 // The combining flag must not leak: a later single delete replaces.
3916 let mut st = new_state_with("alpha beta\n");
3917 st.set_cursor(Position::new(0, 0));
3918 for c in "3dw".chars() {
3919 st.tick(&press(KeyCode::Char(c)));
3920 }
3921 let mut st2 = new_state_with("gamma delta\n");
3922 st2.set_cursor(Position::new(0, 0));
3923 for c in "dw".chars() {
3924 st2.tick(&press(KeyCode::Char(c)));
3925 }
3926 assert_eq!(st2.register.as_deref(), Some("gamma "));
3927 }
3928
3929 #[test]
3930 fn two_separate_counted_deletes_do_not_accumulate_into_each_other() {
3931 // The flag is cleared after each group, so the second `2dw` starts
3932 // from empty rather than appending to the first.
3933 let mut st = new_state_with("a b c d e f\n");
3934 st.set_cursor(Position::new(0, 0));
3935 for c in "2dw".chars() {
3936 st.tick(&press(KeyCode::Char(c)));
3937 }
3938 let first = st.register.clone();
3939 for c in "2dw".chars() {
3940 st.tick(&press(KeyCode::Char(c)));
3941 }
3942 assert_eq!(first.as_deref(), Some("a b "));
3943 assert_eq!(st.register.as_deref(), Some("c d "), "not \"a b c d \"");
3944 }
3945
3946 #[test]
3947 fn a_counted_yank_accumulates_without_changing_the_buffer() {
3948 let mut st = new_state_with("one two three\n");
3949 st.set_cursor(Position::new(0, 0));
3950 let before = st
3951 .buffers
3952 .get(st.active)
3953 .map(|b| b.to_string())
3954 .unwrap_or_default();
3955 for c in "2yw".chars() {
3956 st.tick(&press(KeyCode::Char(c)));
3957 }
3958 assert_eq!(st.register.as_deref(), Some("one two "));
3959 let after = st
3960 .buffers
3961 .get(st.active)
3962 .map(|b| b.to_string())
3963 .unwrap_or_default();
3964 assert_eq!(after, before, "yank does not edit");
3965 }
3966
3967 fn press(kc: KeyCode) -> AppEvent {
3968 AppEvent::Key(KeyEvent {
3969 key: kc,
3970 pressed: true,
3971 modifiers: Modifiers::default(),
3972 text: None,
3973 })
3974 }
3975
3976 // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
3977
3978 fn line0_len(s: &EditorState) -> u32 {
3979 s.buffers.get(s.active).unwrap().line_len_chars(0)
3980 }
3981
3982 #[test]
3983 fn delete_to_line_end_clears_line_and_fills_register() {
3984 let mut s = new_state_with("hello world");
3985 s.apply(&Action::ApplyOperator {
3986 op: Operator::Delete,
3987 motion: Motion::LineEnd,
3988 });
3989 assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
3990 assert_eq!(
3991 s.register(),
3992 Some("hello world"),
3993 "delete fills the register"
3994 );
3995 assert_eq!(
3996 s.cursor(),
3997 Position::ZERO,
3998 "cursor lands at the range start"
3999 );
4000 }
4001
4002 #[test]
4003 fn delete_over_right_motion_removes_one_char() {
4004 let mut s = new_state_with("abc");
4005 s.apply(&Action::ApplyOperator {
4006 op: Operator::Delete,
4007 motion: Motion::Right,
4008 });
4009 assert_eq!(
4010 s.buffers.get(s.active).unwrap().line(0).as_deref(),
4011 Some("bc")
4012 );
4013 assert_eq!(s.register(), Some("a"));
4014 }
4015
4016 #[test]
4017 fn change_to_line_end_deletes_and_enters_insert() {
4018 let mut s = new_state_with("hello world");
4019 assert_eq!(s.modal.mode(), Mode::Normal);
4020 s.apply(&Action::ApplyOperator {
4021 op: Operator::Change,
4022 motion: Motion::LineEnd,
4023 });
4024 assert_eq!(line0_len(&s), 0, "c$ deletes the range");
4025 assert_eq!(
4026 s.modal.mode(),
4027 Mode::Insert,
4028 "change enters Insert to type the replacement"
4029 );
4030 assert_eq!(
4031 s.register(),
4032 Some("hello world"),
4033 "change fills the register"
4034 );
4035 }
4036
4037 #[test]
4038 fn yank_to_line_end_fills_register_without_mutating() {
4039 let mut s = new_state_with("hello world");
4040 s.apply(&Action::ApplyOperator {
4041 op: Operator::Yank,
4042 motion: Motion::LineEnd,
4043 });
4044 assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
4045 assert_eq!(s.register(), Some("hello world"), "yank fills the register");
4046 assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
4047 }
4048
4049 #[test]
4050 fn resolve_motion_is_the_shared_target_for_move_and_operator() {
4051 // The encapsulation proof: apply_motion (cursor move) and
4052 // apply_operator (range end) BOTH stand on resolve_motion — so a move
4053 // to LineEnd lands at exactly the position the operator deletes to.
4054 let mut s = new_state_with("hello world");
4055 let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
4056 assert_eq!(target, Position::new(0, 11));
4057 s.apply_motion(Motion::LineEnd);
4058 assert_eq!(
4059 s.cursor(),
4060 target,
4061 "the move path resolves the same target the operator uses"
4062 );
4063 }
4064
4065 #[test]
4066 fn empty_motion_range_is_a_no_op() {
4067 // An operator over a zero-width motion (cursor already at line start)
4068 // mutates nothing and leaves the register untouched.
4069 let mut s = new_state_with("abc");
4070 s.apply(&Action::ApplyOperator {
4071 op: Operator::Delete,
4072 motion: Motion::LineStart,
4073 });
4074 assert_eq!(
4075 s.buffers.get(s.active).unwrap().line(0).as_deref(),
4076 Some("abc")
4077 );
4078 assert_eq!(s.register(), None);
4079 }
4080
4081 #[test]
4082 fn operator_then_motion_composes_through_the_pending_fsm() {
4083 // The full keymap→FSM→engine path: dispatching the `d` operator action
4084 // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
4085 // the operator key alone does nothing until the motion arrives.
4086 let mut s = new_state_with("hello world");
4087 s.apply(&Action::Operator(Operator::Delete));
4088 assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
4089 s.apply(&Action::Move(Motion::LineEnd));
4090 assert_eq!(
4091 line0_len(&s),
4092 0,
4093 "d then $ composes d$ and deletes the line"
4094 );
4095 assert_eq!(s.register(), Some("hello world"));
4096 }
4097
4098 #[test]
4099 fn change_operator_through_fsm_enters_insert() {
4100 let mut s = new_state_with("hello world");
4101 s.apply(&Action::Operator(Operator::Change));
4102 s.apply(&Action::Move(Motion::LineEnd));
4103 assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
4104 }
4105
4106 #[test]
4107 fn lone_motion_after_no_operator_just_moves() {
4108 // Without a preceding operator the motion passes through unchanged.
4109 let mut s = new_state_with("hello world");
4110 s.apply(&Action::Move(Motion::LineEnd));
4111 assert_eq!(s.cursor(), Position::new(0, 11));
4112 assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
4113 }
4114
4115 #[test]
4116 fn counted_operator_deletes_count_times() {
4117 // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
4118 // flows through the FSM to the composed motion (the bug fix: previously
4119 // the count repeated the operator key and toggled the FSM).
4120 let mut s = new_state_with("abcdef");
4121 s.apply_counted(&Action::Operator(Operator::Delete), 3);
4122 assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
4123 s.apply(&Action::Move(Motion::Right));
4124 assert_eq!(
4125 s.buffers.get(s.active).unwrap().line(0).as_deref(),
4126 Some("def")
4127 );
4128 }
4129
4130 #[test]
4131 fn operator_and_motion_counts_multiply_end_to_end() {
4132 // `2d3l` = delete 2×3 = 6 chars.
4133 let mut s = new_state_with("abcdefgh");
4134 s.apply_counted(&Action::Operator(Operator::Delete), 2);
4135 s.apply_counted(&Action::Move(Motion::Right), 3);
4136 assert_eq!(
4137 s.buffers.get(s.active).unwrap().line(0).as_deref(),
4138 Some("gh")
4139 );
4140 }
4141
4142 #[test]
4143 fn bare_counted_motion_still_repeats_no_regression() {
4144 // `3j` still moves down 3 lines — the count passes through the FSM
4145 // unchanged when no operator is pending.
4146 let mut s = new_state_with("a\nb\nc\nd\ne");
4147 s.apply_counted(&Action::Move(Motion::Down), 3);
4148 assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
4149 }
4150
4151 /// A monotonic clock for the key-repeat gate in tests — each `next()`
4152 /// jumps a full second past the previous, so every press it stamps is
4153 /// well outside the 80ms debounce window and therefore an INTENTIONAL
4154 /// press (never a storm tick). Used by tests that fire the *same*
4155 /// navigation key twice and assert editor logic, not debounce timing.
4156 struct SpacedClock(std::time::Instant);
4157 impl SpacedClock {
4158 fn new() -> Self {
4159 Self(std::time::Instant::now())
4160 }
4161 fn next(&mut self) -> std::time::Instant {
4162 self.0 += std::time::Duration::from_secs(1);
4163 self.0
4164 }
4165 }
4166
4167 #[test]
4168 fn hjkl_moves_cursor() {
4169 let mut s = new_state_with("hello\nworld");
4170 s.tick(&press(KeyCode::Char('l')));
4171 assert_eq!(s.cursor().column, 1);
4172 s.tick(&press(KeyCode::Char('j')));
4173 assert_eq!(s.cursor().line, 1);
4174 s.tick(&press(KeyCode::Char('h')));
4175 assert_eq!(s.cursor().column, 0);
4176 }
4177
4178 #[test]
4179 fn insert_mode_inserts_chars() {
4180 let mut s = new_state_with("");
4181 s.tick(&press(KeyCode::Char('i')));
4182 assert_eq!(s.modal.mode(), Mode::Insert);
4183 s.tick(&press(KeyCode::Char('h')));
4184 s.tick(&press(KeyCode::Char('i')));
4185 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
4186 assert_eq!(s.cursor().column, 2);
4187 }
4188
4189 #[test]
4190 fn esc_returns_to_normal() {
4191 let mut s = new_state_with("");
4192 s.tick(&press(KeyCode::Char('i')));
4193 s.tick(&press(KeyCode::Escape));
4194 assert_eq!(s.modal.mode(), Mode::Normal);
4195 }
4196
4197 #[test]
4198 fn count_prefix_repeats_motion() {
4199 let mut s = new_state_with("abcdefghij");
4200 s.tick(&press(KeyCode::Char('5')));
4201 s.tick(&press(KeyCode::Char('l')));
4202 assert_eq!(s.cursor().column, 5);
4203 }
4204
4205 #[test]
4206 fn close_event_requests_quit() {
4207 let mut s = new_state_with("");
4208 s.tick(&AppEvent::CloseRequested);
4209 assert!(s.quit_requested);
4210 }
4211
4212 #[test]
4213 fn word_next_jumps_past_whitespace() {
4214 let mut s = new_state_with("foo bar baz");
4215 // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
4216 // the gate passes both (a real user's two taps are ≥80ms apart).
4217 let mut clk = SpacedClock::new();
4218 s.tick_at(&press(KeyCode::Char('w')), clk.next());
4219 assert_eq!(s.cursor().column, 4);
4220 s.tick_at(&press(KeyCode::Char('w')), clk.next());
4221 assert_eq!(s.cursor().column, 8);
4222 }
4223
4224 // ── Multi-key / leader pending-stroke ───────────────────────────
4225
4226 #[test]
4227 fn leader_sequence_holds_then_resolves() {
4228 let mut s = new_state_with("a\nbb\nccc");
4229 s.keymap.bind_sequence(
4230 Mode::Normal,
4231 vec![Key::Char(','), Key::Char('g')],
4232 Action::Move(Motion::DocEnd),
4233 "doc end",
4234 );
4235 // `,` begins the sequence — held pending, nothing applied yet.
4236 s.on_key(&Key::Char(','));
4237 assert_eq!(s.pending_keys, vec![Key::Char(',')]);
4238 assert_eq!(s.cursor(), Position::ZERO);
4239 // `g` completes `<leader>g` → DocEnd; pending clears.
4240 s.on_key(&Key::Char('g'));
4241 assert!(s.pending_keys.is_empty());
4242 assert_eq!(s.cursor().line, 2);
4243 }
4244
4245 #[test]
4246 fn two_key_gg_jumps_doc_start() {
4247 let mut s = new_state_with("a\nbb\nccc");
4248 s.keymap.bind_sequence(
4249 Mode::Normal,
4250 vec![Key::Char('g'), Key::Char('g')],
4251 Action::Move(Motion::DocStart),
4252 "doc start",
4253 );
4254 let mut clk = SpacedClock::new();
4255 s.tick_at(&press(KeyCode::Char('j')), clk.next());
4256 s.tick_at(&press(KeyCode::Char('j')), clk.next());
4257 assert_eq!(s.cursor().line, 2);
4258 s.on_key(&Key::Char('g')); // pending
4259 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4260 s.on_key(&Key::Char('g')); // resolve
4261 assert_eq!(s.cursor(), Position::ZERO);
4262 }
4263
4264 #[test]
4265 fn broken_sequence_aborts_and_clears_pending() {
4266 let mut s = new_state_with("hello");
4267 s.keymap.bind_sequence(
4268 Mode::Normal,
4269 vec![Key::Char('g'), Key::Char('g')],
4270 Action::Move(Motion::DocEnd),
4271 "doc end",
4272 );
4273 s.on_key(&Key::Char('g')); // pending [g]
4274 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4275 s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
4276 assert!(s.pending_keys.is_empty());
4277 assert_eq!(s.cursor(), Position::ZERO);
4278 }
4279
4280 #[test]
4281 fn single_binding_wins_over_sequence_prefix() {
4282 // A key that is BOTH a complete single binding and the start of
4283 // a sequence fires the single binding immediately (no chord
4284 // timeout needed). Here `h` (move-left) also prefixes `hz`.
4285 let mut s = new_state_with("abcde");
4286 let mut clk = SpacedClock::new();
4287 s.tick_at(&press(KeyCode::Char('l')), clk.next());
4288 s.tick_at(&press(KeyCode::Char('l')), clk.next());
4289 assert_eq!(s.cursor().column, 2);
4290 s.keymap.bind_sequence(
4291 Mode::Normal,
4292 vec![Key::Char('h'), Key::Char('z')],
4293 Action::Move(Motion::DocEnd),
4294 "shadowed",
4295 );
4296 s.on_key(&Key::Char('h'));
4297 assert!(s.pending_keys.is_empty(), "single binding should not pend");
4298 assert_eq!(s.cursor().column, 1, "h moved left immediately");
4299 }
4300
4301 // ── tatara-lisp runtime bridge (imperative programmability) ─────
4302
4303 #[test]
4304 fn lisp_set_option_writes_live_options() {
4305 let mut s = new_state_with("");
4306 s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
4307 assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
4308 }
4309
4310 #[test]
4311 fn lisp_insert_modifies_buffer_and_advances_cursor() {
4312 let mut s = new_state_with("");
4313 s.run_lisp(r#"(insert "abc")"#).unwrap();
4314 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
4315 assert_eq!(s.cursor(), Position::new(0, 3));
4316 }
4317
4318 #[test]
4319 fn lisp_message_appends_to_messages() {
4320 let mut s = new_state_with("");
4321 s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
4322 assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
4323 }
4324
4325 #[test]
4326 fn lisp_reads_snapshot_and_branches_to_effect() {
4327 // Genuine programmability: Lisp reads the live cursor line and
4328 // an `if` decides which option to set.
4329 let mut s = new_state_with("one\ntwo\nthree");
4330 // cursor at line 0 → "top" branch
4331 s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
4332 .unwrap();
4333 assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
4334 }
4335
4336 #[test]
4337 fn lisp_run_command_effect_drives_registry() {
4338 // `(run-command "undo")` reaches the live command registry and
4339 // reverts a prior Lisp-driven insert — proving the RunCommand
4340 // effect dispatches through real editor commands.
4341 let mut s = new_state_with("");
4342 s.run_lisp(r#"(insert "abc")"#).unwrap();
4343 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
4344 s.run_lisp(r#"(run-command "undo")"#).unwrap();
4345 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
4346 }
4347
4348 #[test]
4349 fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
4350 // The full imperative-quit path: (run-command "quit") routes
4351 // through the registry's typed `quit_requested` signal — no string
4352 // sentinel, and no minibuffer pollution (the editor stays in a
4353 // clean Normal state, which has no minibuffer at all).
4354 let mut s = new_state_with("");
4355 s.run_lisp(r#"(run-command "quit")"#).unwrap();
4356 assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
4357 assert_eq!(
4358 s.modal.minibuffer(),
4359 "",
4360 "quit must not pollute any command line — Normal mode has no minibuffer",
4361 );
4362 }
4363
4364 // ── Lazy plugin activation (PluginHost) ────────────────────────
4365
4366 #[test]
4367 fn lazy_plugin_activates_on_command_trigger() {
4368 // A user plugin gated on `Command: LazyGo` has its entry applied
4369 // the first time that command runs — proving the lazy.nvim
4370 // `cmd =` model works end-to-end against live editor state.
4371 let mut s = new_state_with("");
4372 s.register_lazy_plugin(
4373 "user-lazy",
4374 vec![LazyTrigger::Command("LazyGo".into())],
4375 r#"(defoption :name "lazy-loaded" :value "yes")
4376 (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
4377 );
4378 assert_eq!(s.plugin_host.pending(), 1);
4379 assert!(
4380 s.options.get("lazy-loaded").is_none(),
4381 "entry not applied yet"
4382 );
4383
4384 // Drive the command through the public imperative path.
4385 s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
4386
4387 assert_eq!(
4388 s.options.get("lazy-loaded").map(String::as_str),
4389 Some("yes"),
4390 "the command trigger applied the plugin's entry",
4391 );
4392 assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
4393 }
4394
4395 #[test]
4396 fn lazy_plugin_activates_on_filetype() {
4397 let mut s = new_state_with("");
4398 s.register_lazy_plugin(
4399 "user-rust",
4400 vec![LazyTrigger::FileType("rust".into())],
4401 r#"(defoption :name "rust-plugin" :value "on")"#,
4402 );
4403 let n = s.activate_filetype_plugins("rust");
4404 assert_eq!(n, 1);
4405 assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
4406 // A second open of the same filetype is a no-op (one-shot).
4407 assert_eq!(s.activate_filetype_plugins("rust"), 0);
4408 }
4409
4410 #[test]
4411 fn cached_vm_serves_multiple_run_lisp_calls() {
4412 let mut s = new_state_with("");
4413 s.run_lisp(r#"(message "one")"#).unwrap();
4414 assert!(
4415 s.lisp_vm.is_some(),
4416 "VM should be cached after first run_lisp"
4417 );
4418 s.run_lisp(r#"(message "two")"#).unwrap();
4419 assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
4420 }
4421
4422 #[test]
4423 fn lisp_define_persists_across_run_lisp_calls() {
4424 // The cached VM's top-level env persists across calls (REPL
4425 // semantics): a `define` in one call is visible in the next.
4426 let mut s = new_state_with("");
4427 s.run_lisp(r#"(define greeting "hi")"#).unwrap();
4428 s.run_lisp(r#"(message greeting)"#).unwrap();
4429 assert_eq!(s.messages, vec!["hi".to_string()]);
4430 }
4431
4432 #[test]
4433 fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
4434 // Within ONE call a program cannot observe its own writes — the
4435 // read snapshot is captured before eval, effects apply after. A
4436 // later call sees the refreshed snapshot.
4437 let mut s = new_state_with("");
4438 s.run_lisp(
4439 r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
4440 )
4441 .unwrap();
4442 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
4443 assert_eq!(
4444 s.options.get("col").map(String::as_str),
4445 Some("stale-zero"),
4446 "cursor-column within the same call reads the pre-eval snapshot",
4447 );
4448 // After the first call the cursor advanced to column 2; the next
4449 // call's snapshot reflects it.
4450 s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
4451 .unwrap();
4452 assert_eq!(
4453 s.options.get("col2").map(String::as_str),
4454 Some("live-two"),
4455 "a later call sees the refreshed snapshot",
4456 );
4457 }
4458
4459 #[test]
4460 fn insert_text_effect_multiline_lands_cursor_on_last_line() {
4461 let mut s = new_state_with("");
4462 s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
4463 assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
4464 assert_eq!(s.cursor(), Position::new(1, 3));
4465 }
4466
4467 #[test]
4468 fn visual_mode_sequence_resolves() {
4469 let mut s = new_state_with("abc");
4470 s.modal.enter(Mode::Visual);
4471 s.keymap.bind_sequence(
4472 Mode::Visual,
4473 vec![Key::Char('g'), Key::Char('e')],
4474 Action::Move(Motion::DocEnd),
4475 "ge",
4476 );
4477 s.on_key(&Key::Char('g'));
4478 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4479 s.on_key(&Key::Char('e'));
4480 assert!(s.pending_keys.is_empty());
4481 assert_eq!(
4482 s.cursor().column,
4483 3,
4484 "ge resolved to doc-end in visual mode"
4485 );
4486 }
4487
4488 #[test]
4489 fn sequence_abort_with_bound_breaking_key_redispatches() {
4490 // gg is a sequence; `l` (move-right) is a bound single key. After
4491 // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
4492 let mut s = new_state_with("abcde");
4493 s.keymap.bind_sequence(
4494 Mode::Normal,
4495 vec![Key::Char('g'), Key::Char('g')],
4496 Action::Move(Motion::DocEnd),
4497 "gg",
4498 );
4499 s.on_key(&Key::Char('g'));
4500 assert_eq!(s.pending_keys, vec![Key::Char('g')]);
4501 s.on_key(&Key::Char('l'));
4502 assert!(s.pending_keys.is_empty());
4503 assert_eq!(
4504 s.cursor().column,
4505 1,
4506 "the breaking key l should re-dispatch as move-right",
4507 );
4508 }
4509
4510 // ── Viewport-follows-cursor invariant (both axes) ───────────────
4511
4512 #[test]
4513 fn viewport_contains_cursor_after_every_op() {
4514 // Tiny window: 5 visible lines × 10 visible columns. Drive a
4515 // representative scripted sequence and assert the viewport contains
4516 // the cursor after EVERY mutating step.
4517 let mut s = new_state_small_viewport("", 5, 10);
4518 assert_cursor_in_viewport(&s, "initial");
4519
4520 // Enter insert mode and type 30 newline-separated lines — this is
4521 // the exact "type past the bottom" complaint.
4522 s.tick(&press(KeyCode::Char('i')));
4523 assert_eq!(s.modal.mode(), Mode::Insert);
4524 for line in 0..30u32 {
4525 for c in "line".chars() {
4526 s.tick(&press(KeyCode::Char(c)));
4527 assert_cursor_in_viewport(&s, "typing chars");
4528 }
4529 s.tick(&press(KeyCode::Enter));
4530 assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
4531 }
4532
4533 // Type a long (200-char) line — the "type past the right edge"
4534 // complaint. The cursor must stay horizontally visible the whole way.
4535 for i in 0..200u32 {
4536 s.tick(&press(KeyCode::Char('x')));
4537 assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
4538 }
4539
4540 // Multi-line insert_text effect (the `(insert …)` Lisp path).
4541 s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
4542 assert_cursor_in_viewport(&s, "insert_text multiline");
4543
4544 // Back to normal mode and move in all directions / to extremes.
4545 s.tick(&press(KeyCode::Escape));
4546 assert_eq!(s.modal.mode(), Mode::Normal);
4547 for m in [
4548 Motion::DocStart,
4549 Motion::DocEnd,
4550 Motion::Down,
4551 Motion::Down,
4552 Motion::Up,
4553 Motion::Right,
4554 Motion::Right,
4555 Motion::Left,
4556 Motion::LineEnd,
4557 Motion::LineStart,
4558 Motion::GotoLine(1),
4559 Motion::GotoLine(40),
4560 Motion::PageDown,
4561 Motion::PageUp,
4562 ] {
4563 s.apply_motion(m);
4564 assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
4565 }
4566
4567 // Undo many times — the buffer shrinks; the viewport must re-follow
4568 // the (now clamped) cursor.
4569 for i in 0..50u32 {
4570 s.apply(&Action::Undo);
4571 assert_cursor_in_viewport(&s, &format!("undo {i}"));
4572 }
4573 // Redo back up.
4574 for i in 0..50u32 {
4575 s.apply(&Action::Redo);
4576 assert_cursor_in_viewport(&s, &format!("redo {i}"));
4577 }
4578 }
4579
4580 #[test]
4581 fn insert_at_eof_keeps_cursor_in_bounds() {
4582 // Inserting at the end of the buffer must leave the cursor clamped
4583 // to a valid position (and inside the viewport).
4584 let mut s = new_state_small_viewport("abc", 5, 10);
4585 s.apply_motion(Motion::DocEnd);
4586 s.tick(&press(KeyCode::Char('i')));
4587 s.tick(&press(KeyCode::Char('d')));
4588 let buf = s.buffers.get(s.active).unwrap();
4589 let clamped = buf.clamp(s.cursor());
4590 assert_eq!(
4591 s.cursor(),
4592 clamped,
4593 "cursor must be clamped in-bounds at EOF"
4594 );
4595 assert_cursor_in_viewport(&s, "insert at eof");
4596 }
4597
4598 #[test]
4599 fn count_prefix_then_sequence_repeats() {
4600 // `2` then `gj` (→ move-down) repeats the resolved action twice.
4601 let mut s = new_state_with("a\nb\nc\nd\ne");
4602 s.keymap.bind_sequence(
4603 Mode::Normal,
4604 vec![Key::Char('g'), Key::Char('j')],
4605 Action::Move(Motion::Down),
4606 "gj",
4607 );
4608 s.on_key(&Key::Char('2'));
4609 s.on_key(&Key::Char('g'));
4610 s.on_key(&Key::Char('j'));
4611 assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
4612 }
4613
4614 // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
4615
4616 #[test]
4617 fn held_key_repeat_storm_is_debounced_in_normal_mode() {
4618 // The audit's exact complaint: holding `j` floods motion events
4619 // and thrashes the viewport. Simulate an OS key-repeat storm — 20
4620 // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
4621 // — and assert only the gated subset (one per 80ms window) actually
4622 // moves the cursor.
4623 let mut s = new_state_with(&"x\n".repeat(40));
4624 let t0 = std::time::Instant::now();
4625 let mut delivered = 0u32;
4626 for i in 0..20u32 {
4627 let before = s.cursor().line;
4628 s.tick_at(
4629 &press(KeyCode::Char('j')),
4630 t0 + std::time::Duration::from_millis(u64::from(i) * 50),
4631 );
4632 if s.cursor().line != before {
4633 delivered += 1;
4634 }
4635 }
4636 // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
4637 // fewer than the 20 the ungated path would have applied.
4638 assert!(
4639 (10..=14).contains(&delivered),
4640 "expected the storm debounced to ~13 moves, got {delivered}",
4641 );
4642 assert!(
4643 delivered < 20,
4644 "the gate must drop SOME storm ticks, not pass all 20",
4645 );
4646 }
4647
4648 #[test]
4649 fn spaced_intentional_taps_all_pass() {
4650 // Intentional taps spaced past the debounce window must ALL reach
4651 // the editor — the gate filters storms, never deliberate input.
4652 let mut s = new_state_with(&"x\n".repeat(10));
4653 let t0 = std::time::Instant::now();
4654 for i in 0..5u32 {
4655 s.tick_at(
4656 &press(KeyCode::Char('j')),
4657 // 100ms apart — comfortably past the 80ms window.
4658 t0 + std::time::Duration::from_millis(u64::from(i) * 100),
4659 );
4660 }
4661 assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
4662 }
4663
4664 #[test]
4665 fn distinct_keys_have_independent_clocks() {
4666 // Holding `j` must not block a simultaneous `l` — the gate keys on
4667 // the Key, so independent keys have independent windows.
4668 let mut s = new_state_with("abc\ndef\nghi");
4669 let t = std::time::Instant::now();
4670 s.tick_at(&press(KeyCode::Char('j')), t);
4671 // `j` again within the window is dropped…
4672 s.tick_at(
4673 &press(KeyCode::Char('j')),
4674 t + std::time::Duration::from_millis(10),
4675 );
4676 assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
4677 // …but `l` at the same instant passes (its own clock).
4678 s.tick_at(
4679 &press(KeyCode::Char('l')),
4680 t + std::time::Duration::from_millis(10),
4681 );
4682 assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
4683 }
4684
4685 // ── Cursors newtype is the single cursor home ──────────────────────
4686
4687 #[test]
4688 fn cursor_home_preserves_single_cursor_behavior() {
4689 // The typed `Cursors` wrapper behaves exactly like the old bare
4690 // `Position` field for single-cursor editing: the read accessor
4691 // tracks every mutation routed through `set_cursor`, and there is
4692 // exactly one caret.
4693 let mut s = new_state_with("hello\nworld\nthere");
4694 assert_eq!(s.cursor(), Position::ZERO);
4695 assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
4696
4697 s.apply_motion(Motion::Down);
4698 s.apply_motion(Motion::Right);
4699 s.apply_motion(Motion::Right);
4700 assert_eq!(s.cursor(), Position::new(1, 2));
4701 // Still a single caret after a sequence of motions.
4702 assert_eq!(s.cursors.count(), 1);
4703
4704 // The accessor is the SAME value the viewport-follow path read.
4705 let w = s.layout.active_window().unwrap();
4706 assert!(w.viewport.top_line <= s.cursor().line);
4707 }
4708
4709 #[test]
4710 fn insert_mode_is_ungated_so_repeat_typing_works() {
4711 // Holding a key to repeat-type a character is intended in Insert
4712 // mode — the gate must NOT suppress it. 10 rapid identical `x`
4713 // keystrokes at the same instant must all land as text.
4714 let mut s = new_state_with("");
4715 s.tick(&press(KeyCode::Char('i')));
4716 assert_eq!(s.modal.mode(), Mode::Insert);
4717 let t = std::time::Instant::now();
4718 for _ in 0..10 {
4719 s.tick_at(&press(KeyCode::Char('x')), t);
4720 }
4721 assert_eq!(
4722 s.buffers.get(s.active).unwrap().to_string(),
4723 "xxxxxxxxxx",
4724 "insert-mode repeat typing is ungated",
4725 );
4726 }
4727}