Skip to main content

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