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
10pub mod breakpoint;
11mod courier;
12mod plugin_host;
13pub mod scan;
14pub use breakpoint::Breakpoints;
15pub use plugin_host::{LazyTrigger, PluginHost};
16
17pub mod status;
18
19/// Re-exported from [`escriba_mode`], which now OWNS the operator-pending FSM.
20///
21/// It moved down in 0.1.68 because it only ever needed `escriba-core` +
22/// `zenmai`; keeping it here meant a consumer had to depend on the entire
23/// editor to compose `d` with `w`. This re-export is deliberate rather than a
24/// deprecation shim: `escriba_runtime::OpState` is the spelling every existing
25/// consumer and test uses, and a path change would be a breaking one for no
26/// gain.
27pub use escriba_mode::{OpState, OperatorPending};
28
29/// What one key meant to the operator-pending object layer.
30enum ObjectKey {
31    /// Swallowed — the key began an object and nothing runs yet.
32    Consumed,
33    /// The object is complete; run this.
34    Compose(Action),
35}
36pub use status::{PromptKind, StatusModel};
37
38use std::collections::HashMap;
39
40use awase::KeyRepeatGate;
41use escriba_buffer::BufferSet;
42use escriba_buffer::TextRev;
43use escriba_command::CommandRegistry;
44use escriba_core::{
45    Action, Anchored, Bound, BufferId, Cursors, Damage, Edit, EditGen, HighlightEffect, InsertAt,
46    JumpList, Mode, Motion, Operator, Position, Range, Register, RegisterKind, TextEffect,
47    WindowId,
48};
49use escriba_input::{InputOutcome, translate_app_event};
50use escriba_keymap::{Key, Keymap};
51use escriba_madoguchi::{Negai, Outcome};
52use escriba_mode::ModalState;
53use escriba_search::{Direction as SearchDirection, MatchCount, SearchState};
54use escriba_ui::chrome::{ChromePalette, FleetTheme};
55use escriba_ui::splash::Splash;
56use escriba_ui::{Layout, Viewport, Window};
57use escriba_vm::{EditorSnapshot, EscribaHost, EscribaVm, VmError};
58use madori::AppEvent;
59use std::time::Instant;
60
61/// Full editor state — the single Rust value the binary hands to the
62/// renderer each frame.
63pub struct EditorState {
64    pub buffers: BufferSet,
65    pub modal: ModalState,
66    /// Search session — the committed pattern, its matches, the live `/`
67    /// prompt and history. Owns no buffer or cursor; it answers questions
68    /// about text and this runtime applies the answers.
69    pub search: SearchState,
70    pub keymap: Keymap,
71    pub commands: CommandRegistry,
72    pub layout: Layout,
73    pub active: BufferId,
74    /// The single typed home for cursor state. Phase-1 holds one primary
75    /// [`Position`]; reads go through [`Self::cursor`], writes through
76    /// [`Self::set_cursor`] → [`Cursors::set_primary`]. There is no loose
77    /// `Position` field beside an unused multi-caret type to desync.
78    cursors: Cursors,
79    pub quit_requested: bool,
80    /// Messages surfaced to the user (status line / `:messages`) — the
81    /// sink for the tatara-lisp `(message …)` effect and other feedback.
82    pub messages: Vec<String>,
83    /// Which match the cursor last landed on (0-based) — the `[3/17]`
84    /// numerator, ANCHORED to the text revision it was computed against.
85    ///
86    /// The anchor is what removes the manual invalidation this field used to
87    /// need. An ordinal indexes a match set; when the text changes the set
88    /// changes underneath it and the number silently means something else.
89    /// Reading through `Anchored::get(current_rev)` makes that a `None`, so
90    /// forgetting to clear is no longer a thing that can be forgotten.
91    search_at: Option<Anchored<usize, TextRev>>,
92    /// The last text change, for `.`.
93    ///
94    /// An action plus whatever was typed while it held Insert open. Both
95    /// halves are needed: `cw` alone is not a change, it is the FIRST HALF of
96    /// one — the text that followed is the rest, and replaying without it
97    /// would delete a word and leave the buffer in Insert.
98    last_change: Option<LastChange>,
99    /// True while an insert session belonging to `last_change` is open, so
100    /// typed characters are appended to it. Cleared on leaving Insert.
101    recording_insert: bool,
102
103    /// Where the cursor was before each far jump — `<C-o>` / `<C-i>`.
104    /// Search commits, `n`/`N` and `*`/`#` all record into it, which is what
105    /// makes a search a place you can come back from.
106    pub jumps: JumpList,
107    /// Generic editor option store (name → value). Written by the
108    /// tatara-lisp `(set-option …)` effect and the declarative
109    /// `defoption` apply path; typed accessors layer on top later.
110    pub options: HashMap<String, String>,
111    /// Cached embedded tatara-lisp runtime, built lazily on first
112    /// `run_lisp`. Caching avoids re-installing the ~175-definition full
113    /// stdlib on every call; the interpreter's top-level env also
114    /// persists across calls, giving REPL-like session semantics (an
115    /// earlier `(define …)` is visible to a later `run_lisp`).
116    lisp_vm: Option<EscribaVm>,
117    /// Keys accumulated for an in-progress multi-key sequence — e.g.
118    /// holding `[,, f]` while waiting for the final key of
119    /// `<leader>ff`. Empty when not mid-sequence. Lives on
120    /// `EditorState` (not `ModalState`) so `escriba-mode` needn't
121    /// depend on `escriba-keymap`'s `Key`.
122    pub pending_keys: Vec<Key>,
123    /// Per-key debouncer for OS key-repeat storms. Holding `j`/`l` makes
124    /// the windowing system deliver one `KeyDown` per repeat tick
125    /// (~30-50ms); without a gate those flood the motion path and thrash
126    /// the viewport. The gate lets ONE event per `min_interval` (80ms
127    /// default — ~12 intentional taps/sec still pass) reach the editor in
128    /// the navigation modes. The fleet primitive (`awase::KeyRepeatGate`,
129    /// the same one mado uses) is reused — not reinvented.
130    repeat_gate: KeyRepeatGate<Key>,
131    /// Runtime lazy-activation host for USER plugin caixas (the bundled
132    /// default catalog is applied eagerly at boot, not through here).
133    /// A command / filetype-open / event fires the matching plugins'
134    /// entries through the escriba-lisp apply paths. See [`PluginHost`].
135    pub plugin_host: PluginHost,
136    /// The unnamed register — the home for text an operator yanks or
137    /// deletes (`Operator::leaves_register`). `None` until the first
138    /// register-leaving operator runs. Phase-1 holds the single unnamed
139    /// register; named registers (`"ay`) layer on later.
140    ///
141    /// Typed as a [`Register`], not a `String`: the put has to know whether
142    /// the text was captured CHARWISE (`dw`) or LINEWISE (`dd`), and the
143    /// capture is the only place that knows. A `String` register makes `p`
144    /// after `dd` guess, and the only guess available is the wrong one —
145    /// splicing a whole line into the middle of another.
146    register: Option<Register>,
147    /// The operator-pending FSM (`d`/`c`/`y` then a motion → `dw`/`c$`/`y0`),
148    /// standing on the fleet `zenmai` Mealy-machine primitive. Every dispatched
149    /// action passes through it; only an operator-then-motion pair is rewritten
150    /// into an [`Action::ApplyOperator`].
151    op_pending: zenmai::Stateful<OperatorPending>,
152    /// Operator-pending OBJECT selection, held at the KEY layer.
153    ///
154    /// `Some(around)` means `d` + `i`/`a` have been pressed and the NEXT key
155    /// names the object. It lives here rather than in the operator FSM
156    /// because the FSM sees `Action`s and this decision needs the KEY: `a`
157    /// and every bracket are unbound in Normal, so they all arrive as
158    /// `Action::Pending` with the character already discarded. vim has a
159    /// whole operator-pending keymap for the same reason.
160    pending_object: Option<bool>,
161    /// `f`/`F`/`t`/`T` was pressed and the editor is waiting for the character
162    /// to search for. Like [`Self::pending_object`] this is a KEY-layer
163    /// concern: the character never reaches the keymap, so it cannot be a
164    /// binding, and it must be claimed before the sequence stepper or `f` then
165    /// `f` would resolve as the bound `ff` sequence.
166    pending_find: Option<FindSpec>,
167    /// `r` was pressed and the editor is waiting for the replacement
168    /// character. Same key-layer shape as [`Self::pending_find`], and needed
169    /// for the same reason: `rw` must not read as `r` then *move a word*, and
170    /// `rr` must reach the operand branch rather than resolve as a sequence.
171    pending_replace: bool,
172    /// The last resolved character search — what `;` and `,` repeat.
173    last_find: Option<FindSpec>,
174    /// `m`, `` ` `` or `'` was pressed and the editor is waiting for the mark
175    /// letter. Same key-layer shape as [`Self::pending_find`].
176    pending_mark: Option<MarkKey>,
177    /// `m{a-z}` → position. Buffer-agnostic today, which is honest and
178    /// limited: vim's `a-z` marks are per-buffer and `A-Z` are global, and a
179    /// single map is `a-z`-shaped. Jumping to a mark set in another buffer
180    /// would land at that position in THIS one, so only `a-z` are accepted.
181    marks: HashMap<char, Position>,
182    /// Monotonic refresh-generation stamp — the root of the sealed refresh
183    /// tree (`theory/ESCRIBA.md` §Refresh-Seal). Bumped on every applied
184    /// action + resize; the renderer gates on it so an idle frame does zero
185    /// re-highlight / re-shape, and a stale frame is unreachable.
186    edit_gen: EditGen,
187    /// The accumulated dirty region since the renderer last drained it (M1).
188    /// Only ever widened via [`Damage::join`] at the mutation funnel, so it
189    /// always covers the changed region (`Damage ⊇ changed`); the renderer
190    /// drains it with [`take_damage`](Self::take_damage) to scope its work.
191    damage: Damage,
192    /// The theme every face paints with.
193    ///
194    /// ONE owner. Before this, `(deftheme :preset …)` parsed, validated,
195    /// resolved to a real `FleetTheme` — and then nothing consumed it,
196    /// because each renderer called `ChromePalette::prescribed()` at every
197    /// paint site. The declaration was honoured on paper only. Holding it
198    /// here means a face reads the operator's theme the same way it reads
199    /// the cursor: from the state, per frame.
200    theme: FleetTheme,
201    /// `theme` resolved to concrete colours — cached because it is a plain
202    /// `Copy` struct read many times per frame, and re-derived only in
203    /// [`set_theme`](Self::set_theme), so the two cannot disagree.
204    chrome: ChromePalette,
205    /// How deep the current command dispatch is nested.
206    ///
207    /// `Negai::RunCommand` lets a command invoke a command, which is useful
208    /// and which can also recurse forever. The budget makes the runaway
209    /// bounded and REPORTED rather than a stack overflow — the difference
210    /// between a typed refusal and the editor dying under the operator.
211    dispatch_depth: u8,
212    /// Every live result list — diagnostics, hunks, grep hits, TODOs.
213    ///
214    /// Public so a producer outside the runtime can publish into it once the
215    /// courier lands; today the only producer is the marker scan.
216    pub results: escriba_shirube::ListRegistry,
217    /// The open picker, if any.
218    ///
219    /// `Option<Picker>` on the state, exactly like `splash` — deliberately
220    /// NOT a `Mode` variant. A mode is a state keys are interpreted IN; this
221    /// is a surface that OWNS keys while it is up, which is a different
222    /// thing and composes differently with the keymap.
223    picker: Option<escriba_ui::picker::Picker>,
224    /// The git-index generation. See [`world`](Self::world) — every axis the
225    /// world can move is emitted unconditionally, so a producer anchoring on
226    /// one is not born permanently stale.
227    index_rev: escriba_shirube::IndexRev,
228    /// The language-server generation — bumped when a server restarts.
229    lsp_gen: escriba_shirube::SessionGen,
230    /// The filesystem-scan generation — bumped when a surface a scan feeds
231    /// opens or closes, which is what supersedes an in-flight scan.
232    scan_gen: escriba_shirube::SessionGen,
233    /// Work handed off the editor thread. Inert until the composition root
234    /// hires a crew, so every existing `EditorState::new*` call site is
235    /// unchanged and the editor is fully usable with no runners at all.
236    courier: courier::Courier,
237    /// Which findings list the open picker is a VIEW of, if any —
238    /// `(workspace, list)`. Set when a picker opens over a live producer,
239    /// cleared whenever the picker closes.
240    picker_projects: Option<(bool, Option<String>)>,
241    /// Extension → language facts, populated from `(defmode …)`.
242    ///
243    /// The consumer `:commentstring` never had. Public so the binary's apply
244    /// pass can fill it the way it fills the keymap and the option store.
245    pub filetypes: escriba_core::FiletypeTable,
246    /// The start screen, while it is up.
247    ///
248    /// `Some` only between boot and the first keypress, and only when the
249    /// editor opened with no file. It is deliberately NOT a `Mode`: a mode
250    /// is a state keys are interpreted *in*, and the splash interprets
251    /// exactly one key before it is gone. Modelling it as `Option<Splash>`
252    /// keeps the modal state machine's variant set — and every exhaustive
253    /// match over it — untouched.
254    splash: Option<Splash>,
255    /// What a language server last said one buffer's tokens MEAN.
256    ///
257    /// One slot, not a per-buffer map, and that is the honest shape of what
258    /// produces it: the diagnostics errand runs per OPEN, so at any moment
259    /// there is one buffer whose tokens are both present and fresh. A map
260    /// would model a fan-out that has no producer.
261    ///
262    /// Sealed, and read through [`semantic_spans`](Self::semantic_spans) —
263    /// never directly — for the same reason a `ResultList` is: a colour
264    /// derived from text the operator has since edited is *confidently wrong*,
265    /// which is worse than absent. A stale read is an empty read and the face
266    /// falls back to its own lexer.
267    semantic: Option<SemanticPaint>,
268    /// Where the operator asked the debugger to stop.
269    ///
270    /// Its own field rather than a [`escriba_shirube::ResultList`], and the
271    /// difference is the whole reason [`Breakpoints`] exists as a type: every
272    /// result list is ANCHORED and a stale read is an empty read, so a
273    /// breakpoint published as a finding would vanish on the next keystroke —
274    /// and `publish` also FOCUSES the list it replaces, so `]d` would start
275    /// walking breakpoints. See [`crate::breakpoint`] for the full argument
276    /// and for what the line-number key does not yet survive.
277    breakpoints: Breakpoints,
278}
279
280/// Semantic tokens for one buffer, sealed with the world they describe.
281///
282/// A twin of [`escriba_shirube::ResultList`] rather than a use of it: that
283/// type's payload is `Vec<Finding>`, and a token is deliberately not a finding
284/// (see [`escriba_madoguchi::SemanticSpan`]). What IS shared is the part that
285/// matters — the [`Anchor`](escriba_shirube::Anchor) and the rule that a stale
286/// read yields nothing.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct SemanticPaint {
289    buffer: BufferId,
290    spans: Vec<escriba_madoguchi::SemanticSpan>,
291    anchor: escriba_shirube::Anchor,
292}
293
294impl SemanticPaint {
295    #[must_use]
296    pub const fn new(
297        buffer: BufferId,
298        spans: Vec<escriba_madoguchi::SemanticSpan>,
299        anchor: escriba_shirube::Anchor,
300    ) -> Self {
301        Self {
302            buffer,
303            spans,
304            anchor,
305        }
306    }
307
308    /// The spans, IF they describe `buffer` AND are still fresh against
309    /// `world`. Anything else is empty.
310    #[must_use]
311    pub fn fresh(
312        &self,
313        world: &escriba_shirube::Anchor,
314        buffer: BufferId,
315    ) -> &[escriba_madoguchi::SemanticSpan] {
316        if self.buffer == buffer && self.anchor.is_fresh(world) {
317            &self.spans
318        } else {
319            &[]
320        }
321    }
322}
323
324/// What the start screen did with a keypress.
325///
326/// Total, and matched exhaustively at its one call site, so a future
327/// outcome (a menu that opens a submenu, say) is a compile error rather
328/// than a key that silently falls through to the buffer.
329enum SplashKey {
330    /// No start screen is up — the key is the buffer's.
331    NotShowing,
332    /// The key selected a menu entry; run this.
333    Ran(Action),
334    /// The screen is gone and the key was not a menu key, so it still
335    /// means whatever it normally means. Anything else would make the
336    /// first keystroke after boot vanish.
337    Dismissed,
338}
339
340// ─── The counter, and the one place slips become mutations ───────────────
341
342/// `EditorState` read through the counter.
343///
344/// Borrowed, never copied: building it is free, so a command dispatch does
345/// not pay for a snapshot of the buffers.
346pub struct EditorWindow<'a> {
347    state: &'a EditorState,
348}
349
350impl escriba_madoguchi::CursorView for EditorWindow<'_> {
351    fn position(&self) -> Position {
352        self.state.cursor()
353    }
354    fn mode(&self) -> Mode {
355        self.state.modal.mode()
356    }
357}
358
359impl escriba_madoguchi::SyntaxView for EditorWindow<'_> {
360    fn filetype(&self) -> Option<&escriba_core::Filetype> {
361        let path = self.state.buffers.get(self.state.active)?.path.as_deref()?;
362        self.state.filetypes.resolve(path)
363    }
364}
365
366impl escriba_madoguchi::SearchView for EditorWindow<'_> {
367    fn pattern(&self) -> Option<&str> {
368        self.state.search.committed_pattern()
369    }
370    fn match_count(&self) -> Option<usize> {
371        // `None` means "nothing committed", which is not the same as zero
372        // matches — a distinction the status line already makes and that a
373        // handler must not have to re-derive.
374        self.state
375            .search
376            .committed_pattern()
377            .map(|_| self.state.search.match_count())
378    }
379    fn is_prompting(&self) -> bool {
380        self.state.search.is_prompting()
381    }
382}
383
384impl escriba_madoguchi::Snapshot for EditorWindow<'_> {
385    fn active(&self) -> Option<&dyn escriba_madoguchi::BufferView> {
386        self.buffer(self.state.active)
387    }
388    fn buffer(&self, id: BufferId) -> Option<&dyn escriba_madoguchi::BufferView> {
389        self.state
390            .buffers
391            .get(id)
392            .map(|b| b as &dyn escriba_madoguchi::BufferView)
393    }
394    fn buffer_ids(&self) -> Vec<BufferId> {
395        self.state.buffers.ids()
396    }
397    fn cursor(&self) -> &dyn escriba_madoguchi::CursorView {
398        self
399    }
400    fn option(&self, name: &str) -> Option<&str> {
401        self.state.options.get(name).map(String::as_str)
402    }
403    fn search(&self) -> &dyn escriba_madoguchi::SearchView {
404        self
405    }
406    fn syntax(&self) -> &dyn escriba_madoguchi::SyntaxView {
407        self
408    }
409}
410
411impl EditorState {
412    /// A read-only window onto this editor.
413    #[must_use]
414    pub fn window(&self) -> EditorWindow<'_> {
415        EditorWindow { state: self }
416    }
417
418    /// Honour an [`Outcome`] — the ONLY place slips become mutations.
419    ///
420    /// Every `&mut self` in the dispatch path lives here. A command cannot
421    /// reach editor state, so if the editor ends up in a state nobody
422    /// designed, this function is where it happened; that narrowing is the
423    /// whole return on the seam.
424    ///
425    /// A failed outcome's slips are DROPPED rather than half-applied: a
426    /// handler that reported failure has no business also mutating, and
427    /// applying part of what it asked for is how an editor reaches a state
428    /// nobody designed.
429    pub fn interpret(&mut self, outcome: Outcome) {
430        if let Some(m) = outcome.verdict.message() {
431            self.messages.push(m.to_string());
432            self.damage = self.damage.join(Damage::Viewport);
433            self.bump_gen();
434        }
435        if outcome.verdict.is_failure() {
436            return;
437        }
438        for slip in outcome.slips {
439            self.honour(slip);
440        }
441    }
442
443    /// Lower an [`Action`] to slips, when it has an exact slip equivalent.
444    ///
445    /// `None` means "editor mechanics" — 23 of the 30 variants are prompt
446    /// editing, the operator-pending FSM, motion resolution, the jumplist,
447    /// the dot register. Those are the KEYMAP's vocabulary, not the AUTHORED
448    /// one, and forcing them into `Negai` would put `DeleteToLineStart` and
449    /// `SearchPreviewStep` in front of every plugin author and make the
450    /// capability question meaningless (what capability does a caret move
451    /// read?). One type serving two vocabularies is the mistake this avoids.
452    ///
453    /// The plan's M3 predicate was "apply_resolved contains zero `self.`
454    /// mutations", which would have forced exactly that. Amended: the
455    /// invariant worth having is ONE IMPLEMENTATION PER MUTATION, not one
456    /// vocabulary. See docs/backlog-plan.md §V Phase 1.
457    fn lower(action: &Action, active: BufferId) -> Option<Vec<Negai>> {
458        Some(match action {
459            Action::Quit => vec![Negai::Quit],
460            Action::ClearSearchHighlight => vec![Negai::ClearSearchHighlight],
461            Action::Save => vec![Negai::Save { buffer: active }],
462            Action::Undo => vec![Negai::Undo { buffer: active }],
463            Action::Redo => vec![Negai::Redo { buffer: active }],
464            // `apply_edit` was a STUB that did nothing, so this action was a
465            // silent no-op while `Negai::Edit` applied for real. Lowering it
466            // makes keymap-originated edits work for the first time — and
467            // nothing binds it today, so the duplication goes away at zero
468            // risk.
469            Action::Edit(edit) => vec![Negai::Edit {
470                buffer: active,
471                edit: edit.clone(),
472            }],
473            _ => return None,
474        })
475    }
476
477    /// What the world currently is, for freshness.
478    ///
479    /// One text axis per open buffer. A list sealed against this is fresh
480    /// exactly while the buffers it depends on are unchanged — and a buffer
481    /// that has since CLOSED drops out, which makes lists about it stale
482    /// rather than silently kept.
483    #[must_use]
484    pub fn world(&self) -> escriba_shirube::Anchor {
485        let mut a = escriba_shirube::Anchor::new();
486        for id in self.buffers.ids() {
487            if let Some(b) = self.buffers.get(id) {
488                a = a.on(escriba_shirube::Axis::Text(id, b.text_rev()));
489            }
490        }
491        // Every axis the world can move, ALWAYS present — not only the ones
492        // some producer happens to use today.
493        //
494        // `Anchor::is_fresh` treats an ABSENT axis as stale, deliberately:
495        // unknowable is not unchanged. The consequence, unnoticed until a
496        // recon pass went looking, is that a list anchored on an axis this
497        // function never emits is born PERMANENTLY stale — `]c` would answer
498        // "that list is out of date" forever, and nothing would say why. The
499        // two-axis model was built for git hunks and then only ever fed one
500        // axis.
501        //
502        // Emitting them unconditionally means a producer can anchor on any
503        // axis and get an honest answer. A counter that never moves reads as
504        // "unchanged", which is exactly right for a plane escriba does not
505        // track yet.
506        a = a.on(escriba_shirube::Axis::Index(self.index_rev));
507        // Both session kinds, unconditionally, for the reason above: a
508        // producer anchoring on either must get an honest answer rather than
509        // permanent staleness. They are separate axes because they move
510        // independently — a picker closing must not invalidate diagnostics.
511        a = a.on(escriba_shirube::Axis::Session(
512            escriba_shirube::SessionKind::Lsp,
513            self.lsp_gen,
514        ));
515        a.on(escriba_shirube::Axis::Session(
516            escriba_shirube::SessionKind::Scan,
517            self.scan_gen,
518        ))
519    }
520
521    /// Where the cursor is, WITH the buffer it is in.
522    ///
523    /// Every jumplist push goes through this. A bare `Position` is what let
524    /// `<C-o>` return to the right line in the wrong file.
525    #[must_use]
526    pub fn spot(&self) -> escriba_core::Spot {
527        escriba_core::Spot::new(self.active, self.cursor())
528    }
529
530    /// Move to a `Spot`, switching buffer if it names another one.
531    ///
532    /// The read half of [`spot`](Self::spot). `<C-o>` and `<C-i>` both land
533    /// here so neither can forget the buffer.
534    fn goto_spot(&mut self, s: escriba_core::Spot) {
535        if s.buffer != self.active && self.buffers.get(s.buffer).is_some() {
536            self.active = s.buffer;
537        }
538        let clamped = self
539            .buffers
540            .get(self.active)
541            .map_or(s.pos, |b| b.clamp(s.pos));
542        self.set_cursor(clamped);
543    }
544
545    /// Advance the git-index generation — every list anchored on
546    /// `Axis::Index` goes stale.
547    ///
548    /// Not called yet; a git layer calls it after a stage/reset. Present so
549    /// the axis is WIRED rather than declared, because an axis nothing can
550    /// move is indistinguishable from an axis that does not exist.
551    pub fn bump_index_rev(&mut self) {
552        self.index_rev = escriba_shirube::IndexRev(self.index_rev.0.wrapping_add(1));
553    }
554
555    /// Advance the language-server generation — a server restarted, so every
556    /// diagnostic it produced describes a conversation that no longer exists.
557    /// See [`bump_index_rev`](Self::bump_index_rev).
558    pub fn bump_lsp_gen(&mut self) {
559        self.lsp_gen = escriba_shirube::SessionGen(self.lsp_gen.0.wrapping_add(1));
560    }
561
562    /// Advance the filesystem-scan generation.
563    ///
564    /// This is what makes a superseded scan's rows stale. A scan runs on its
565    /// own thread and cannot be stopped mid-walk; bumping this means its
566    /// remaining batches are *ignored on arrival*, which is a reply filter, not
567    /// cancellation — the thread keeps going until it notices. Say the weaker
568    /// thing, because the stronger one is not true.
569    ///
570    /// Deliberately separate from [`bump_lsp_gen`](Self::bump_lsp_gen): they
571    /// move for unrelated reasons, and when they shared one axis, closing a
572    /// picker staled every diagnostic in the gutter.
573    pub fn bump_scan_gen(&mut self) {
574        self.scan_gen = escriba_shirube::SessionGen(self.scan_gen.0.wrapping_add(1));
575    }
576
577    /// Install the courier's runners. Called once, by the composition root.
578    pub fn hire(&mut self, crew: escriba_madoguchi::errand::Crew) {
579        self.courier.hire(crew);
580    }
581
582    /// Diagnose every buffer that is already open.
583    ///
584    /// **Call this LAST, after the plan has been applied.** It has two
585    /// prerequisites and they are in different places: the courier needs its
586    /// crew ([`hire`](Self::hire)), and `language_of` needs the filetype table,
587    /// which `apply_plan_to_filetypes` fills from the catalog's `defmode`
588    /// forms. Both are set up by the composition root, in that order, and this
589    /// belongs after both.
590    ///
591    /// # Why this exists at all
592    ///
593    /// `ask_for_diagnostics` had exactly one caller — `Negai::OpenPath`, the
594    /// path a file takes when opened from INSIDE the editor. A file named on
595    /// the command line never travels it: the composition root reads the file
596    /// and hands the buffer to `new_with_buffer`. So `escriba main.rs` opened a
597    /// buffer that was never diagnosed while `:e main.rs` on the same file was,
598    /// and nothing on screen distinguished them — a file with errors simply
599    /// looked clean.
600    ///
601    /// # The ordering trap, which cost an hour
602    ///
603    /// The first fix hung this off `hire`, reasoning that hiring is the moment
604    /// diagnosis becomes possible. It is not: hiring makes the courier able to
605    /// RUN, but the filetype table is still empty there, so every errand went
606    /// out carrying `language: None`. The runner then fell back to its own
607    /// two-extension `language_of`, which does not know `.b`, and declined —
608    /// **silently, and correctly**, because "most files have no server" is the
609    /// common case and saying so on every open would be noise.
610    ///
611    /// Nothing was broken enough to report. `blue check` said `diag.b:8:3`
612    /// while the gutter stayed empty, and the only visible difference between
613    /// "no server for this language" and "the language was not resolved yet"
614    /// was a `None` in a struct field. **A prerequisite that is satisfied
615    /// somewhere else, later, is not a prerequisite the caller can see.**
616    pub fn diagnose_open_buffers(&mut self) {
617        // Every buffer, not just the active one: a session restored with a
618        // split, or a future `escriba a.rs b.rs`, opens more than one.
619        for id in self.buffers.ids() {
620            self.ask_for_diagnostics(id);
621        }
622    }
623
624    /// What an errand of this class depends on — the ONE place a courier
625    /// anchor is minted.
626    ///
627    /// A total match, so a new [`Freight`](escriba_madoguchi::errand::Freight)
628    /// variant does not compile until somebody decides what makes its results
629    /// stale. That is the point: the failure this prevents is not a wrong
630    /// answer, it is an errand class shipping with no freshness rule at all and
631    /// nobody noticing, because "no rule" reads at runtime as "always fresh".
632    ///
633    /// The return type forbids the empty anchor by construction — see
634    /// [`NonEmptyAnchor`](escriba_shirube::NonEmptyAnchor).
635    fn seal(
636        &self,
637        freight: &escriba_madoguchi::errand::Freight,
638    ) -> escriba_shirube::NonEmptyAnchor {
639        use escriba_madoguchi::errand::Freight;
640        use escriba_shirube::{Axis, NonEmptyAnchor, SessionKind};
641        match freight {
642            // A scan reads the filesystem, which no axis tracks, so text
643            // revisions are irrelevant to it — anchoring one on the buffers
644            // would make it die on the next keystroke for no reason. What DOES
645            // supersede it is the surface it feeds opening or closing, which is
646            // exactly what `scan_gen` counts.
647            Freight::Scan { .. } => {
648                NonEmptyAnchor::on(Axis::Session(SessionKind::Scan, self.scan_gen))
649            }
650            // Diagnostics describe ONE buffer at one revision, from one server
651            // session. Narrow on purpose: anchoring on the whole world would
652            // mean an edit in an unrelated buffer discards them.
653            Freight::Diagnostics { buffer, .. } => {
654                let rev = self.buffers.get(*buffer).map_or_else(
655                    escriba_buffer::TextRev::default,
656                    escriba_buffer::Buffer::text_rev,
657                );
658                NonEmptyAnchor::on(Axis::Text(*buffer, rev))
659                    .and(Axis::Session(SessionKind::Lsp, self.lsp_gen))
660            }
661            // A formatter reply REWRITES text. It must be judged against the
662            // revision it read and nothing else — the whole hazard is applying
663            // one to a buffer the operator kept typing into.
664            Freight::Format { path, .. } => match self.buffers.find_by_path(path) {
665                Some(id) => {
666                    let rev = self.buffers.get(id).map_or_else(
667                        escriba_buffer::TextRev::default,
668                        escriba_buffer::Buffer::text_rev,
669                    );
670                    NonEmptyAnchor::on(Axis::Text(id, rev))
671                }
672                // No open buffer for that path: seal on the LSP session so the
673                // reply is judged against SOMETHING. Never an empty anchor —
674                // that would be fresh forever, which is the whole hazard.
675                None => NonEmptyAnchor::on(Axis::Session(SessionKind::Lsp, self.lsp_gen)),
676            },
677        }
678    }
679
680    /// How many courier replies one tick may apply.
681    ///
682    /// Bounded so a chatty runner cannot hold a frame open. The remainder is
683    /// not dropped — it lands on the next tick.
684    const DELIVER_BUDGET: usize = 64;
685
686    /// Apply whatever the courier has delivered since the last tick.
687    ///
688    /// Must run BEFORE input translation: a redraw event maps to
689    /// `InputOutcome::None`, so a drain hung off the input path would never see
690    /// a tick that carried no keystroke — which is every tick during a scan.
691    pub fn deliver(&mut self) {
692        let slips = self.courier.drain(Self::DELIVER_BUDGET);
693        if slips.is_empty() {
694            return;
695        }
696        for slip in slips {
697            self.honour_one(slip);
698        }
699        // Something landed, so the screen is out of date.
700        self.bump_gen();
701    }
702
703    /// Move the cursor to the next/previous finding in `list`.
704    ///
705    /// Reports the wrap, because `n`/`N` do and a reader losing their place
706    /// in a long file is the same problem either way.
707    fn walk_list(&mut self, list: &str, forward: bool) {
708        let world = self.world();
709        let Some(result) = self.results.get(list) else {
710            let mut m = String::from("no list named ");
711            m.push_str(list);
712            self.messages.push(m);
713            return;
714        };
715        if result.is_stale(&world) {
716            self.messages
717                .push("that list is out of date — run it again".to_string());
718            return;
719        }
720        let here = (Some(self.active), self.cursor().line);
721        let Some(found) = result.step(&world, here, forward, escriba_shirube::Bound::Exclusive)
722        else {
723            let mut m = String::from("no entries in ");
724            m.push_str(list);
725            self.messages.push(m);
726            return;
727        };
728        let site = found.site.clone();
729        let msg = found.message.clone();
730        self.jump_to_site(&site);
731        self.messages.push(msg);
732    }
733
734    /// Move the cursor to a located finding's SITE — the one operation that
735    /// cannot drop the buffer half of a location.
736    ///
737    /// A `Site` is `(buffer, range)`. Every jumper before this re-derived the
738    /// move itself and clamped against `self.active`, so a finding in another
739    /// file landed on the right LINE in the WRONG file. `on_line` and
740    /// `worst_on_line` already filter by buffer, so the gutter and the walker
741    /// disagreed — latent only because the first producer scanned one buffer.
742    ///
743    /// Every future producer (diagnostics, hunks, grep hits, test failures)
744    /// is cross-file by nature, which is why this is a shared operation
745    /// rather than a fix at the one call site that has it wrong today.
746    ///
747    /// Always a FAR jump: it pushes the jumplist, so `<C-o>` returns from a
748    /// `]t` exactly as it returns from an `n`.
749    pub fn jump_to_site(&mut self, site: &escriba_shirube::Site) {
750        self.jumps.push(self.spot());
751        // Switch buffers FIRST — clamping against the wrong buffer is how the
752        // position gets silently mangled before anyone can notice.
753        if let Some(target) = site.buffer {
754            if target != self.active && self.buffers.get(target).is_some() {
755                self.active = target;
756                self.refollow_cursor();
757            }
758        }
759        let to = site.range.start;
760        let clamped = self.buffers.get(self.active).map_or(to, |b| b.clamp(to));
761        self.set_cursor(clamped);
762    }
763
764    /// Close a buffer, keeping "there is always an active buffer" true.
765    ///
766    /// The invariant is the whole reason this is not just
767    /// `self.buffers.close(id)`. `EditorState::active` is a `BufferId`, not
768    /// an `Option`, so a dangling active is not a degraded state — it is a
769    /// state where every read of the active buffer returns `None` and the
770    /// editor renders `<no buffer>` forever. Closing the last buffer opens a
771    /// scratch rather than emptying the set, which is what vim's `:bd` does
772    /// and what the type demands.
773    fn close_buffer(&mut self, id: BufferId) {
774        if self.buffers.close(id).is_none() {
775            self.messages.push("no such buffer".to_string());
776            return;
777        }
778        if self.active != id {
779            return;
780        }
781        // The active buffer went. Prefer the next one by id so repeated
782        // closes walk forward predictably rather than jumping around.
783        let next = self.buffers.ids().into_iter().find(|b| *b > id);
784        self.active = match next.or_else(|| self.buffers.ids().into_iter().next_back()) {
785            Some(b) => b,
786            None => self.buffers.scratch(""),
787        };
788        self.set_cursor(Position::ZERO);
789        if let Some(w) = self.layout.active_window_mut() {
790            w.buffer_id = self.active;
791        }
792    }
793
794    /// Move to the next or previous buffer, wrapping.
795    fn cycle_buffer(&mut self, forward: bool) {
796        let ids = self.buffers.ids();
797        if ids.len() < 2 {
798            self.messages.push("only one buffer".to_string());
799            return;
800        }
801        let at = ids.iter().position(|b| *b == self.active).unwrap_or(0);
802        let next = if forward {
803            (at + 1) % ids.len()
804        } else {
805            (at + ids.len() - 1) % ids.len()
806        };
807        self.active = ids[next];
808        self.set_cursor(Position::ZERO);
809        if let Some(w) = self.layout.active_window_mut() {
810            w.buffer_id = self.active;
811        }
812    }
813
814    /// Re-clamp the cursor and re-contain the viewport after a buffer
815    /// mutation.
816    ///
817    /// An undo can SHRINK the buffer under a cursor that was legal a moment
818    /// ago, leaving it out of bounds and its viewport scrolled past the end.
819    /// The Action executor has always done this (`self.set_cursor(self.cursor())`
820    /// after undo/redo/save); the M1 interpreter did NOT, so `u` re-followed
821    /// and `:undo` did not — two implementations of one operation, already
822    /// drifted within one milestone of being written. Naming it once is the
823    /// fix; lowering the Action arms onto the same slips is what keeps it
824    /// fixed.
825    fn refollow(&mut self) {
826        self.set_cursor(self.cursor());
827    }
828
829    /// Apply one slip and record what it damaged.
830    ///
831    /// The bookkeeping wrapper. The Action executor calls
832    /// [`honour_one`](Self::honour_one) directly because it does its own,
833    /// wider bookkeeping (the dot register, the S3 damage seal) around a
834    /// whole action.
835    fn honour(&mut self, slip: Negai) {
836        let touches_text = slip.touches_text();
837        self.honour_one(slip);
838        self.damage = self.damage.join(if touches_text {
839            Damage::Full
840        } else {
841            Damage::Viewport
842        });
843        self.bump_gen();
844    }
845
846    /// Apply one slip. THE single implementation of every mutation a slip
847    /// can ask for.
848    ///
849    /// Total over `Negai`: a new request variant is a compile error here
850    /// rather than a request silently ignored — the same failure Phase 0
851    /// removed one layer up.
852    fn honour_one(&mut self, slip: Negai) {
853        match slip {
854            Negai::Edit { buffer, edit } => {
855                if let Some(b) = self.buffers.get_mut(buffer) {
856                    let _ = b.apply(&edit);
857                }
858                self.refollow();
859            }
860            Negai::SetCursor { buffer, to } => {
861                // Clamping is the interpreter's job, exactly so that no
862                // handler has to re-implement it and get it wrong.
863                let clamped = self.buffers.get(buffer).map_or(to, |b| b.clamp(to));
864                self.set_cursor(clamped);
865            }
866            Negai::EnterMode(m) => self.modal.enter(m),
867            Negai::OpenPicker(source) => self.open_picker(source),
868            Negai::SplitWindow { stacked } => {
869                let axis = if stacked {
870                    escriba_ui::shikiri::Axis::Stacked
871                } else {
872                    escriba_ui::shikiri::Axis::SideBySide
873                };
874                self.layout.split_active(axis);
875                // The new pane is narrower/shorter than the old one, so the
876                // cursor can now be outside it. Every face re-reports its
877                // frame on the next draw, but the invariant must hold NOW —
878                // an operator who splits and immediately types should not be
879                // editing off-screen.
880                self.refollow_cursor();
881                self.damage = self.damage.join(Damage::Viewport);
882            }
883            Negai::CloseWindow => {
884                let id = self.layout.active();
885                if self.layout.close(id) {
886                    self.refollow_cursor();
887                    self.damage = self.damage.join(Damage::Viewport);
888                } else {
889                    // vim's E444, and the same refusal: the last window is
890                    // the editor. Closing it would mean "quit", which is a
891                    // different verb the operator did not type.
892                    self.messages
893                        .push("E444: Cannot close last window".to_string());
894                }
895            }
896            Negai::FocusDir { dx, dy } => {
897                use escriba_ui::Dir;
898                let dir = match (dx, dy) {
899                    (d, _) if d < 0 => Dir::Left,
900                    (d, _) if d > 0 => Dir::Right,
901                    (_, d) if d < 0 => Dir::Up,
902                    _ => Dir::Down,
903                };
904                if let Some(id) = self.layout.neighbour(dir) {
905                    self.layout.focus(id);
906                    // The window we moved to has its OWN buffer; the editor's
907                    // active buffer follows focus, or the next keystroke
908                    // would edit the file we just navigated away from.
909                    if let Some(w) = self.layout.active_window() {
910                        self.active = w.buffer_id;
911                    }
912                    self.refollow_cursor();
913                    self.damage = self.damage.join(Damage::Viewport);
914                }
915                // No neighbour is not an error — it is the edge of the
916                // layout, and vim says nothing there either.
917            }
918            Negai::GrepProject { pattern } => self.grep_project(&pattern),
919            Negai::FormatBuffer => self.ask_for_format(self.active),
920            Negai::ToggleBreakpoint => self.toggle_breakpoint(),
921            Negai::CycleBuffer { forward } => self.cycle_buffer(forward),
922            Negai::FocusBuffer(id) => {
923                if self.buffers.get(id).is_some() {
924                    self.active = id;
925                }
926            }
927            Negai::OpenPath(path) => match self.buffers.open(&path) {
928                Ok(id) => {
929                    self.active = id;
930                    self.ask_for_diagnostics(id);
931                }
932                Err(e) => self.messages.push(e.to_string()),
933            },
934            Negai::CloseBuffer(id) => self.close_buffer(id),
935            Negai::Save { buffer } => {
936                if let Some(b) = self.buffers.get_mut(buffer) {
937                    if let Err(e) = b.save() {
938                        self.messages.push(e.to_string());
939                    }
940                }
941                self.refollow();
942            }
943            Negai::Undo { buffer } => {
944                if let Some(b) = self.buffers.get_mut(buffer) {
945                    let _ = b.undo();
946                }
947                self.refollow();
948            }
949            Negai::Redo { buffer } => {
950                if let Some(b) = self.buffers.get_mut(buffer) {
951                    let _ = b.redo();
952                }
953                self.refollow();
954            }
955            Negai::Yank { text, kind, .. } => self.register = Some(Register::new(text, kind)),
956            Negai::ClearSearchHighlight => self.search.clear_highlight(),
957            Negai::SetOption { name, value } => {
958                self.options.insert(name, value);
959            }
960            Negai::InsertText(text) => self.insert_text(&text),
961            Negai::RunCommand { name, args } => self.run_command(&name, &args),
962            Negai::PublishFindings { list, findings } => {
963                let world = self.world();
964                self.results
965                    .publish(list, escriba_shirube::ResultList::new(findings, world));
966            }
967            // Sealed at `world()`, exactly like the `PublishFindings` above and
968            // for the same reason: an ON-TICK producer computed this against
969            // the world as it is right now. The off-tick path is the
970            // `ErrandReply` arm below, which must NOT reseal — see the note
971            // there.
972            Negai::PublishSemanticTokens { buffer, tokens } => {
973                let world = self.world();
974                self.semantic = Some(SemanticPaint::new(buffer, tokens, world));
975            }
976            // A reply from off the tick. Honour it only if the world it was
977            // computed against still holds.
978            //
979            // The drop is silent BY DESIGN, and this is the one place in the
980            // slip vocabulary where silence is right: a stale reply is not a
981            // failure anyone can act on. The operator kept typing, which is
982            // the correct thing to have done, and telling them "a diagnostic
983            // you never asked for was discarded" is noise. The producer
984            // re-runs against the new world; that is the whole contract.
985            Negai::ErrandReply { anchor, then } => {
986                if anchor.is_fresh(&self.world()) {
987                    match *then {
988                        // The reply's OWN anchor becomes the list's seal.
989                        //
990                        // Passing the gate and then re-sealing at `world()` —
991                        // which is what the direct `PublishFindings` arm does,
992                        // correctly, for an on-tick producer — is wrong for a
993                        // reply that crossed a thread. It widens a narrow claim
994                        // into a broad one: findings that depended on one
995                        // buffer would be stored as depending on every open
996                        // buffer, so an edit anywhere kills them. And it
997                        // upgrades an unearned claim into a durable one.
998                        //
999                        // Special-cased on this one payload because
1000                        // `ErrandReply` WRAPS a slip rather than putting an
1001                        // anchor field on `PublishFindings`; adding one there
1002                        // now would reopen exactly the hole the wrapper closed.
1003                        Negai::PublishFindings { list, findings } => {
1004                            self.results.publish(
1005                                list.clone(),
1006                                escriba_shirube::ResultList::new(findings, anchor),
1007                            );
1008                            self.refresh_projected_picker(&list);
1009                        }
1010                        // The second payload of one language-server
1011                        // conversation, and special-cased here for exactly the
1012                        // reason `PublishFindings` is: it must keep the
1013                        // reply's OWN anchor. Falling through to
1014                        // `honour_one` would reseal it at `world()` — passing
1015                        // the gate and then widening the claim, which turns
1016                        // "these colours describe buffer 3 at revision 7" into
1017                        // "these colours describe the world", so the next
1018                        // keystroke in ANY buffer would blank them and an
1019                        // unearned claim would have been made durable.
1020                        Negai::PublishSemanticTokens { buffer, tokens } => {
1021                            self.semantic = Some(SemanticPaint::new(buffer, tokens, anchor));
1022                        }
1023                        other => self.honour_one(other),
1024                    }
1025                }
1026            }
1027            Negai::WalkList { list, forward } => self.walk_list(&list, forward),
1028            Negai::Message(m) => self.messages.push(m),
1029            Negai::Quit => self.quit_requested = true,
1030            // Hand the freight to the courier, sealed against the world it
1031            // was dispatched in.
1032            //
1033            // The seal happens HERE and nowhere else. A handler named a class
1034            // of work; it did not — and could not — say what world that work
1035            // depends on, because a handler holds a read-only snapshot. If the
1036            // slip carried an anchor, any handler could mint one depending on
1037            // nothing, which is fresh forever, and the freshness gate would
1038            // stop meaning anything.
1039            Negai::Errand(freight) => {
1040                let anchor = self.seal(&freight);
1041                self.courier.send(*freight, anchor);
1042            }
1043            // Still unwired: the AwaitKey resume (M3). Announced, never
1044            // silently dropped — a slip that vanishes is the class Phase 0
1045            // sealed.
1046            Negai::AwaitKey { .. } => {
1047                self.messages
1048                    .push("deferred work is not wired yet".to_string());
1049            }
1050        }
1051    }
1052}
1053
1054/// Outcome of feeding one key to the multi-key pending-stroke loop.
1055enum SeqStep {
1056    /// Key consumed into an in-progress sequence; wait for the next.
1057    Pending,
1058    /// A full bound sequence resolved — run this action.
1059    Resolved(Action),
1060    /// Key is not part of any sequence; hand it to single-key dispatch.
1061    Passthrough,
1062}
1063
1064/// Keys whose HELD repeat is a viewport storm, and which the repeat gate
1065/// therefore exists to debounce.
1066///
1067/// This is an ALLOW-LIST, and it used to be the complement — an exception list
1068/// of "discrete" keys that grew three times (`n`/`N`/`*`/`#`, then `.`/`u`/
1069/// `<C-r>`, then `/`/`?`/`:`), each time because a key had been silently
1070/// swallowed and someone noticed. The third growth is the signal that the
1071/// default was backwards: almost every key in a modal editor is a discrete,
1072/// deliberate press, and only a handful are ones you HOLD.
1073///
1074/// Inverting it makes the failure mode safe. Forgetting to list a key here now
1075/// means it is ungated — one extra keypress honoured — instead of silently
1076/// dropped, and a dropped key is indistinguishable from a dead one.
1077///
1078/// Measured cost of the old direction: `/foo<CR>` then `/<CR>` (vim's
1079/// reuse-the-previous-pattern) lost the second `/` outright, because the gate
1080/// is keyed by KEY and the two presses fell inside one debounce window.
1081const fn is_repeat_storm_candidate(key: &Key) -> bool {
1082    matches!(
1083        key,
1084        // The four navigation keys a user actually holds down. `h`/`l` and
1085        // `j`/`k` flood the motion path and thrash the viewport; everything
1086        // else is pressed once and meant once.
1087        Key::Char('h')
1088            | Key::Char('j')
1089            | Key::Char('k')
1090            | Key::Char('l')
1091            | Key::Left
1092            | Key::Right
1093            | Key::Up
1094            | Key::Down
1095    )
1096}
1097
1098/// Turn a command failure into the sentence an operator should read.
1099///
1100/// The two failures mean genuinely different things and must not be reported
1101/// the same way:
1102///
1103/// - `:flurb` — the operator typed a name that does not exist. "command not
1104///   found" is exactly right; it says *you* made a typo.
1105/// - `<leader>ff` bound to `picker.files` — escriba's OWN shipped config
1106///   declares this, `--list-rc` counts it, and it is not built yet. Telling
1107///   the operator "command not found" blames them for a gap we shipped.
1108///
1109/// The discriminator is the dotted form. `:action` takes action SYMBOLS
1110/// (`picker.files`), never command names — that boundary is already pinned by
1111/// `action_naming_a_command_is_inert_not_recursive` in escriba-command. So a
1112/// dotted name that reached dispatch and resolved to nothing is a declared
1113/// capability with no implementation, which is precisely what the 85 entries
1114/// in `escriba/tests/action_resolution.rs` are.
1115fn describe_command_failure(name: &str, e: &escriba_command::CommandError) -> String {
1116    use escriba_command::CommandError as E;
1117    match e {
1118        // Already the right words — the registry knew it was declared.
1119        E::Unhandled(_) => e.to_string(),
1120        E::NotFound(n) if n.contains('.') => {
1121            let mut m = String::with_capacity(n.len() + 48);
1122            m.push('`');
1123            m.push_str(n);
1124            m.push_str("` is declared but not implemented yet");
1125            m
1126        }
1127        _ => {
1128            let _ = name;
1129            e.to_string()
1130        }
1131    }
1132}
1133
1134/// What committing the open search prompt did.
1135///
1136/// The two commit paths — bare `/` and operated `d/` — used to own private
1137/// copies of the whole sequence (read origin+skip, `accept`, three-arm
1138/// match, `commit_step_skipping`), and they drifted: the operated one
1139/// never reported the wrap, so `d/foo<CR>` that wrapped the file was
1140/// silent where `/foo<CR>` printed "search hit BOTTOM, continuing at TOP".
1141///
1142/// Total, and matched exhaustively at BOTH call sites, so a new outcome is
1143/// a compile error in two places rather than a case one path quietly
1144/// forgets. It does not make divergence impossible — the two paths
1145/// genuinely differ at the landing step — it makes FORGETTING A CASE
1146/// impossible, which is the failure that actually happened.
1147enum CommitOutcome {
1148    /// The prompt committed and a match was found.
1149    Landed {
1150        origin: usize,
1151        step: escriba_search::Step,
1152    },
1153    /// Committed, but nothing matched. E486 already reported.
1154    NotFound,
1155    /// Nothing typed and no previous pattern. E35 already reported.
1156    NoPrevious,
1157    /// No prompt was open.
1158    NoPrompt,
1159}
1160
1161/// A replayable text change.
1162#[derive(Debug, Clone)]
1163struct LastChange {
1164    /// The action that began the change.
1165    action: Action,
1166    /// How many times it ran.
1167    count: u32,
1168    /// Characters typed while the change held Insert mode open.
1169    inserted: String,
1170}
1171
1172impl EditorState {
1173    /// Build a fresh editor with one buffer (scratch or file-backed).
1174    pub fn new_with_buffer(initial: BufferSet, active: BufferId) -> Self {
1175        let window = Window {
1176            id: WindowId(1),
1177            buffer_id: active,
1178            viewport: Viewport {
1179                top_line: 0,
1180                left_column: 0,
1181                visible_lines: 40,
1182                visible_columns: 160,
1183            },
1184        };
1185        Self {
1186            buffers: initial,
1187            modal: ModalState::new(),
1188            search: SearchState::new(escriba_search::CaseMode::Smart),
1189            search_at: None,
1190            last_change: None,
1191            recording_insert: false,
1192            jumps: JumpList::new(),
1193            keymap: Keymap::default_vim(),
1194            commands: CommandRegistry::default_set(),
1195            layout: Layout::single(window),
1196            active,
1197            cursors: Cursors::single(Position::ZERO),
1198            quit_requested: false,
1199            register: None,
1200            op_pending: zenmai::Stateful::new(OpState::Resting),
1201            pending_object: None,
1202            pending_find: None,
1203            pending_replace: false,
1204            last_find: None,
1205            pending_mark: None,
1206            marks: HashMap::new(),
1207            messages: Vec::new(),
1208            options: HashMap::new(),
1209            lisp_vm: None,
1210            pending_keys: Vec::new(),
1211            repeat_gate: KeyRepeatGate::new(),
1212            plugin_host: PluginHost::default(),
1213            edit_gen: EditGen::default(),
1214            damage: Damage::None,
1215            // The FLEET default until an rc says otherwise — never a
1216            // hand-written theme name, so a fleet re-point lands for free.
1217            dispatch_depth: 0,
1218            filetypes: escriba_core::FiletypeTable::new(),
1219            results: escriba_shirube::ListRegistry::new(),
1220            picker: None,
1221            index_rev: escriba_shirube::IndexRev::default(),
1222            lsp_gen: escriba_shirube::SessionGen::default(),
1223            scan_gen: escriba_shirube::SessionGen::default(),
1224            courier: courier::Courier::inert(),
1225            picker_projects: None,
1226            theme: FleetTheme::prescribed_default(),
1227            chrome: ChromePalette::prescribed(),
1228            splash: None,
1229            semantic: None,
1230            breakpoints: Breakpoints::default(),
1231        }
1232    }
1233
1234    /// What a language server says `buffer`'s tokens mean, if that answer is
1235    /// still about this buffer at this revision.
1236    ///
1237    /// The ONLY reader — the field is private so a face cannot reach past the
1238    /// freshness check the way an earlier `results` reader could have. Empty is
1239    /// the honest answer for "no server", "not this buffer" and "you have typed
1240    /// since", and every one of them means the same thing to a renderer: paint
1241    /// with your own lexer.
1242    #[must_use]
1243    pub fn semantic_spans(&self, buffer: BufferId) -> &[escriba_madoguchi::SemanticSpan] {
1244        self.semantic
1245            .as_ref()
1246            .map_or(&[][..], |p| p.fresh(&self.world(), buffer))
1247    }
1248
1249    /// Everything the gutter has to say about one line of one buffer.
1250    ///
1251    /// ONE function, so the three faces cannot disagree about which planes
1252    /// the gutter reads. Before this each face called `worst_on_line` for
1253    /// itself, which was fine while there was one plane and is exactly how a
1254    /// second plane lands on two faces out of three — the divergence
1255    /// `escriba_ui::gutter` was extracted to stop, reappearing one layer up
1256    /// in the ARGUMENTS rather than in the composition.
1257    ///
1258    /// Takes `world` rather than computing it so a face that already holds
1259    /// one for the frame does not rebuild it per line.
1260    #[must_use]
1261    pub fn gutter_marks(
1262        &self,
1263        world: &escriba_shirube::Anchor,
1264        buffer: BufferId,
1265        line: u32,
1266    ) -> escriba_ui::gutter::GutterMarks {
1267        escriba_ui::gutter::GutterMarks::new(
1268            self.results.worst_on_line(world, buffer, line),
1269            self.breakpoints.is_set(buffer, line),
1270        )
1271    }
1272
1273    /// Where the operator asked the debugger to stop.
1274    ///
1275    /// Read-only: the only way to change it is [`Negai::ToggleBreakpoint`],
1276    /// so the refresh-generation bump that makes a face repaint cannot be
1277    /// forgotten by a caller that reached in and mutated the set.
1278    #[must_use]
1279    pub const fn breakpoints(&self) -> &Breakpoints {
1280        &self.breakpoints
1281    }
1282
1283    /// The theme this editor is set to.
1284    #[must_use]
1285    pub const fn theme(&self) -> FleetTheme {
1286        self.theme
1287    }
1288
1289    /// The colours every face paints with — read once per frame.
1290    #[must_use]
1291    pub const fn chrome(&self) -> ChromePalette {
1292        self.chrome
1293    }
1294
1295    /// Point the editor at a theme. The wiring that makes
1296    /// `(deftheme :preset …)` real.
1297    ///
1298    /// Bumps the refresh generation, because a theme change repaints
1299    /// everything: the GPU face caches its shaped buffer against that
1300    /// generation and would otherwise keep the old colours until an
1301    /// unrelated edit happened to invalidate it.
1302    pub fn set_theme(&mut self, theme: FleetTheme) {
1303        if self.theme == theme {
1304            return;
1305        }
1306        self.theme = theme;
1307        self.chrome = ChromePalette::for_theme(theme);
1308        self.damage = self.damage.join(Damage::Viewport);
1309        self.bump_gen();
1310    }
1311
1312    /// The start screen, if one is up. Renderers paint this INSTEAD of the
1313    /// buffer pane; `None` is the ordinary editor.
1314    #[must_use]
1315    pub fn splash(&self) -> Option<&Splash> {
1316        self.splash.as_ref()
1317    }
1318
1319    /// Raise the start screen. The binary calls this at boot when no file
1320    /// was named; an empty splash is refused so a face never has to render
1321    /// a blank screen over a perfectly good buffer.
1322    pub fn set_splash(&mut self, splash: Splash) {
1323        if splash.is_empty() {
1324            return;
1325        }
1326        self.splash = Some(splash);
1327        self.damage = self.damage.join(Damage::Viewport);
1328        self.bump_gen();
1329    }
1330
1331    /// Take the start screen down. Idempotent; bumps the refresh generation
1332    /// only when something actually changed, so dismissing twice does not
1333    /// cost a repaint.
1334    pub fn dismiss_splash(&mut self) {
1335        if self.splash.take().is_some() {
1336            self.damage = self.damage.join(Damage::Viewport);
1337            self.bump_gen();
1338        }
1339    }
1340
1341    /// Offer `key` to the start screen.
1342    ///
1343    /// A menu key runs its entry; ANY other key simply takes the screen
1344    /// down and is then handled normally — so the first thing an operator
1345    /// types is never swallowed.
1346    /// The open picker, for a face to paint.
1347    #[must_use]
1348    pub fn picker(&self) -> Option<&escriba_ui::picker::Picker> {
1349        self.picker.as_ref()
1350    }
1351
1352    /// Give an open picker the key.
1353    ///
1354    /// Runs BEFORE the keymap, and before the sequence stepper: while a
1355    /// picker is up it owns every key, including ones it has no meaning for.
1356    /// An overlay that let unknown keys fall through would edit the file
1357    /// behind itself.
1358    /// Ask the courier what a language server thinks of this buffer.
1359    ///
1360    /// Fires on open, and only on open. Re-asking on every keystroke is what a
1361    /// real diagnostics pump does, and it needs a session that outlives one
1362    /// errand plus `didChange` to keep the server's copy current — neither
1363    /// exists yet, and dispatching per keystroke against a one-shot runner
1364    /// would spawn a language server per character typed.
1365    ///
1366    /// A buffer with no path is skipped: a scratch buffer has no document for
1367    /// a server to have an opinion about.
1368    fn ask_for_diagnostics(&mut self, buffer: escriba_core::BufferId) {
1369        let Some(b) = self.buffers.get(buffer) else {
1370            return;
1371        };
1372        let Some(path) = b.path.clone() else {
1373            return;
1374        };
1375        let text = b.to_string();
1376        let language = self.language_of(&path);
1377        let freight = escriba_madoguchi::errand::Freight::Diagnostics {
1378            buffer,
1379            path,
1380            language,
1381            text,
1382        };
1383        let anchor = self.seal(&freight);
1384        self.courier.send(freight, anchor);
1385    }
1386
1387    /// The language of `path`, as the CATALOG declared it.
1388    ///
1389    /// `FiletypeTable` is populated from every `(defmode … :extensions …)` the
1390    /// catalog carries, so this answer covers every language escriba has been
1391    /// told about — 11 of them today — rather than the two extensions
1392    /// `escriba_lsp_client::runner::language_of` hardcodes. That is why blue
1393    /// resolves here without anything in this crate naming blue: the
1394    /// declaration already existed, and nothing was reading it.
1395    ///
1396    /// `None` when the table has no entry, which is not an error — it is the
1397    /// answer for a plain `.txt`, and it is also what a `--no-defaults` boot
1398    /// gives for everything, so the runner keeps its own fallback.
1399    fn language_of(&self, path: &std::path::Path) -> Option<String> {
1400        self.filetypes.resolve(path).map(|f| f.name.clone())
1401    }
1402
1403    /// Ask the formatter runner to format `buffer`.
1404    ///
1405    /// The buffer's CURRENT text goes with the errand and the anchor seals on
1406    /// its revision, so a reply computed against text the operator has since
1407    /// changed is refused rather than applied — see `seal`'s `Format` arm,
1408    /// which was written before anything could construct this errand.
1409    fn ask_for_format(&mut self, buffer: escriba_core::BufferId) {
1410        let Some(b) = self.buffers.get(buffer) else {
1411            return;
1412        };
1413        // A formatter needs a path: it is how the server is chosen and how the
1414        // project root is found. A scratch buffer has none, and saying so beats
1415        // silently doing nothing to a keystroke the operator pressed.
1416        let Some(path) = b.path.clone() else {
1417            self.messages
1418                .push("format: this buffer has no path".to_string());
1419            self.damage = self.damage.join(Damage::Viewport);
1420            self.bump_gen();
1421            return;
1422        };
1423        let text = b.to_string();
1424        let language = self.language_of(&path);
1425        let freight = escriba_madoguchi::errand::Freight::Format {
1426            buffer,
1427            path,
1428            language,
1429            text,
1430        };
1431        let anchor = self.seal(&freight);
1432        self.courier.send(freight, anchor);
1433    }
1434
1435    /// Set or clear a breakpoint on the cursor's line of the active buffer.
1436    ///
1437    /// The SOLE writer of `self.breakpoints`, which is why the field is
1438    /// private and [`breakpoints`](Self::breakpoints) hands out a `&`: the
1439    /// mark has to reach the screen, and the GPU face rebuilds its cached
1440    /// shaped gutter buffer ONLY on a refresh-generation change
1441    /// (`escriba-render/src/gpu.rs:260`). A caller that reached in and
1442    /// mutated the set would change the state and not the picture until some
1443    /// unrelated edit invalidated the cache — the defect `set_theme` had.
1444    ///
1445    /// It does NOT bump the generation itself, and that is deliberate rather
1446    /// than an omission: [`honour`](Self::honour) widens the damage and bumps
1447    /// for EVERY slip, so doing it here as well would be a second
1448    /// implementation of one guarantee — measured 2026-08-12, the first cut
1449    /// of this method did exactly that and the redundancy was invisible
1450    /// because both spellings produce the same answer.
1451    fn toggle_breakpoint(&mut self) {
1452        let buffer = self.active;
1453        let line = self.cursor().line;
1454        // No buffer means no line to mark, and marking a row of nothing would
1455        // put a breakpoint somewhere a future DAP client cannot name.
1456        if self.buffers.get(buffer).is_none() {
1457            return;
1458        }
1459        let set = self.breakpoints.toggle(buffer, line);
1460        // Built by `push_str` + `Display`, not `format!` — ★★ TYPED EMISSION.
1461        // The number is 1-based, like the gutter it appears beside and like
1462        // every message vim prints; reporting the internal 0-based row would
1463        // disagree with the label painted next to the mark.
1464        let mut msg = String::from(if set {
1465            "breakpoint set at line "
1466        } else {
1467            "breakpoint cleared at line "
1468        });
1469        msg.push_str(&(line + 1).to_string());
1470        self.messages.push(msg);
1471    }
1472
1473    /// Close the picker — the SOLE writer of `self.picker = None`.
1474    ///
1475    /// Sole on purpose. Closing has three consequences that must not come
1476    /// apart: the overlay goes, the screen repaints, and any scan feeding that
1477    /// overlay is superseded. Spread across call sites, one of them eventually
1478    /// forgets the third and a dismissed picker springs back open when a late
1479    /// batch arrives.
1480    ///
1481    /// `cancel_all` is asked as well as the generation bumped, but note which
1482    /// one is load-bearing: the bump is what makes late rows *ignored*, and it
1483    /// works whether or not any runner reads its flag. The cancel is a courtesy
1484    /// to a runner that checks.
1485    fn close_picker(&mut self) {
1486        self.picker = None;
1487        self.picker_projects = None;
1488        self.bump_scan_gen();
1489        self.courier.cancel_all();
1490        self.bump_gen();
1491    }
1492
1493    fn consume_picker_key(&mut self, key: &Key) -> escriba_ui::picker::Consumed {
1494        use escriba_ui::picker::Consumed;
1495        let Some(p) = self.picker.as_mut() else {
1496            return Consumed::NotShowing;
1497        };
1498        let outcome = p.on_key(key);
1499        match &outcome {
1500            // BOTH arms close the picker — choosing a row dismisses it just as
1501            // surely as pressing Esc, and an earlier reading of this that only
1502            // considered Esc would have left a scan running after every pick.
1503            Consumed::Dismissed | Consumed::Chose(_) => self.close_picker(),
1504            Consumed::Held => self.bump_gen(),
1505            Consumed::NotShowing => {}
1506        }
1507        outcome
1508    }
1509
1510    /// Lower an accepted pick into the ONE interpreter.
1511    ///
1512    /// The whole reason `Choice` is a closed enum: a new source must decide
1513    /// here, and the compiler says so.
1514    fn honour_choice(&mut self, choice: escriba_ui::picker::Choice) {
1515        use escriba_ui::picker::Choice;
1516        let slip = match choice {
1517            Choice::Buffer(id) => Negai::FocusBuffer(id),
1518            Choice::Command(name) => Negai::RunCommand {
1519                name,
1520                args: Vec::new(),
1521            },
1522            Choice::OpenFile(path) => Negai::OpenPath(path),
1523            Choice::Location { path, line } => {
1524                // Open FIRST, then jump: the buffer may not exist yet, and
1525                // `jump_to_site` needs a BufferId. Two slips, one interpret.
1526                self.interpret(Outcome::did(vec![Negai::OpenPath(path)]));
1527                let site = escriba_shirube::Site::in_buffer(
1528                    self.active,
1529                    escriba_core::Range::new(
1530                        escriba_core::Position::new(line, 0),
1531                        escriba_core::Position::new(line, 1),
1532                    ),
1533                );
1534                self.jump_to_site(&site);
1535                return;
1536            }
1537        };
1538        self.interpret(Outcome::did(vec![slip]));
1539    }
1540
1541    /// How many files a project grep will read, and how many hits it keeps.
1542    ///
1543    /// BOUNDED, and the bound is here rather than hidden, because this is a
1544    /// SYNCHRONOUS scan on the editor's own thread. The interpreter already
1545    /// does synchronous filesystem I/O (`OpenPath`, `Save`), so the posture
1546    /// is not new — but those touch one file and this walks a tree, which is
1547    /// the first one big enough to freeze the editor.
1548    ///
1549    /// GREP NO LONGER USES THIS. The courier carries the scan now, with no
1550    /// ceiling at all, and `GREP_HIT_LIMIT` is gone with it — the bound was
1551    /// a symptom of walking the tree on the thread that draws the screen.
1552    ///
1553    /// It survives for the files and project pickers, which still build
1554    /// their row set synchronously at open. Moving those onto the courier
1555    /// is the same change again and has not been made.
1556    const GREP_FILE_LIMIT: usize = 2_000;
1557
1558    /// Walk the working directory, bounded, returning `(files, truncated)`.
1559    ///
1560    /// ONE walker. grep, files and project each need to enumerate the tree,
1561    /// and three copies of a bounded traversal is three places to get the
1562    /// ceiling, the skip-list, or the truncation report subtly different.
1563    ///
1564    /// Skips dotfiles, `target` and `node_modules`. That is NOT a gitignore
1565    /// implementation and does not pretend to be — a real ignore crate comes
1566    /// with the courier.
1567    fn walk_project(limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1568        Self::walk_from(std::path::Path::new("."), limit)
1569    }
1570
1571    /// The same bounded walk, from an explicit root.
1572    ///
1573    /// `walk_project` is this with `.` — one traversal, two callers, rather
1574    /// than a second copy for "browse from somewhere else".
1575    fn walk_from(root: &std::path::Path, limit: usize) -> (Vec<std::path::PathBuf>, bool) {
1576        let mut out = Vec::new();
1577        let mut truncated = false;
1578        let mut stack = vec![root.to_path_buf()];
1579        while let Some(dir) = stack.pop() {
1580            let Ok(entries) = std::fs::read_dir(&dir) else {
1581                continue;
1582            };
1583            for entry in entries.flatten() {
1584                let name = entry.file_name();
1585                let name = name.to_string_lossy();
1586                if name.starts_with('.') || name == "target" || name == "node_modules" {
1587                    continue;
1588                }
1589                let path = entry.path();
1590                if entry.file_type().is_ok_and(|t| t.is_dir()) {
1591                    stack.push(path);
1592                    continue;
1593                }
1594                if out.len() >= limit {
1595                    truncated = true;
1596                    return (out, truncated);
1597                }
1598                out.push(path);
1599            }
1600        }
1601        (out, truncated)
1602    }
1603
1604    /// Say plainly when a bounded scan stopped short.
1605    ///
1606    /// A truncated list presented as complete is the failure this codebase
1607    /// keeps finding in itself; it does not get to ship one.
1608    fn report_truncation(&mut self, truncated: bool) {
1609        if truncated {
1610            self.messages
1611                .push("scan stopped at the limit — results are INCOMPLETE".to_string());
1612        }
1613    }
1614
1615    /// Scan the working directory for `pattern`, off the editor thread.
1616    ///
1617    /// This used to walk the tree inline, capped at 2,000 files and 500 hits
1618    /// because it ran on the thread that draws the screen — and it reported
1619    /// that truncation, honestly, as INCOMPLETE. Both ceilings are gone with
1620    /// the walk: the courier carries it, and results arrive in batches.
1621    ///
1622    /// The picker opens EMPTY and then projects the findings list. That is why
1623    /// there is no "no matches" message here any more — at dispatch time
1624    /// nothing is known yet, and guessing would mean reporting an empty result
1625    /// before the scan had read a single file.
1626    fn grep_project(&mut self, pattern: &str) {
1627        use escriba_ui::picker::{Picker, Source};
1628        if pattern.is_empty() {
1629            self.messages.push("grep: empty pattern".to_string());
1630            return;
1631        }
1632        // A fresh scan supersedes whatever was running, and clears the list so
1633        // the previous pattern's rows are not shown under the new one.
1634        self.bump_scan_gen();
1635        self.courier.cancel_all();
1636        self.results.clear(crate::scan::LIST);
1637
1638        let freight = escriba_madoguchi::errand::Freight::Scan {
1639            raw: pattern.to_string(),
1640            case: escriba_search::CaseMode::Smart,
1641            root: std::path::PathBuf::from("."),
1642        };
1643        let anchor = self.seal(&freight);
1644        self.courier.send(freight, anchor);
1645
1646        self.picker = Some(Picker::open(Source::Grep, Vec::new()));
1647        self.picker_projects = Some((true, Some(crate::scan::LIST.to_string())));
1648        self.bump_gen();
1649    }
1650
1651    /// Re-list a projecting picker after `published` changed.
1652    ///
1653    /// A picker over a live producer is a VIEW of the registry, not a snapshot
1654    /// taken when it opened — otherwise a scan's later batches would have
1655    /// nowhere to land, since `Picker` has no append.
1656    fn refresh_projected_picker(&mut self, published: &str) {
1657        let Some((workspace, ref list)) = self.picker_projects else {
1658            return;
1659        };
1660        // Only the list this picker is projecting. A diagnostics publish must
1661        // not redraw a grep picker.
1662        if list.as_deref().is_some_and(|l| l != published) {
1663            return;
1664        }
1665        let items = self.finding_items(workspace, list.as_deref());
1666        if let Some(p) = self.picker.as_mut() {
1667            if p.refresh_items(items) {
1668                self.bump_gen();
1669            }
1670        }
1671    }
1672
1673    /// Where accepting a finding should take the operator.
1674    ///
1675    /// Returns `None` for a finding whose site names neither a path nor a
1676    /// live buffer — that is a finding about nowhere, and offering it would
1677    /// give the operator a row that does nothing when pressed.
1678    fn finding_choice(&self, f: &escriba_shirube::Finding) -> Option<escriba_ui::picker::Choice> {
1679        use escriba_ui::picker::Choice;
1680        let line = f.site.range.start.line;
1681        if let Some(p) = &f.site.path {
1682            return Some(Choice::Location {
1683                path: p.clone(),
1684                line,
1685            });
1686        }
1687        let id = f.site.buffer?;
1688        let b = self.buffers.get(id)?;
1689        // A buffer WITH a path becomes a Location, so the line survives. A
1690        // scratch buffer has no path to name, so the best available answer
1691        // is the buffer itself — and the line is lost. Stated rather than
1692        // hidden: a `Choice` that carried a BufferId AND a line would be
1693        // the fix, and it belongs with the picker, not here.
1694        b.path.as_ref().map_or(Some(Choice::Buffer(id)), |p| {
1695            Some(Choice::Location {
1696                path: p.clone(),
1697                line,
1698            })
1699        })
1700    }
1701
1702    /// How a finding's location reads in a list row.
1703    fn finding_label(&self, f: &escriba_shirube::Finding) -> String {
1704        if let Some(p) = &f.site.path {
1705            return p.to_string_lossy().into_owned();
1706        }
1707        f.site
1708            .buffer
1709            .and_then(|id| self.buffers.get(id))
1710            .and_then(|b| b.path.as_ref())
1711            .map_or_else(
1712                || String::from("[scratch]"),
1713                |p| p.to_string_lossy().into_owned(),
1714            )
1715    }
1716
1717    /// Picker rows for every file under `root`.
1718    ///
1719    /// `picker.files` and `files.open-parent` differ only in the root, so
1720    /// they share this rather than carrying two copies of the same body —
1721    /// which is what they did for one commit, and what the line-count lint
1722    /// correctly complained about.
1723    fn file_items(
1724        &mut self,
1725        root: &std::path::Path,
1726    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1727        use escriba_ui::picker::{Choice, PickerItem};
1728        let (files, truncated) = Self::walk_from(root, Self::GREP_FILE_LIMIT);
1729        self.report_truncation(truncated);
1730        files
1731            .into_iter()
1732            .map(|p| {
1733                let label = p.to_string_lossy().into_owned();
1734                PickerItem::new(Choice::OpenFile(p), label)
1735            })
1736            .collect()
1737    }
1738
1739    /// Picker rows for the located findings the `trouble.*` verbs show.
1740    ///
1741    /// Freshness is asked of the registry, not assumed: `fresh` filters
1742    /// against the CURRENT world, so a list anchored to a revision the
1743    /// buffer has moved past contributes nothing rather than offering a
1744    /// line that has since shifted.
1745    fn finding_items(
1746        &self,
1747        workspace: bool,
1748        only: Option<&str>,
1749    ) -> Vec<escriba_ui::picker::PickerItem<escriba_ui::picker::Choice>> {
1750        use escriba_ui::picker::PickerItem;
1751        let world = self.world();
1752        let active = Some(self.active);
1753        let mut items = Vec::new();
1754        // `None` means every list — the trouble.* behaviour, unchanged.
1755        // `Some(n)` narrows to one producer, so a grep picker does not also
1756        // render LSP diagnostics and the marker scan.
1757        let names: Vec<&str> = only.map_or_else(|| self.results.names(), |n| vec![n]);
1758        for name in names {
1759            let Some(list) = self.results.get(name) else {
1760                continue;
1761            };
1762            for f in list.fresh(&world) {
1763                // `trouble.document` narrows to the buffer in front of the
1764                // operator. A finding that names only a path is
1765                // workspace-scoped by construction — it has no buffer to be
1766                // "this" one.
1767                if !workspace && f.site.buffer != active {
1768                    continue;
1769                }
1770                let Some(choice) = self.finding_choice(f) else {
1771                    continue;
1772                };
1773                let line = f.site.range.start.line;
1774                let mut label = String::with_capacity(64);
1775                label.push_str(f.severity.label());
1776                label.push_str("  ");
1777                label.push_str(&self.finding_label(f));
1778                label.push(':');
1779                label.push_str(&(line + 1).to_string());
1780                label.push_str("  ");
1781                label.push_str(&f.message);
1782                items.push(PickerItem::new(choice, label));
1783            }
1784        }
1785        items
1786    }
1787
1788    /// Build and open a picker over `source`.
1789    fn open_picker(&mut self, source: escriba_madoguchi::PickerSource) {
1790        use escriba_ui::picker::{Choice, Picker, PickerItem, Source};
1791        let (src, items) = match source {
1792            escriba_madoguchi::PickerSource::Buffers => (
1793                Source::Buffers,
1794                self.buffers
1795                    .ids()
1796                    .into_iter()
1797                    .filter_map(|id| {
1798                        let b = self.buffers.get(id)?;
1799                        let label = b.path.as_ref().map_or_else(
1800                            || String::from("[scratch]"),
1801                            |p| p.to_string_lossy().into_owned(),
1802                        );
1803                        Some(PickerItem::new(Choice::Buffer(id), label))
1804                    })
1805                    .collect::<Vec<_>>(),
1806            ),
1807            escriba_madoguchi::PickerSource::Help => (
1808                Source::Help,
1809                self.keymap
1810                    .entries_sorted()
1811                    .into_iter()
1812                    .map(|(mode, key, b)| {
1813                        // "NORMAL  gd   goto definition" — searchable by key,
1814                        // by mode, or by what it does, because a reader
1815                        // arrives from any of the three.
1816                        let mut label = String::with_capacity(48);
1817                        label.push_str(mode.as_str());
1818                        label.push_str("  ");
1819                        // `{key:?}` because there is no shared key FORMATTER
1820                        // in the fleet — awase owns the chord vocabulary but
1821                        // escriba-keymap's `Key` has no Display. That gap
1822                        // belongs to the keymap consolidation, not here, and
1823                        // inventing a fourth spelling would make it worse.
1824                        label.push_str(&format!("{key:?}"));
1825                        label.push_str("  ");
1826                        label.push_str(&b.description);
1827                        // Accepting runs the binding's action if it names a
1828                        // command; a typed Action has no name to run, so it
1829                        // reports rather than pretending.
1830                        let choice = match &b.action {
1831                            escriba_core::Action::Command { name, .. } => {
1832                                Choice::Command(name.clone())
1833                            }
1834                            other => Choice::Command(format!("{other:?}")),
1835                        };
1836                        PickerItem::new(choice, label)
1837                    })
1838                    .collect::<Vec<_>>(),
1839            ),
1840            escriba_madoguchi::PickerSource::Files => {
1841                (Source::Files, self.file_items(std::path::Path::new(".")))
1842            }
1843            escriba_madoguchi::PickerSource::Project => {
1844                // A project root is a directory carrying a marker. Derived
1845                // from the SAME walk rather than a second traversal — the
1846                // markers are files, so the walker already visited them.
1847                const MARKERS: &[&str] = &[
1848                    "Cargo.toml",
1849                    "flake.nix",
1850                    "package.json",
1851                    "go.mod",
1852                    "pyproject.toml",
1853                ];
1854                let (files, truncated) = Self::walk_project(Self::GREP_FILE_LIMIT);
1855                self.report_truncation(truncated);
1856                let mut roots: Vec<std::path::PathBuf> = files
1857                    .into_iter()
1858                    .filter(|p| {
1859                        p.file_name()
1860                            .is_some_and(|n| MARKERS.contains(&n.to_string_lossy().as_ref()))
1861                    })
1862                    .filter_map(|p| p.parent().map(std::path::Path::to_path_buf))
1863                    .collect();
1864                roots.sort();
1865                roots.dedup();
1866                (
1867                    Source::Project,
1868                    roots
1869                        .into_iter()
1870                        .map(|p| {
1871                            let label = p.to_string_lossy().into_owned();
1872                            PickerItem::new(Choice::OpenFile(p), label)
1873                        })
1874                        .collect::<Vec<_>>(),
1875                )
1876            }
1877            escriba_madoguchi::PickerSource::Commands => (
1878                Source::Commands,
1879                self.commands
1880                    .names()
1881                    .into_iter()
1882                    .map(|n| PickerItem::new(Choice::Command(n.to_string()), n.to_string()))
1883                    .collect::<Vec<_>>(),
1884            ),
1885            escriba_madoguchi::PickerSource::FilesUnder(root) => {
1886                (Source::Files, self.file_items(&root))
1887            }
1888            escriba_madoguchi::PickerSource::Findings { workspace } => {
1889                (Source::Findings, self.finding_items(workspace, None))
1890            }
1891        };
1892        if items.is_empty() {
1893            self.messages.push("nothing to pick from".to_string());
1894            return;
1895        }
1896        self.picker = Some(Picker::open(src, items));
1897        self.bump_gen();
1898    }
1899
1900    /// Read one key as operator-pending object selection.
1901    ///
1902    /// Returns `None` when the key is nothing to do with objects, so the
1903    /// ordinary path runs untouched.
1904    fn consume_object_key(&mut self, key: Key) -> Option<ObjectKey> {
1905        use escriba_core::TextObject as O;
1906        let Key::Char(c) = key else {
1907            // Esc (or anything non-printable) abandons a half-typed object
1908            // rather than leaving the editor silently armed.
1909            if self.pending_object.take().is_some() {
1910                self.op_pending
1911                    .dispatch((Action::ChangeMode(Mode::Normal), 1));
1912                return Some(ObjectKey::Consumed);
1913            }
1914            return None;
1915        };
1916
1917        // Second key: it names the object.
1918        if let Some(around) = self.pending_object.take() {
1919            let object = match c {
1920                'w' => Some(O::Word { around }),
1921                // vim's `b` and `B` aliases for the bracket pairs, plus the
1922                // brackets themselves in both directions.
1923                '(' | ')' | 'b' => Some(O::Delimited {
1924                    open: '(',
1925                    close: ')',
1926                    around,
1927                }),
1928                '{' | '}' | 'B' => Some(O::Delimited {
1929                    open: '{',
1930                    close: '}',
1931                    around,
1932                }),
1933                '[' | ']' => Some(O::Delimited {
1934                    open: '[',
1935                    close: ']',
1936                    around,
1937                }),
1938                '<' | '>' => Some(O::Delimited {
1939                    open: '<',
1940                    close: '>',
1941                    around,
1942                }),
1943                // Quotes: `open == close`, which is what tells the resolver
1944                // not to count nesting.
1945                '"' => Some(O::Delimited {
1946                    open: '"',
1947                    close: '"',
1948                    around,
1949                }),
1950                '\'' => Some(O::Delimited {
1951                    open: '\'',
1952                    close: '\'',
1953                    around,
1954                }),
1955                '`' => Some(O::Delimited {
1956                    open: '`',
1957                    close: '`',
1958                    around,
1959                }),
1960                _ => None,
1961            };
1962            let OpState::Awaiting { op, count } = *self.op_pending.state() else {
1963                return Some(ObjectKey::Consumed);
1964            };
1965            // Disarm either way: an unknown object key cancels the operator,
1966            // it does not leave it armed for the next unrelated keystroke.
1967            self.op_pending
1968                .dispatch((Action::ChangeMode(Mode::Normal), 1));
1969            let Some(object) = object else {
1970                return Some(ObjectKey::Consumed);
1971            };
1972            let composed = Action::ApplyOperatorObject { op, object };
1973            // `2diw` applies the object twice. The caller runs it once, so
1974            // the extra repeats happen here.
1975            for _ in 1..count {
1976                self.apply(&composed);
1977            }
1978            return Some(ObjectKey::Compose(composed));
1979        }
1980
1981        // First key: `i` or `a` while an operator waits.
1982        if matches!(c, 'i' | 'a') && matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
1983            self.pending_object = Some(c == 'a');
1984            return Some(ObjectKey::Consumed);
1985        }
1986        None
1987    }
1988
1989    /// Claim the operand of a pending `f`/`F`/`t`/`T`, or arm one.
1990    ///
1991    /// Runs BEFORE the sequence stepper and before the keymap, for the same
1992    /// reason [`Self::consume_object_key`] does: the character is an OPERAND,
1993    /// not a binding. `dfx` is undecidable from actions — `x` would resolve as
1994    /// whatever `x` is bound to — and `f` then `f` has to reach here rather
1995    /// than resolve as a two-key sequence.
1996    ///
1997    /// Composition with an operator is free: the armed motion is emitted as an
1998    /// ordinary `Action::Move`, so the operator-pending FSM composes `dfx`
1999    /// exactly the way it composes `dw`.
2000    fn consume_find_key(&mut self, key: Key) -> Option<ObjectKey> {
2001        if let Some(spec) = self.pending_find.take() {
2002            let Key::Char(ch) = key else {
2003                // Esc (or any non-printable) abandons a half-typed find rather
2004                // than leaving the editor armed for the next keystroke.
2005                if matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
2006                    self.op_pending
2007                        .dispatch((Action::ChangeMode(Mode::Normal), 1));
2008                }
2009                return Some(ObjectKey::Consumed);
2010            };
2011            let spec = FindSpec { ch, ..spec };
2012            self.last_find = Some(spec);
2013            return Some(ObjectKey::Compose(Action::Move(Motion::FindChar {
2014                ch,
2015                backward: spec.backward,
2016                till: spec.till,
2017            })));
2018        }
2019        if self.modal.mode() != Mode::Normal && self.modal.mode() != Mode::Visual {
2020            return None;
2021        }
2022        // A key that is CONTINUING a sequence belongs to the sequence.
2023        //
2024        // Without this, `zt` was unreachable: `z` starts a pending sequence,
2025        // then `t` was claimed here as a till-find and the sequence never
2026        // completed. The rule "an operand key outranks a binding" is right
2027        // for the FIRST key of a gesture and wrong for a later one — by then
2028        // the gesture has already been chosen. Note the operand branch above
2029        // runs before this guard, so `zt` and `ft` are both reachable.
2030        if !self.pending_keys.is_empty() {
2031            return None;
2032        }
2033        let Key::Char(c) = key else { return None };
2034        let (backward, till) = match c {
2035            'f' => (false, false),
2036            'F' => (true, false),
2037            't' => (false, true),
2038            'T' => (true, true),
2039            _ => return None,
2040        };
2041        self.pending_find = Some(FindSpec {
2042            ch: '\0',
2043            backward,
2044            till,
2045        });
2046        Some(ObjectKey::Consumed)
2047    }
2048
2049    /// Claim the operand of a pending `r`, or arm one.
2050    ///
2051    /// Same key-layer shape as [`Self::consume_find_key`], down to the
2052    /// mid-sequence guard: `r` is unbound in the keymap on purpose, because a
2053    /// binding on it would be a table entry no keypress can reach — which
2054    /// reads as configured and behaves as absent, the exact trap `f`/`t`
2055    /// documented.
2056    ///
2057    /// It runs AFTER the find capture and before the sequence stepper, and
2058    /// the relative order with find does not matter — the two arm on disjoint
2059    /// keys and neither can be pending while the other is.
2060    fn consume_replace_key(&mut self, key: Key) -> Option<ObjectKey> {
2061        if self.pending_replace {
2062            self.pending_replace = false;
2063            let Key::Char(ch) = key else {
2064                // Esc (or any non-printable) abandons a half-typed `r` rather
2065                // than replacing with something unprintable.
2066                return Some(ObjectKey::Consumed);
2067            };
2068            return Some(ObjectKey::Compose(Action::ReplaceChar(ch)));
2069        }
2070        if self.modal.mode() != Mode::Normal && self.modal.mode() != Mode::Visual {
2071            return None;
2072        }
2073        // A key CONTINUING a sequence belongs to the sequence — the `zt` rule.
2074        if !self.pending_keys.is_empty() {
2075            return None;
2076        }
2077        if key != Key::Char('r') {
2078            return None;
2079        }
2080        // `r` is not a motion, so `dr` is a typo — and vim treats it as one by
2081        // CANCELLING the operator. Falling through to the keymap instead is
2082        // not the same thing and is the worse reading: `r` is unbound (it has
2083        // to be, see above), so it resolves to `Action::Pending`, and the FSM
2084        // deliberately lets a stray `Pending` leave the operator armed for the
2085        // multi-key-sequence case. The next motion would then delete.
2086        if matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
2087            self.op_pending
2088                .dispatch((Action::ChangeMode(Mode::Normal), 1));
2089            return Some(ObjectKey::Consumed);
2090        }
2091        self.pending_replace = true;
2092        Some(ObjectKey::Consumed)
2093    }
2094
2095    fn consume_splash_key(&mut self, key: &Key) -> SplashKey {
2096        let Some(splash) = self.splash.as_ref() else {
2097            return SplashKey::NotShowing;
2098        };
2099        let chosen = match key {
2100            Key::Char(c) => splash.entry_for(*c).map(|e| e.action.clone()),
2101            _ => None,
2102        };
2103        self.dismiss_splash();
2104        chosen.map_or(SplashKey::Dismissed, SplashKey::Ran)
2105    }
2106
2107    /// The current refresh generation. A renderer caches its products against
2108    /// this; equality is the freshness test (an unchanged generation ⇒ the
2109    /// last frame is still valid, so skip the re-highlight + re-shape).
2110    #[must_use]
2111    pub fn edit_gen(&self) -> EditGen {
2112        self.edit_gen
2113    }
2114
2115    /// Advance the refresh generation (a mutation happened).
2116    fn bump_gen(&mut self) {
2117        self.edit_gen = self.edit_gen.next();
2118    }
2119
2120    /// The accumulated dirty region (read-only). See [`take_damage`](Self::take_damage).
2121    #[must_use]
2122    pub fn damage(&self) -> Damage {
2123        self.damage
2124    }
2125
2126    /// Drain the accumulated dirty region, resetting to [`Damage::None`]. The
2127    /// renderer calls this once per frame to learn what to repaint, then the
2128    /// accumulator restarts — so damage never double-counts across frames.
2129    pub fn take_damage(&mut self) -> Damage {
2130        std::mem::replace(&mut self.damage, Damage::None)
2131    }
2132
2133    /// The line count of the active buffer (0 if none) — used to compute the
2134    /// [`Damage`] scope of a mutation.
2135    fn active_line_count(&self) -> u32 {
2136        self.buffers
2137            .get(self.active)
2138            .map_or(0, escriba_buffer::Buffer::line_count)
2139    }
2140
2141    /// Register a lazy USER plugin: its escriba entry is deferred until
2142    /// one of its `triggers` fires. Bundled defaults do NOT go through
2143    /// here — they are applied eagerly at boot. Empty `triggers` means
2144    /// the plugin never lazily activates (the binary applies eager
2145    /// plugins directly).
2146    pub fn register_lazy_plugin(
2147        &mut self,
2148        name: impl Into<String>,
2149        triggers: Vec<LazyTrigger>,
2150        entry_src: impl Into<String>,
2151    ) {
2152        self.plugin_host.register(name, triggers, entry_src);
2153    }
2154
2155    /// Apply a plugin entry's escriba-lisp to live state — the same
2156    /// keymap / command / option apply paths a user rc uses. Options are
2157    /// applied before keybinds so a plugin that sets `mapleader` resolves
2158    /// `<leader>` correctly. Returns the count of commands + keybinds it
2159    /// registered (best-effort; a malformed entry is skipped, not fatal).
2160    fn apply_plugin_entry(&mut self, entry_src: &str) -> usize {
2161        let Ok(plan) = escriba_lisp::apply_source(entry_src) else {
2162            return 0;
2163        };
2164        let cmd = escriba_lisp::apply_plan_to_commands(&plan, &mut self.commands);
2165        escriba_lisp::apply_plan_to_options(&plan, &mut self.options);
2166        if let Some(value) = self.options.get("mapleader") {
2167            if let Some(key) = escriba_lisp::parse_leader_key(value) {
2168                self.keymap.set_leader(key);
2169            }
2170        }
2171        let km = escriba_lisp::apply_plan_to_keymap(&plan, &mut self.keymap);
2172        (cmd.registered + km.keybinds_applied) as usize
2173    }
2174
2175    /// Fire any lazy plugin gated on a `FileType` trigger for `filetype`.
2176    /// Returns the number of plugins activated. Call when a buffer of a
2177    /// known filetype is opened.
2178    pub fn activate_filetype_plugins(&mut self, filetype: &str) -> usize {
2179        let pending = self.plugin_host.pending_for_filetype(filetype);
2180        let n = pending.len();
2181        for src in pending {
2182            self.apply_plugin_entry(&src);
2183        }
2184        n
2185    }
2186
2187    /// Fire any lazy plugin gated on an `Event` trigger for `event`.
2188    /// Returns the number of plugins activated.
2189    pub fn activate_event_plugins(&mut self, event: &str) -> usize {
2190        let pending = self.plugin_host.pending_for_event(event);
2191        let n = pending.len();
2192        for src in pending {
2193            self.apply_plugin_entry(&src);
2194        }
2195        n
2196    }
2197
2198    /// Advance one frame's worth of state given a raw madori event.
2199    ///
2200    /// Key events pass through the [`KeyRepeatGate`] first (see
2201    /// [`Self::tick_at`]); everything else is handled directly.
2202    pub fn tick(&mut self, event: &AppEvent) {
2203        self.tick_at(event, Instant::now());
2204    }
2205
2206    /// [`Self::tick`] with an explicit timestamp for the key-repeat gate —
2207    /// lets tests drive the debounce window without depending on the
2208    /// wall clock.
2209    pub fn tick_at(&mut self, event: &AppEvent, now: Instant) {
2210        match translate_app_event(event) {
2211            InputOutcome::Key(k) => {
2212                if self.gate_key(&k, now) {
2213                    self.on_key(&k);
2214                }
2215            }
2216            InputOutcome::Resized { .. } => {
2217                // Damage only. Each face owns its own geometry: the GPU
2218                // backend derives the grid in `RenderCallback::resize`, and
2219                // the ratatui face reads its area every frame. This arm used
2220                // to write `Window.rect`, which nothing ever read — so the
2221                // resize path was already doing no real work, it just looked
2222                // like it was.
2223                self.damage = self.damage.join(Damage::Viewport);
2224                self.bump_gen();
2225            }
2226            InputOutcome::Quit => self.quit_requested = true,
2227            InputOutcome::Focus(_) | InputOutcome::None => {}
2228        }
2229    }
2230
2231    /// Decide whether `key` survives the key-repeat gate at time `now`.
2232    ///
2233    /// Returns `true` when the key should be processed, `false` when it is
2234    /// an OS key-repeat storm tick that should be dropped. Gating applies
2235    /// ONLY in the navigation modes (Normal / Visual / VisualLine) — those
2236    /// are where a held `j`/`l` floods the motion path and thrashes the
2237    /// viewport. Insert and Command modes pass every key through ungated,
2238    /// because there "hold a key to repeat the character" is the intended
2239    /// behavior, not a storm to suppress.
2240    fn gate_key(&mut self, key: &Key, now: Instant) -> bool {
2241        match self.modal.mode() {
2242            Mode::Normal | Mode::Visual | Mode::VisualLine => {
2243                // The gate exists for HELD keys that flood the motion path and
2244                // thrash the viewport (`j`, `l`). It is wrong for the discrete
2245                // jumps: two `n` presses 10 ms apart mean two matches, and
2246                // swallowing the second is indistinguishable from a dead key —
2247                // the exact symptom the gate was added to prevent elsewhere.
2248                if is_repeat_storm_candidate(key) {
2249                    return self.repeat_gate.try_pass_at(*key, now);
2250                }
2251                true
2252            }
2253            Mode::Insert | Mode::Command => true,
2254        }
2255    }
2256
2257    /// Dispatch a single key through the keymap + apply the resulting action.
2258    pub fn on_key(&mut self, key: &Key) {
2259        // An open picker owns EVERY key while it is up — before the splash,
2260        // before the sequence stepper, before the keymap.
2261        match self.consume_picker_key(key) {
2262            escriba_ui::picker::Consumed::NotShowing => {}
2263            escriba_ui::picker::Consumed::Held | escriba_ui::picker::Consumed::Dismissed => return,
2264            escriba_ui::picker::Consumed::Chose(c) => {
2265                self.honour_choice(c);
2266                return;
2267            }
2268        }
2269        // The start screen owns the first keypress and nothing after it.
2270        match self.consume_splash_key(key) {
2271            SplashKey::NotShowing | SplashKey::Dismissed => {}
2272            SplashKey::Ran(action) => {
2273                self.apply(&action);
2274                return;
2275            }
2276        }
2277        // ── OPERAND CAPTURE — the keys that are ARGUMENTS, not bindings ──
2278        //
2279        // `di(`, `fx`, `` `a ``, `rZ`: in each, the second keystroke is an
2280        // operand of a half-typed gesture and must be claimed before the
2281        // sequence stepper and before the keymap, or it resolves as whatever
2282        // it happens to be bound to (`i` enters Insert, `w` moves a word).
2283        //
2284        // This was four near-identical inlined blocks. It is a TABLE now
2285        // because **the order is the correctness property**, and an order that
2286        // lives in the arrangement of code is protected by nothing —
2287        // `operand_capture_order.rs` asserts this list, which the four blocks
2288        // could not be. Each adjacency below is a real dependency, not a
2289        // preference; see that test for the failure each one prevents.
2290        for cap in OPERAND_CHAIN {
2291            let Some(outcome) = (cap.claim)(self, key.clone()) else {
2292                continue;
2293            };
2294            match outcome {
2295                ObjectKey::Consumed => return,
2296                ObjectKey::Compose(a) => {
2297                    match cap.count {
2298                        // The object path applies its own repeats before
2299                        // returning (`2diw`), so re-counting here would square
2300                        // the count.
2301                        OperandCount::SelfCounted => self.apply(&a),
2302                        OperandCount::Drained => {
2303                            let n = self.modal.pending_count().unwrap_or(1);
2304                            self.modal.clear_count();
2305                            self.apply_counted(&a, n);
2306                        }
2307                    }
2308                    return;
2309                }
2310            }
2311        }
2312        // Multi-key sequence resolution runs first: a key that begins or
2313        // continues a bound sequence (`<leader>ff`, `gg`) is held or
2314        // resolved here before the single-key path sees it.
2315        match self.step_sequence(key) {
2316            SeqStep::Pending => return,
2317            SeqStep::Resolved(action) => {
2318                let count = self.modal.pending_count().unwrap_or(1);
2319                self.modal.clear_count();
2320                for _ in 0..count {
2321                    self.apply(&action);
2322                    if self.quit_requested {
2323                        return;
2324                    }
2325                }
2326                return;
2327            }
2328            SeqStep::Passthrough => {}
2329        }
2330        let counted = self.keymap.dispatch(&self.modal, key);
2331        // Count prefixes accumulate into modal state.
2332        if matches!(counted.action, Action::Pending) {
2333            if let Key::Char(c) = key {
2334                if c.is_ascii_digit() {
2335                    let d = u32::from(*c as u8 - b'0');
2336                    self.modal.append_count(d);
2337                }
2338            }
2339            return;
2340        }
2341        // The count flows through the operator-pending FSM (apply_counted), which
2342        // owns repetition: a bare motion runs count× , an operator captures its
2343        // count, and an operated motion multiplies the two. No naive outer loop.
2344        self.apply_counted(&counted.action, counted.count);
2345        // After applying, reset pending count.
2346        self.modal.clear_count();
2347    }
2348
2349    /// Advance the multi-key pending-stroke state machine for `key`.
2350    ///
2351    /// Sequences only apply in normal / visual modes — insert and
2352    /// command modes treat keys as literal text. Rules:
2353    /// - Mid-sequence: extend the pending prefix. Exact match →
2354    ///   [`SeqStep::Resolved`]; still a live prefix → [`SeqStep::Pending`];
2355    ///   otherwise abort the sequence and re-process this key fresh.
2356    /// - Not mid-sequence: if `key` begins a bound sequence AND is not
2357    ///   itself a complete single binding (single bindings win, so no
2358    ///   chord timeout is needed) → start pending. Otherwise
2359    ///   [`SeqStep::Passthrough`] to the single-key dispatcher.
2360    fn step_sequence(&mut self, key: &Key) -> SeqStep {
2361        let mode = self.modal.mode();
2362        if !matches!(mode, Mode::Normal | Mode::Visual | Mode::VisualLine) {
2363            return SeqStep::Passthrough;
2364        }
2365        if !self.pending_keys.is_empty() {
2366            let mut seq = self.pending_keys.clone();
2367            seq.push(key.clone());
2368            if let Some(b) = self.keymap.lookup_sequence(mode, &seq) {
2369                let action = b.action.clone();
2370                self.pending_keys.clear();
2371                return SeqStep::Resolved(action);
2372            }
2373            if self.keymap.is_sequence_prefix(mode, &seq) {
2374                self.pending_keys = seq;
2375                return SeqStep::Pending;
2376            }
2377            // The key broke the in-progress sequence — abort it and let
2378            // the key be re-processed as a fresh stroke below.
2379            self.pending_keys.clear();
2380        }
2381        let start = [key.clone()];
2382        if self.keymap.is_sequence_prefix(mode, &start) && self.keymap.lookup(mode, key).is_none() {
2383            self.pending_keys = start.to_vec();
2384            return SeqStep::Pending;
2385        }
2386        SeqStep::Passthrough
2387    }
2388
2389    /// The primary cursor position. The single read accessor — every
2390    /// renderer + motion path goes through it, so the underlying
2391    /// representation (today a single-cursor [`Cursors`]) can grow to
2392    /// multi-caret without changing read sites.
2393    #[must_use]
2394    pub fn cursor(&self) -> Position {
2395        self.cursors.primary()
2396    }
2397
2398    /// The **single** cursor-mutation path. Clamp the requested position to
2399    /// the active buffer's bounds, then scroll the active window's viewport
2400    /// to contain it on BOTH axes. Routing every cursor change through this
2401    /// (and through [`Cursors::set_primary`]) makes "cursor outside its
2402    /// viewport" an unrepresentable state, AND keeps cursor state in ONE
2403    /// typed home — there is no code path that advances the cursor without
2404    /// re-deriving the viewport from it, and no second `Position` field to
2405    /// fall out of sync.
2406    /// Re-assert the cursor-visibility invariant against the CURRENT
2407    /// viewport.
2408    ///
2409    /// A resize changes how much a face can show without moving the cursor,
2410    /// so nothing would otherwise re-run `scroll_to_contain` — the cursor
2411    /// would sit off-screen until the operator happened to move it. Every
2412    /// face calls this after telling the runtime its new size.
2413    pub fn refollow_cursor(&mut self) {
2414        self.set_cursor(self.cursors.primary());
2415    }
2416
2417    fn set_cursor(&mut self, pos: Position) {
2418        self.place_cursor(pos, CursorRest::OnCharacter);
2419    }
2420
2421    /// The **single** cursor-mutation body. `rest` says what kind of place the
2422    /// caller is asking for — see [`CursorRest`].
2423    fn place_cursor(&mut self, pos: Position, rest: CursorRest) {
2424        let clamped = if let Some(buf) = self.buffers.get(self.active) {
2425            let on_buffer = buf.clamp(pos);
2426            // In Normal mode the cursor sits ON a character; only Insert may
2427            // park past the last one, because that is where the next typed
2428            // character goes.
2429            //
2430            // `Buffer::clamp` cannot make this call — it answers "is this
2431            // position inside the text", which is a question about the BUFFER,
2432            // and one-past-the-end legitimately is. Whether the cursor may
2433            // REST there is a question about the MODE, so it is asked here,
2434            // once, on the single cursor-mutation path. Every motion inherits
2435            // it: `w` onto the last word, `$`, `x` at end of line.
2436            if rest == CursorRest::OnCharacter && self.modal.mode() == Mode::Normal {
2437                Position::new(
2438                    on_buffer.line,
2439                    on_buffer
2440                        .column
2441                        .min(buf.line_len_chars(on_buffer.line).saturating_sub(1)),
2442                )
2443            } else {
2444                on_buffer
2445            }
2446        } else {
2447            pos
2448        };
2449        self.cursors.set_primary(clamped);
2450        if let Some(w) = self.layout.active_window_mut() {
2451            w.viewport = w.viewport.scroll_to_contain(self.cursors.primary(), 2);
2452        }
2453    }
2454
2455    /// Dispatch one resolved action at count 1. See [`apply_counted`](Self::apply_counted).
2456    fn apply(&mut self, action: &Action) {
2457        self.apply_counted(action, 1);
2458    }
2459
2460    /// Dispatch one resolved action with its count. Routes `(action, count)`
2461    /// through the operator-pending FSM ([`OperatorPending`], on `zenmai`): most
2462    /// actions pass straight to [`apply_resolved`](Self::apply_resolved) carrying
2463    /// their count (so `5j` runs the motion 5×), an operator key is held, and an
2464    /// operator-then-motion pair is rewritten into a counted
2465    /// [`Action::ApplyOperator`] (so `3dw` deletes 3 words). The FSM owns count
2466    /// composition — there is no naive outer repeat loop.
2467    fn apply_counted(&mut self, action: &Action, count: u32) {
2468        // An uncompilable pattern must not reach the operator machine.
2469        //
2470        // `SearchState::accept` puts the prompt BACK on a compile error so the
2471        // typed text is not lost — but the FSM had already transitioned out of
2472        // `AwaitingSearch` on the way in, so the prompt survived and the
2473        // OPERATOR did not, with nothing said about it. The `d` was simply
2474        // gone, and the corrected pattern then ran as a bare search.
2475        //
2476        // The machine is a pure `(State, Event) -> (State, effects)` and
2477        // cannot observe the result of an effect, so it cannot decide this
2478        // itself. The fix is to stop handing it an event it has no business
2479        // deciding: the runtime classifies the submit first, from state it
2480        // already holds. `prompt_error` returns `None` for an EMPTY prompt, so
2481        // the bare-`/<CR>` reuse path is untouched.
2482        //
2483        // Tier-honest: parse-rejected at the boundary, not
2484        // truly-unrepresentable.
2485        if matches!(action, Action::SubmitCommand) {
2486            if let Some(e) = self.search.prompt_error() {
2487                let mut m = String::from("E383: Invalid search string: ");
2488                m.push_str(&e.to_string());
2489                self.messages.push(m);
2490                return;
2491            }
2492        }
2493
2494        // `|` is the one motion whose count is an ARGUMENT rather than a
2495        // repetition: `40|` means column 40, not "column 1, forty times"
2496        // (which is column 1). Folded in before the FSM sees it, so the
2497        // machine keeps one rule — counts repeat — and the exception lives
2498        // where the exception is.
2499        let action = &match action {
2500            Action::Move(Motion::Column(_)) => Action::Move(Motion::Column(count)),
2501            a => a.clone(),
2502        };
2503        let count = match action {
2504            Action::Move(Motion::Column(_)) => 1,
2505            _ => count,
2506        };
2507        for (resolved, times) in self.op_pending.dispatch((action.clone(), count)) {
2508            // Two ways an action can carry a count, and the split is real:
2509            //
2510            //   REPEAT (`5j`) — run it `times` over. The default.
2511            //   ABSORB (`3dw`, `2dd`, `3p`) — ONE operation over an extent
2512            //     resolved `times` over.
2513            //
2514            // The distinction is not pedantry. Repeating an operator works by
2515            // accident for delete — the text vanishes, so the cursor lands
2516            // somewhere new each round — and is simply wrong for yank, which
2517            // does not move: `2yw` re-yanked the FIRST word twice and put
2518            // "one one " in the register. It is wrong for `2dd` in the same
2519            // shape (the register kept only the second line, so `2ddp` put
2520            // back half), and it would make `3p` three undo steps.
2521            //
2522            // Both kinds now go THROUGH `apply_resolved`, and that is the
2523            // load-bearing part. The absorbing three used to short-circuit
2524            // straight to their executors from here, skipping the damage
2525            // classification and the dot-register recording at that function's
2526            // tail — so `.` after `3p` or `dw` replayed nothing, and the
2527            // repaint span was whatever the previous action had asked for.
2528            let absorbs = absorbs_count(&resolved);
2529            let (reps, n) = if absorbs { (1, times) } else { (times, 1) };
2530            for _ in 0..reps {
2531                self.apply_resolved(&resolved, n);
2532                if self.quit_requested {
2533                    return;
2534                }
2535            }
2536        }
2537    }
2538
2539    /// The active buffer's text. Search is a pure function of it.
2540    /// The active buffer's text revision — the token an offset measured
2541    /// against it should carry.
2542    #[must_use]
2543    fn text_rev(&self) -> TextRev {
2544        self.buffers
2545            .get(self.active)
2546            .map_or_else(TextRev::default, escriba_buffer::Buffer::text_rev)
2547    }
2548
2549    fn active_text(&self) -> String {
2550        self.buffers
2551            .get(self.active)
2552            .map(escriba_buffer::Buffer::to_string)
2553            .unwrap_or_default()
2554    }
2555
2556    /// The cursor as a char offset — the coordinate search speaks.
2557    fn cursor_char(&self) -> usize {
2558        self.buffers
2559            .get(self.active)
2560            .and_then(|b| b.position_to_char(self.cursor()).ok())
2561            .unwrap_or(0)
2562    }
2563
2564    /// Move the cursor onto a match and report a wrap the way vim does.
2565    /// The status line as data — what every face draws.
2566    ///
2567    /// One model, so the two faces can only disagree about styling. Before
2568    /// this existed the GPU face built its own line from a fixed `format!()`
2569    /// and drew neither the prompt nor any message, which made a fully
2570    /// working `/` look like a dead key on escriba's default renderer.
2571    #[must_use]
2572    pub fn status_model(&self) -> StatusModel<'_> {
2573        let cursor = self.cursor();
2574        let prompt = self.search.prompt();
2575
2576        let kind = match prompt.map(|p| p.direction) {
2577            Some(escriba_search::Direction::Forward) => PromptKind::SearchForward,
2578            Some(escriba_search::Direction::Backward) => PromptKind::SearchBackward,
2579            // Command mode with no search prompt open is an ex-command; the
2580            // typed `Option<Prompt>` is the discriminator, never a mode flag.
2581            None if self.modal.mode() == Mode::Command => PromptKind::Ex,
2582            None => PromptKind::None,
2583        };
2584
2585        StatusModel {
2586            mode: self.modal.mode(),
2587            line: cursor.line.saturating_add(1) as usize,
2588            column: cursor.column.saturating_add(1) as usize,
2589            prompt: kind,
2590            prompt_text: prompt
2591                .map_or_else(|| self.modal.minibuffer(), escriba_search::Prompt::text),
2592            prompt_caret: prompt.map_or_else(
2593                || self.modal.minibuffer_caret(),
2594                escriba_search::Prompt::caret,
2595            ),
2596            count: self.match_count(),
2597            message: self.messages.last().map(String::as_str),
2598        }
2599    }
2600
2601    /// `[3/17]` for the current pattern.
2602    ///
2603    /// While a prompt is open the count describes the PREVIEW — the answer to
2604    /// "what would Enter do", which is the question being asked mid-typing.
2605    /// Once committed it describes where the cursor actually is.
2606    #[must_use]
2607    fn match_count(&self) -> MatchCount {
2608        if self.search.is_prompting() {
2609            let text = self.active_text();
2610            // ONE scan, four outcomes. `Incomplete` and `NoMatch` used to be
2611            // the same `None`, so a half-typed character class reported
2612            // `[0/0]` — telling the user their pattern matches nothing while
2613            // they are still writing it.
2614            return match self.search.preview(&text) {
2615                escriba_search::Preview::Landed { step, total } => {
2616                    MatchCount::new(step.index, total)
2617                }
2618                escriba_search::Preview::NoMatch => MatchCount::None,
2619                escriba_search::Preview::Incomplete | escriba_search::Preview::Idle => {
2620                    MatchCount::Idle
2621                }
2622            };
2623        }
2624        if self.search.pattern().is_none() {
2625            return MatchCount::Idle;
2626        }
2627        let total = self.search.matches().len();
2628        // Read THROUGH the anchor: an ordinal computed against text that has
2629        // since changed reads as absent, so a stale count cannot be displayed.
2630        let rev = self.text_rev();
2631        self.search_at.as_ref().and_then(|a| a.get(rev)).map_or(
2632            if total == 0 {
2633                MatchCount::None
2634            } else {
2635                MatchCount::Idle
2636            },
2637            |&i| MatchCount::new(i, total),
2638        )
2639    }
2640
2641    /// `.` — replay the last change at the cursor.
2642    ///
2643    /// Two steps, because a change can be two: run the action, then re-type
2644    /// whatever followed it. `cgn` + `.` is exactly this — change the next
2645    /// match, then repeat that whole gesture on the one after.
2646    fn repeat_last_change(&mut self) {
2647        let Some(change) = self.last_change.clone() else {
2648            self.messages
2649                .push("E32: No previous change to repeat".to_string());
2650            return;
2651        };
2652
2653        // Replayed the same way it ran: an absorbing action takes its count as
2654        // an argument, a repeating one takes it as a loop. Getting this
2655        // backwards makes `3dw` then `.` delete one word — which is what it
2656        // did while the recorded count was hardcoded to 1.
2657        let n = change.count.max(1);
2658        if absorbs_count(&change.action) {
2659            self.apply_resolved(&change.action, n);
2660        } else {
2661            for _ in 0..n {
2662                self.apply_resolved(&change.action, 1);
2663            }
2664        }
2665        for c in change.inserted.chars() {
2666            self.apply_resolved(&Action::InsertChar(c), 1);
2667        }
2668        if self.modal.mode() == Mode::Insert {
2669            // A replayed change must not leave the editor in Insert — the
2670            // original ended with an Esc the recording deliberately does not
2671            // store, since it is punctuation rather than part of the change.
2672            self.apply_resolved(&Action::ChangeMode(Mode::Normal), 1);
2673        }
2674        // The replay wrote through `apply_resolved`, which re-records
2675        // `last_change` from the inner action. Put the ORIGINAL back so a
2676        // second `.` repeats the same change rather than a fragment of it.
2677        self.last_change = Some(change);
2678        self.recording_insert = false;
2679    }
2680
2681    /// Resolve a text object to the range it names.
2682    ///
2683    /// `gn` uses the INCLUSIVE step, so a cursor already sitting inside a
2684    /// match operates on THAT match rather than skipping to the next — which
2685    /// is what makes `cgn` then `.` walk matches one at a time instead of
2686    /// every other one.
2687    /// `dd` — the current line INCLUDING its terminator.
2688    ///
2689    /// Taking the newline is what makes `dd` remove a line rather than blank
2690    /// it. On the last line there is no following newline to take, so it
2691    /// falls back to the preceding one — otherwise `dd` on the final line
2692    /// leaves an empty line behind, which is the one case a naive
2693    /// "start-of-line to start-of-next-line" range gets wrong.
2694    fn object_line(&self) -> Option<Range> {
2695        self.line_extent(1).map(|e| e.capture)
2696    }
2697
2698    /// `{n}dd` — `n` whole lines from the cursor down.
2699    ///
2700    /// A counted linewise operator is ONE operation over an `n`-line extent,
2701    /// not `n` operations over one line — the same rule `apply_operator_n`
2702    /// enforces for motions, and it broke here in exactly the way that note
2703    /// predicts. `2dd` ran the single-line object twice: the text came out
2704    /// right (deleting a line brings the next one under the cursor, so the
2705    /// repeat lands correctly by accident) and the REGISTER held only the
2706    /// second line, so `2ddp` silently put back half of what it took.
2707    ///
2708    /// Returns an [`Extent`], not a range, because a linewise CHANGE cuts
2709    /// something different from what a linewise DELETE cuts: `dd` takes the
2710    /// line and its terminator, `cc`/`S` clear the line's text and keep the
2711    /// line. Both put the same thing in the register.
2712    fn line_extent(&self, n: u32) -> Option<Extent> {
2713        let line = self.cursor().line;
2714        self.line_span(line, line.saturating_add(n.max(1).saturating_sub(1)))
2715    }
2716
2717    /// The linewise extent covering `first..=last`, in either order.
2718    ///
2719    /// Split out of [`Self::line_extent`] when linewise MOTIONS landed
2720    /// (2026-08-14). `dd` names its lines by counting down from the cursor;
2721    /// `dgg` and `dk` name theirs by reaching BACKWARDS to a resolved target.
2722    /// Both are the same extent question, and answering it twice is how the
2723    /// two would come to disagree about the trailing-newline and phantom-row
2724    /// cases below — which are the whole difficulty here and were already
2725    /// paid for once.
2726    fn line_span(&self, first: u32, last_line: u32) -> Option<Extent> {
2727        let buf = self.buffers.get(self.active)?;
2728        let (line, requested_end) = if first <= last_line {
2729            (first, last_line)
2730        } else {
2731            (last_line, first)
2732        };
2733        let last = buf.line_count().saturating_sub(1);
2734        // The last line the extent reaches, clamped so `999dd` near the end of
2735        // a file takes what is there rather than resolving to nothing.
2736        //
2737        // Clamped to `last_text_line`, NOT to `last`: on a file ending in `\n`
2738        // those differ by the phantom row the rope reports, and letting the
2739        // extent reach it sent the whole resolution down the "no following
2740        // newline" branch below — so `dd` on the last real line ate the file's
2741        // trailing newline instead of the line. It also makes a `dd` issued
2742        // FROM the phantom row resolve to an empty range (a no-op) rather than
2743        // to a destructive one.
2744        let end = requested_end.min(last_text_line(buf));
2745        if line > last_text_line(buf) {
2746            // The phantom row. There is no line here to operate on.
2747            return None;
2748        }
2749        // What a CHANGE cuts: the text of the named lines, terminators intact.
2750        // Independent of which capture branch runs below, because the lines
2751        // named are the same in all three.
2752        let removal = Range::new(
2753            Position::new(line, 0),
2754            Position::new(end, buf.line_len_chars(end)),
2755        );
2756        let capture = if end < last {
2757            Range::new(Position::new(line, 0), Position::new(end + 1, 0))
2758        } else if line > 0 {
2759            // Final line of a file with NO trailing newline: there is no
2760            // following line start to take a terminator from, so swallow the
2761            // PRECEDING one — otherwise `dd` blanks the line and leaves it.
2762            Range::new(
2763                Position::new(line - 1, buf.line_len_chars(line - 1)),
2764                Position::new(end, buf.line_len_chars(end)),
2765            )
2766        } else {
2767            // The extent is the whole buffer and there is no terminator to
2768            // take at either end: clear the text, keep the line itself.
2769            removal
2770        };
2771        Some(Extent {
2772            capture,
2773            removal,
2774            kind: RegisterKind::Linewise,
2775        })
2776    }
2777
2778    /// `iw` / `aw` — the word under the cursor.
2779    ///
2780    /// vim's `w` classes are word / punctuation / whitespace, and a text
2781    /// object never crosses a line. `around` additionally takes the trailing
2782    /// whitespace run, falling back to LEADING whitespace when there is none
2783    /// after — which is what vim does at end of line.
2784    fn object_word(&self, around: bool) -> Option<Range> {
2785        let buf = self.buffers.get(self.active)?;
2786        let pos = self.cursor();
2787        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2788        if text.is_empty() {
2789            return None;
2790        }
2791        let col = (pos.column as usize).min(text.len().saturating_sub(1));
2792
2793        #[derive(PartialEq, Clone, Copy)]
2794        enum Class {
2795            Word,
2796            Punct,
2797            Space,
2798        }
2799        let class = |c: char| {
2800            if c.is_alphanumeric() || c == '_' {
2801                Class::Word
2802            } else if c.is_whitespace() {
2803                Class::Space
2804            } else {
2805                Class::Punct
2806            }
2807        };
2808
2809        let here = class(text[col]);
2810        let mut start = col;
2811        while start > 0 && class(text[start - 1]) == here {
2812            start -= 1;
2813        }
2814        let mut end = col + 1;
2815        while end < text.len() && class(text[end]) == here {
2816            end += 1;
2817        }
2818
2819        if around {
2820            let after = end;
2821            while end < text.len() && class(text[end]) == Class::Space {
2822                end += 1;
2823            }
2824            // No trailing run: take the leading one instead, as vim does.
2825            if end == after {
2826                while start > 0 && class(text[start - 1]) == Class::Space {
2827                    start -= 1;
2828                }
2829            }
2830        }
2831
2832        Some(Range::new(
2833            Position::new(pos.line, start as u32),
2834            Position::new(pos.line, end as u32),
2835        ))
2836    }
2837
2838    /// `i(` / `a"` … — the region between a matched pair, on one line.
2839    ///
2840    /// Brackets NEST and quotes do not, and that is the only difference:
2841    /// with `open == close` the scan cannot count depth, so it takes the
2842    /// nearest delimiter on each side instead.
2843    fn object_delimited(&self, open: char, close: char, around: bool) -> Option<Range> {
2844        let buf = self.buffers.get(self.active)?;
2845        let pos = self.cursor();
2846        let text: Vec<char> = buf.line(pos.line)?.chars().collect();
2847        if text.is_empty() {
2848            return None;
2849        }
2850        let col = (pos.column as usize).min(text.len().saturating_sub(1));
2851
2852        let (l, r) = if open == close {
2853            // Quotes: nearest on each side, no nesting to track.
2854            let l = (0..=col).rev().find(|&i| text[i] == open)?;
2855            let r = ((col.max(l) + 1)..text.len()).find(|&i| text[i] == close)?;
2856            (l, r)
2857        } else {
2858            // Brackets: walk out counting depth, so an inner pair does not
2859            // terminate the search for the enclosing one.
2860            let mut depth = 0i32;
2861            let l = (0..=col).rev().find(|&i| {
2862                if text[i] == close && i != col {
2863                    depth += 1;
2864                    false
2865                } else if text[i] == open {
2866                    if depth == 0 {
2867                        true
2868                    } else {
2869                        depth -= 1;
2870                        false
2871                    }
2872                } else {
2873                    false
2874                }
2875            })?;
2876            depth = 0;
2877            let r = ((l + 1)..text.len()).find(|&i| {
2878                if text[i] == open {
2879                    depth += 1;
2880                    false
2881                } else if text[i] == close {
2882                    if depth == 0 {
2883                        true
2884                    } else {
2885                        depth -= 1;
2886                        false
2887                    }
2888                } else {
2889                    false
2890                }
2891            })?;
2892            (l, r)
2893        };
2894
2895        // `i` is strictly between the delimiters; `a` includes them.
2896        let (s, e) = if around { (l, r + 1) } else { (l + 1, r) };
2897        Some(Range::new(
2898            Position::new(pos.line, s as u32),
2899            Position::new(pos.line, e as u32),
2900        ))
2901    }
2902
2903    fn resolve_object(&self, object: escriba_core::TextObject) -> Option<Range> {
2904        use escriba_core::TextObject as O;
2905
2906        // The text-scanning objects resolve against the BUFFER; the two
2907        // search objects resolve against the match set. Splitting here keeps
2908        // the search logic below exactly as it was rather than threading a
2909        // second concern through it.
2910        match object {
2911            O::Line => return self.object_line(),
2912            O::Word { around } => return self.object_word(around),
2913            O::Delimited {
2914                open,
2915                close,
2916                around,
2917            } => return self.object_delimited(open, close, around),
2918            O::NextMatch | O::PrevMatch => {}
2919        }
2920
2921        let at = self.cursor_char();
2922        let matches = self.search.matches();
2923
2924        // A match CONTAINING the cursor wins outright, whichever direction the
2925        // object names.
2926        //
2927        // Comparing only against `m.start` — which is what a `starts`-vector
2928        // plus `Bound::Inclusive` does — is right only when the cursor sits on
2929        // a match's FIRST character. One column further in, `start < at` and
2930        // the match is rejected, so `cgn` skipped the very instance the
2931        // operator was standing in and the rename silently missed it. vim
2932        // operates on the containing match from every interior column, and the
2933        // `starts`-only comparison cannot express "contains" because it never
2934        // looks at `m.end`.
2935        let idx = matches.iter().position(|m| m.contains(at)).or_else(|| {
2936            let starts: Vec<usize> = matches.iter().map(|m| m.start).collect();
2937            match object {
2938                O::PrevMatch => Bound::Inclusive.first_matching(&starts, at, false),
2939                // Every other variant returned above; `NextMatch` is the only
2940                // one that can reach here besides `PrevMatch`.
2941                _ => Bound::Inclusive.first_matching(&starts, at, true),
2942            }
2943        })?;
2944
2945        let m = matches.get(idx)?;
2946        let buf = self.buffers.get(self.active)?;
2947        Some(Range {
2948            start: buf.char_to_position(m.start),
2949            end: buf.char_to_position(m.end),
2950        })
2951    }
2952
2953    fn land_on(&mut self, step: escriba_search::Step) {
2954        if let Some(buf) = self.buffers.get(self.active) {
2955            let pos = buf.char_to_position(step.target.start);
2956            self.set_cursor(pos);
2957        }
2958        // The `[3/17]` numerator. `Step` has carried this index since the
2959        // engine was written — `engine.rs` even names the counter as the
2960        // reason it exists — and every consumer discarded it until now.
2961        self.search_at = Some(Anchored::new(step.index, self.text_rev()));
2962    }
2963
2964    /// vim's "search hit BOTTOM, continuing at TOP".
2965    ///
2966    /// One reporter, called by the two places a search can wrap: the shared
2967    /// commit and `n`/`N`. `land_on` deliberately does NOT report, or the bare
2968    /// commit would say it twice.
2969    fn report_wrap(&mut self, step: &escriba_search::Step) {
2970        if let Some(msg) = escriba_search::wrap_message(step.wrapped) {
2971            self.messages.push(msg.to_string());
2972        }
2973    }
2974
2975    /// `n` / `N`. Reports vim's E486 when the pattern matches nothing, rather
2976    /// than failing silently — a search that appears to do nothing is
2977    /// indistinguishable from a dropped keystroke.
2978    fn jump_search(&mut self, reverse: bool) {
2979        // Using the matches re-lights them: `n` after an auto-clear shows you
2980        // what you are walking through.
2981        self.search.relight();
2982        // `n` is a far jump — record where we leave from so `<C-o>` works.
2983        self.jumps.push(self.spot());
2984        let at = self.cursor_char();
2985        match self.search.repeat(at, reverse) {
2986            Some(step) => {
2987                // `n` wrapping the file says so, same as a commit does.
2988                self.report_wrap(&step);
2989                self.land_on(step);
2990            }
2991            None => {
2992                let msg = self.search.pattern().map_or_else(
2993                    || "E35: No previous regular expression".to_string(),
2994                    |p| {
2995                        let mut m = String::from("E486: Pattern not found: ");
2996                        m.push_str(p.raw());
2997                        m
2998                    },
2999                );
3000                self.messages.push(msg);
3001            }
3002        }
3003    }
3004
3005    /// Move the cursor to where the in-progress pattern would land, without
3006    /// committing anything. vim's `incsearch`.
3007    ///
3008    /// A pattern that does not compile yet (`/a[`, mid-typing) previews
3009    /// nothing and reports nothing — an error toast on every keystroke of a
3010    /// character class would be unusable.
3011    fn preview_search(&mut self) {
3012        let text = self.active_text();
3013        let Some(origin) = self.search.prompt().map(|p| p.origin) else {
3014            return;
3015        };
3016        let target = match self.search.preview(&text) {
3017            escriba_search::Preview::Landed { step, .. } => step.target.start,
3018            // Nothing to show: back to where the search started. Covers a
3019            // half-typed pattern and a pattern that finds nothing alike —
3020            // both mean "there is no match to preview".
3021            escriba_search::Preview::Idle
3022            | escriba_search::Preview::Incomplete
3023            | escriba_search::Preview::NoMatch => origin,
3024        };
3025        // A pattern that STOPS matching returns the cursor to the origin.
3026        //
3027        // Preview used to only ever move forward, so typing `ch` (a match) and
3028        // then `chz` (none) left the cursor parked on the `ch` match — a
3029        // preview showing a position the pattern no longer justifies, while
3030        // the count beside it read `[0/0]`. Restoring is also what makes
3031        // Escape's promise legible: at every keystroke the cursor is either on
3032        // a real match or back where you started, never on a stale one.
3033        if let Some(buf) = self.buffers.get(self.active) {
3034            let pos = buf.char_to_position(target);
3035            self.set_cursor(pos);
3036        }
3037    }
3038
3039    /// `d/foo<CR>` — commit the prompt and operate from the prompt's origin to
3040    /// where the search lands, as ONE action.
3041    ///
3042    /// Split from [`Self::submit_search`] rather than sharing it because the
3043    /// two want opposite things from the commit: the bare `/` MOVES the cursor
3044    /// to the match, and an operated `/` must NOT — the cursor is the
3045    /// operator's start point, and moving it first would leave the operator
3046    /// with a zero-width range.
3047    /// Commit the open search prompt. The ONE copy of the sequence.
3048    ///
3049    /// Reports its own failures (E486 / E35) so neither caller has to carry a
3050    /// third copy of the message strings. `Accepted::Invalid` cannot reach
3051    /// here — `apply_counted` rejects an uncompilable pattern at the dispatch
3052    /// boundary before the FSM or this method ever sees the submit.
3053    fn commit_search_prompt(&mut self) -> CommitOutcome {
3054        let text = self.active_text();
3055        let Some((origin, skip)) = self.search.prompt().map(|p| (p.origin, p.preview_skip()))
3056        else {
3057            return CommitOutcome::NoPrompt;
3058        };
3059
3060        match self.search.accept(&text) {
3061            escriba_search::Accepted::Committed | escriba_search::Accepted::ReusedPrevious => {
3062                self.modal.clear_minibuffer();
3063                self.modal.enter(Mode::Normal);
3064                match self.search.commit_step_skipping(origin, skip) {
3065                    Some(step) => {
3066                        // The wrap notice belongs HERE, once, for both commit
3067                        // paths. Reporting it in each caller is what let the
3068                        // operated path lose it in the first place — and my
3069                        // first attempt at this refactor duplicated it again
3070                        // rather than moving it, which the red proof caught.
3071                        self.report_wrap(&step);
3072                        CommitOutcome::Landed { origin, step }
3073                    }
3074                    None => {
3075                        self.report_pattern_not_found();
3076                        CommitOutcome::NotFound
3077                    }
3078                }
3079            }
3080            escriba_search::Accepted::NothingToRepeat => {
3081                self.modal.clear_minibuffer();
3082                self.modal.enter(Mode::Normal);
3083                self.messages
3084                    .push("E35: No previous regular expression".to_string());
3085                CommitOutcome::NoPrevious
3086            }
3087            // Unreachable: the boundary guard in `apply_counted` returns early
3088            // on an uncompilable pattern, leaving the prompt open. Reported
3089            // rather than `unreachable!()` — a panic in the editor's commit
3090            // path is a worse failure than a duplicate message.
3091            escriba_search::Accepted::Invalid(e) => {
3092                let mut m = String::from("E383: Invalid search string: ");
3093                m.push_str(&e.to_string());
3094                self.messages.push(m);
3095                CommitOutcome::NoPrompt
3096            }
3097        }
3098    }
3099
3100    /// vim's E486, with the pattern named. One place, so every path that fails
3101    /// to find reports identically.
3102    fn report_pattern_not_found(&mut self) {
3103        let mut m = String::from("E486: Pattern not found");
3104        if let Some(p) = self.search.pattern() {
3105            m.push_str(": ");
3106            m.push_str(p.raw());
3107        }
3108        self.messages.push(m);
3109    }
3110
3111    /// Bare `/foo<CR>` — commit and MOVE the cursor to the match.
3112    ///
3113    /// The only difference from the operated path is that this one lands;
3114    /// everything else lives in `commit_search_prompt`.
3115    fn submit_search(&mut self) {
3116        match self.commit_search_prompt() {
3117            CommitOutcome::Landed { origin, step } => {
3118                if let Some(buf) = self.buffers.get(self.active) {
3119                    let from = buf.char_to_position(origin);
3120                    self.jumps.push(escriba_core::Spot::new(self.active, from));
3121                }
3122                self.land_on(step);
3123            }
3124            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
3125        }
3126    }
3127
3128    /// `d/foo<CR>` — commit, then operate from the prompt's origin to where the
3129    /// search lands, as ONE action.
3130    ///
3131    /// The cursor must NOT move to the match first: it is the operator's start
3132    /// point. That is the whole reason this differs from the bare path, and
3133    /// now the only reason.
3134    fn submit_search_operated(&mut self, op: Operator) {
3135        match self.commit_search_prompt() {
3136            CommitOutcome::Landed { origin, step } => {
3137                if let Some(buf) = self.buffers.get(self.active) {
3138                    let from = buf.char_to_position(origin);
3139                    let target = buf.char_to_position(step.target.start);
3140                    // Operating over a search is itself a far jump.
3141                    self.jumps.push(escriba_core::Spot::new(self.active, from));
3142                    self.set_cursor(from);
3143                    self.apply_operator_to(op, target);
3144                }
3145            }
3146            CommitOutcome::NotFound | CommitOutcome::NoPrevious | CommitOutcome::NoPrompt => {}
3147        }
3148    }
3149
3150    /// Execute one resolved action, with the count it ABSORBS.
3151    ///
3152    /// `count` is 1 for everything that repeats — the caller loops those — and
3153    /// is the gesture's full count for the arms listed in [`absorbs_count`].
3154    /// Every path lands here so the damage classification and dot-register
3155    /// recording at the tail run exactly once per gesture; the counted
3156    /// operators used to bypass this function and skipped both.
3157    fn apply_resolved(&mut self, action: &Action, count: u32) {
3158        // Snapshot the scope inputs before the mutation so the resulting
3159        // Damage covers the changed region (the S3 seal — conservative widen).
3160        let lines_before = self.active_line_count();
3161        // Snapshot for the dot register: the only reliable witness that this
3162        // action changed text is that the buffer's revision moved.
3163        let rev_before = self.text_rev();
3164        let cline_before = self.cursor().line;
3165        match action {
3166            // Every action with an exact slip equivalent goes through the
3167            // interpreter, so "undo" has ONE implementation rather than one
3168            // per entry point. These had already drifted: the executor
3169            // re-followed the viewport after undo and the M1 interpreter did
3170            // not, so `u` and `:undo` behaved differently within a milestone
3171            // of each other.
3172            // Listed EXPLICITLY rather than behind a `if lower(..).is_some()`
3173            // guard: a guard arm does not count toward exhaustiveness, so the
3174            // guarded form silently gave up the total match — the compiler
3175            // said so, and it was right. `lowering_and_dispatch_agree` pins
3176            // that this list and `lower` stay the same set.
3177            Action::Quit
3178            | Action::ClearSearchHighlight
3179            | Action::Save
3180            | Action::Undo
3181            | Action::Redo
3182            | Action::Edit(_) => {
3183                for slip in Self::lower(action, self.active).unwrap_or_default() {
3184                    self.honour_one(slip);
3185                }
3186            }
3187            Action::Move(m) => self.apply_motion(*m),
3188            Action::SearchOpen(dir) => {
3189                // vim's `/` is the command-line with a different prompt char,
3190                // so we reuse Command mode; `search.prompt` is what tells a
3191                // later <CR> this is a search and not an ex-command.
3192                let origin = self.cursor_char();
3193                self.search.open(*dir, origin);
3194                self.modal.enter(Mode::Command);
3195            }
3196            Action::SearchRepeat { reverse } => self.jump_search(*reverse),
3197            Action::SearchWord { reverse } => {
3198                let dir = if *reverse {
3199                    SearchDirection::Backward
3200                } else {
3201                    SearchDirection::Forward
3202                };
3203                let (text, at) = (self.active_text(), self.cursor_char());
3204                // `*` jumps, so it records too.
3205                self.jumps.push(self.spot());
3206                match self.search.search_word(&text, at, dir) {
3207                    Some(step) => self.land_on(step),
3208                    // vim beeps and stays put when there is no word under the
3209                    // cursor; a silent no-op would look like a broken key.
3210                    None => self
3211                        .messages
3212                        .push("E348: No string under cursor".to_string()),
3213                }
3214            }
3215            Action::SearchSubmitOperated { op } => self.submit_search_operated(*op),
3216            Action::TextObject(object) => {
3217                // Bare `gn` moves onto the match. vim additionally starts a
3218                // Visual selection of it; escriba's Visual plumbing does not
3219                // carry a selection an operator can consume yet, so this
3220                // stops at the jump rather than faking a selection that
3221                // nothing would honour.
3222                if let Some(range) = self.resolve_object(*object) {
3223                    self.jumps.push(self.spot());
3224                    self.set_cursor(range.start);
3225                } else {
3226                    self.report_pattern_not_found();
3227                }
3228            }
3229            // The linewise object is the one that can express an `n`-fold
3230            // extent, so it reads the count directly; every other object still
3231            // repeats (see `absorbs_count`).
3232            Action::ApplyOperatorObject {
3233                op,
3234                object: escriba_core::TextObject::Line,
3235            } => {
3236                if let Some(extent) = self.line_extent(count) {
3237                    self.apply_operator_over(*op, extent);
3238                }
3239            }
3240            Action::ApplyOperatorObject { op, object } => match self.resolve_object(*object) {
3241                // The kind comes from the OBJECT (`TextObject::register_kind`,
3242                // total over the enum), which is the only thing that knows:
3243                // `dd` on line 1 and a charwise `[(1,0), (2,0))` are the same
3244                // two positions.
3245                Some(range) => {
3246                    self.apply_operator_over(
3247                        *op,
3248                        Extent::from_object(range, object.register_kind()),
3249                    );
3250                }
3251                None => self.report_pattern_not_found(),
3252            },
3253            Action::Put { before } => self.put(*before, count),
3254            Action::ReplaceChar(ch) => self.replace_char(*ch, count),
3255            Action::JoinLines { space } => self.join_lines(*space, count),
3256            Action::RepeatLastChange => self.repeat_last_change(),
3257            Action::JumpBack => {
3258                let here = self.spot();
3259                if let Some(spot) = self.jumps.back(here) {
3260                    self.goto_spot(spot);
3261                } else {
3262                    self.messages
3263                        .push("E662: At start of changelist".to_string());
3264                }
3265            }
3266            Action::JumpForward => {
3267                if let Some(spot) = self.jumps.forward() {
3268                    self.goto_spot(spot);
3269                } else {
3270                    self.messages.push("E663: At end of changelist".to_string());
3271                }
3272            }
3273            Action::ChangeMode(m) => {
3274                // Leaving the cmdline abandons any open search prompt and
3275                // returns the cursor home. The COMMITTED pattern survives —
3276                // cancelling a new search must not erase the old highlights.
3277                if *m == Mode::Normal && self.search.is_prompting() {
3278                    if let Some(origin) = self.search.cancel() {
3279                        if let Some(buf) = self.buffers.get(self.active) {
3280                            let pos = buf.char_to_position(origin);
3281                            self.set_cursor(pos);
3282                        }
3283                    }
3284                }
3285                self.modal.enter(*m);
3286            }
3287            Action::EnterInsert(at) => self.enter_insert_at(*at),
3288            Action::InsertChar(c) => self.insert_char(*c),
3289
3290            Action::SubmitCommand => {
3291                if self.search.is_prompting() {
3292                    self.submit_search();
3293                } else {
3294                    self.submit_command();
3295                }
3296            }
3297            Action::Command { name, args } => self.run_command(name, args),
3298            Action::ApplyOperator { op, motion } => self.apply_operator_n(*op, *motion, count),
3299            // The operator-pending FSM consumes Operator keys (begins pending);
3300            // they never reach the executor. Defensive no-op for exhaustiveness.
3301            Action::Operator(_) => {}
3302            Action::PromptCaret { to } => {
3303                // Both prompts have a caret now, and the same keys move it.
3304                if self.search.is_prompting() {
3305                    self.search.move_caret(*to);
3306                } else {
3307                    self.modal.move_minibuffer_caret(*to);
3308                }
3309            }
3310            Action::SearchPreviewStep { forward } => {
3311                if self.search.is_prompting() {
3312                    self.search.preview_step(*forward);
3313                    self.preview_search();
3314                }
3315            }
3316            Action::DeleteForward => {
3317                if self.modal.mode() == Mode::Command {
3318                    if self.search.is_prompting() {
3319                        self.search.delete_at_caret();
3320                        self.preview_search();
3321                    } else {
3322                        self.modal.delete_minibuffer_at_caret();
3323                    }
3324                } else {
3325                    self.delete_after_cursor();
3326                }
3327            }
3328            Action::DeleteWordBefore => {
3329                if self.modal.mode() == Mode::Command {
3330                    if self.search.is_prompting() {
3331                        self.search.delete_word_before_caret();
3332                        self.preview_search();
3333                    }
3334                } else {
3335                    self.delete_word_before_cursor();
3336                }
3337            }
3338            Action::DeleteToLineStart => {
3339                if self.modal.mode() == Mode::Command {
3340                    if self.search.is_prompting() {
3341                        self.search.clear_before_caret();
3342                        self.preview_search();
3343                    }
3344                } else {
3345                    self.delete_to_line_start();
3346                }
3347            }
3348            Action::Backspace => {
3349                if self.modal.mode() == Mode::Command {
3350                    self.prompt_backspace();
3351                    // Shortening the pattern changes which matches exist, so
3352                    // the preview must re-run — otherwise the cursor sits on a
3353                    // match of a pattern that is no longer typed.
3354                    if self.search.is_prompting() {
3355                        self.preview_search();
3356                    }
3357                } else {
3358                    self.delete_before_cursor();
3359                }
3360            }
3361            Action::PromptHistory { back } => {
3362                if self.search.is_prompting() {
3363                    self.search.history_step(*back);
3364                    // No minibuffer resync: the shadow is the ex-line's store
3365                    // and nothing reads it while a search prompt is open, so
3366                    // rewriting it here was maintaining a copy for no reader.
3367                    self.preview_search();
3368                }
3369            }
3370            // `m{a-z}`. Only `a-z`: `A-Z` are vim's cross-file marks and this
3371            // map is per-editor, so accepting one would promise a jump back
3372            // to another FILE and deliver a jump to that line in this one.
3373            Action::SetMark(name) => {
3374                if name.is_ascii_lowercase() {
3375                    let at = self.cursor();
3376                    self.marks.insert(*name, at);
3377                } else {
3378                    self.messages
3379                        .push(format!("E191: mark `{name}` is not a-z"));
3380                }
3381            }
3382            Action::ScrollView(align) => self.scroll_view(*align),
3383            Action::Pending => {}
3384        }
3385        // Widen the dirty region by what this action touched (M1). Content
3386        // mutations that changed the line count run to end-of-document (every
3387        // line below shifted); an in-place edit or a cursor move is local;
3388        // arbitrary commands are conservatively Full. Never narrows.
3389        let lines_after = self.active_line_count();
3390        let cline_after = self.cursor().line;
3391        let d = match action {
3392            // A search repaints every highlight in the viewport, not just the
3393            // line the cursor left — so it must widen to Full. Treating it as a
3394            // cursor move would leave stale highlights on untouched lines.
3395            Action::SearchOpen(_)
3396            | Action::PromptHistory { .. }
3397            | Action::Backspace
3398            | Action::PromptCaret { .. }
3399            | Action::SearchPreviewStep { .. }
3400            | Action::DeleteForward
3401            | Action::DeleteWordBefore
3402            | Action::DeleteToLineStart
3403            | Action::SearchRepeat { .. }
3404            | Action::SearchWord { .. }
3405            | Action::ClearSearchHighlight
3406            | Action::SearchSubmitOperated { .. }
3407            // A replayed change can edit anywhere the original could, and a
3408            // match object can be anywhere in the document.
3409            | Action::RepeatLastChange
3410            | Action::TextObject(_)
3411            | Action::ApplyOperatorObject { .. }
3412            // A jump can land anywhere, so the viewport may scroll wholesale.
3413            | Action::JumpBack
3414            | Action::JumpForward
3415            // A re-frame repaints every row even though no byte changed —
3416            // which is exactly the case a line-scoped damage would miss.
3417            | Action::ScrollView(_) => Damage::Full,
3418            Action::InsertChar(_)
3419            | Action::Edit(_)
3420            | Action::Undo
3421            | Action::Redo
3422            // Insert-entry belongs in THIS group rather than beside
3423            // `ChangeMode` below, even though four of its six members only move
3424            // the caret: `o`/`O` add a line, and this arm's body is already the
3425            // one that asks whether the line COUNT changed. Grouping it with
3426            // the pure mode change would repaint a one-line span after `o` and
3427            // leave every line below the new one stale.
3428            | Action::EnterInsert(_)
3429            // Same reading as `EnterInsert`: a charwise put touches one line
3430            // and a linewise one adds several, and this arm's body is already
3431            // the one that asks which happened by comparing the line count.
3432            // `J` removes lines and `r` removes none — the same question.
3433            | Action::Put { .. }
3434            | Action::ReplaceChar(_)
3435            | Action::JoinLines { .. }
3436            | Action::ApplyOperator { .. } => {
3437                if lines_after == lines_before {
3438                    Damage::span(cline_before, cline_after)
3439                } else {
3440                    Damage::Lines {
3441                        from: cline_before.min(cline_after),
3442                        to: u32::MAX,
3443                    }
3444                }
3445            }
3446            Action::Move(_) | Action::ChangeMode(_) => Damage::span(cline_before, cline_after),
3447            Action::Save => Damage::Viewport,
3448            Action::Command { .. } | Action::SubmitCommand => Damage::Full,
3449            // Setting a mark changes no pixel — there is no gutter sign for
3450            // one yet. When one lands, this arm becomes `Damage::span`.
3451            Action::Quit | Action::Operator(_) | Action::SetMark(_) | Action::Pending => {
3452                Damage::None
3453            }
3454        };
3455        self.damage = self.damage.join(d);
3456        // Remember this change for `.`.
3457        //
3458        // Recorded from an OBSERVED MUTATION, not from the action's variant.
3459        // `text_effect()` is the wrong predicate here even though it looks
3460        // like the right one: it exists to decide cache invalidation, where
3461        // OVER-reporting is the safe direction, and the dot register needs the
3462        // opposite bias. Leaning on it meant `last_change` was set by actions
3463        // that changed no text at all, with two measured consequences:
3464        //
3465        //   `iZ<Esc>` then `/a<CR>` then `.`  — did nothing; the register held
3466        //       `SubmitCommand`, whose replay reads an already-cleared
3467        //       minibuffer.
3468        //   `iZ<Esc>` then `/q<Esc>` then `.` — TYPED `q` INTO THE BUFFER. An
3469        //       abandoned prompt left the register holding `InsertChar('q')`,
3470        //       and `.` in Normal mode routes that to the text. A corrupting
3471        //       register, not merely a lost one.
3472        //
3473        // Comparing the buffer's `TextRev` across the action answers the only
3474        // question that matters — did this actually change the text — and gets
3475        // the failed-operator case (`dgn` with no pattern) right for free.
3476        if self.recording_insert {
3477            match action {
3478                Action::InsertChar(c) => {
3479                    if let Some(lc) = self.last_change.as_mut() {
3480                        lc.inserted.push(*c);
3481                    }
3482                }
3483                // Leaving Insert ends the session; the change is now whole.
3484                Action::ChangeMode(m) if *m != Mode::Insert => self.recording_insert = false,
3485                _ => {}
3486            }
3487        } else if self.text_rev() != rev_before
3488            && !matches!(
3489                action,
3490                Action::RepeatLastChange | Action::Undo | Action::Redo
3491            )
3492        {
3493            self.last_change = Some(LastChange {
3494                // The count the gesture actually carried, not a hardcoded 1.
3495                // `.` replays it through the same absorb-or-repeat split the
3496                // original ran under (see `repeat_last_change`), so `3dw` then
3497                // `.` deletes three words rather than one.
3498                action: action.clone(),
3499                count,
3500                inserted: String::new(),
3501            });
3502            self.recording_insert = self.modal.mode() == Mode::Insert;
3503        }
3504
3505        // The search is over the moment you move on or edit — clear the
3506        // highlight rather than leaving the buffer as confetti until an
3507        // explicit `:noh`, which is the remap nearly every vimrc carries.
3508        // Clearing suppresses without forgetting, so `n` still works.
3509        if action.highlight_effect() == HighlightEffect::Clear {
3510            self.search.clear_highlight();
3511        }
3512        // Text changed ⇒ every match offset cached against the old text is
3513        // wrong. `SearchState::refresh` existed for exactly this and had ZERO
3514        // callers, so inserting four characters left both renderers painting
3515        // the highlight four columns off.
3516        //
3517        // Gated on the typed classifier rather than on `bump_gen` (which fires
3518        // for pure cursor moves too): re-scanning the document on every `j`
3519        // would be a per-keystroke full pass for no reason.
3520        if action.text_effect() == TextEffect::Mutates && self.search.pattern().is_some() {
3521            let text = self.active_text();
3522            self.search.refresh(&text);
3523            // NO manual invalidation of `search_at` here, deliberately. It is
3524            // `Anchored` to the text revision, so an ordinal computed against
3525            // the old text now reads as `None` on its own. This is the line
3526            // that used to have to be remembered.
3527        }
3528        // An action reached the executor ⇒ visible state may have changed.
3529        // Advance the refresh generation so the renderer repaints (and
3530        // re-highlights) exactly once. A gated-out key never reaches here, so
3531        // a key-repeat storm does not spin the renderer.
3532        self.bump_gen();
3533    }
3534
3535    /// Resolve a [`Motion`] from `from` to its target [`Position`] against the
3536    /// active buffer — **pure**: no cursor mutation, no side effects. This is
3537    /// the single motion-resolution source of truth that both [`apply_motion`]
3538    /// (move the cursor *to* the target) and [`apply_operator`] (use the target
3539    /// as the *other end* of an operated range) stand on. `None` only if there
3540    /// is no active buffer.
3541    ///
3542    /// [`apply_motion`]: Self::apply_motion
3543    /// [`apply_operator`]: Self::apply_operator
3544    fn resolve_motion(&self, from: Position, motion: Motion) -> Option<Position> {
3545        let buf = self.buffers.get(self.active)?;
3546        let pos = from;
3547        Some(match motion {
3548            // Search-as-motion: what makes `dn` / `d/foo<CR>` work. Resolved
3549            // against the committed match list, so it is `None` (motion fails,
3550            // operator aborts, buffer untouched) when nothing is committed —
3551            // never a silent move to 0, which would delete to the file start.
3552            Motion::SearchNext | Motion::SearchPrev => {
3553                let at = buf.position_to_char(pos).ok()?;
3554                let step = self
3555                    .search
3556                    .repeat(at, matches!(motion, Motion::SearchPrev))?;
3557                buf.char_to_position(step.target.start)
3558            }
3559            Motion::Left => Position::new(pos.line, pos.column.saturating_sub(1)),
3560            // Clamped to the line, which is what makes `x` (`dl`) safe to
3561            // express as a composition. Unclamped, an operator range built
3562            // over `Right` crosses the line TERMINATOR on an empty line — so
3563            // `x` there would join the next line onto this one instead of
3564            // doing nothing. The cursor path is unaffected: `place_cursor`
3565            // was already pulling `l` back onto the last character.
3566            Motion::Right => Position::new(
3567                pos.line,
3568                pos.column
3569                    .saturating_add(1)
3570                    .min(buf.line_len_chars(pos.line)),
3571            ),
3572            Motion::Up => Position::new(pos.line.saturating_sub(1), pos.column),
3573            Motion::Down => Position::new(pos.line.saturating_add(1), pos.column),
3574            Motion::LineStart => Position::new(pos.line, 0),
3575            Motion::LineEnd => Position::new(pos.line, buf.line_len_chars(pos.line)),
3576            Motion::LineFirstNonBlank => first_non_blank(buf, pos.line),
3577            // `_` — the same landing character as `^`, and the CURSOR path
3578            // cannot tell them apart. The difference is entirely in the kind,
3579            // which only an operator reads (`Motion::is_linewise`).
3580            Motion::LinewiseDown => first_non_blank(buf, pos.line),
3581            // `g_` — the last non-blank. Inclusive, so the operator widens it;
3582            // the resolver names the CHARACTER, which is where `$` differs.
3583            Motion::LineLastNonBlank => {
3584                let chars = line_chars(buf, pos.line);
3585                let col = chars
3586                    .iter()
3587                    .rposition(|c| !c.is_whitespace())
3588                    .and_then(|i| u32::try_from(i).ok())
3589                    .unwrap_or(0);
3590                Position::new(pos.line, col)
3591            }
3592            // `|` is 1-based, and clamped to the line rather than refused —
3593            // vim puts `500|` on the last character.
3594            Motion::Column(n) => Position::new(
3595                pos.line,
3596                n.saturating_sub(1).min(buf.line_len_chars(pos.line)),
3597            ),
3598            Motion::LineDownFirstNonBlank => {
3599                first_non_blank(buf, pos.line.saturating_add(1).min(last_text_line(buf)))
3600            }
3601            Motion::LineUpFirstNonBlank => first_non_blank(buf, pos.line.saturating_sub(1)),
3602            Motion::DocStart => Position::ZERO,
3603            Motion::DocEnd => Position::new(
3604                buf.line_count().saturating_sub(1),
3605                buf.line_len_chars(buf.line_count().saturating_sub(1)),
3606            ),
3607            Motion::WordStartNext => word_next(buf, pos, Width::Small),
3608            Motion::WordEndNext => word_end(buf, pos, Width::Small),
3609            Motion::WordStartPrev => word_prev(buf, pos, Width::Small),
3610            Motion::WordEndPrev => word_end_prev(buf, pos, Width::Small),
3611            Motion::BigWordStartNext => word_next(buf, pos, Width::Big),
3612            Motion::BigWordEndNext => word_end(buf, pos, Width::Big),
3613            Motion::BigWordStartPrev => word_prev(buf, pos, Width::Big),
3614            Motion::BigWordEndPrev => word_end_prev(buf, pos, Width::Big),
3615            Motion::FindChar { ch, backward, till } => find_char(buf, pos, ch, backward, till)?,
3616            // `;` / `,` resolve through the LAST `f`/`t`, which is runtime
3617            // state — the same shape as the search motions above, and the
3618            // reason neither can be resolved by the enum alone.
3619            Motion::RepeatFind { reverse } => {
3620                let last = self.last_find?;
3621                let backward = last.backward != reverse;
3622                find_char(buf, pos, last.ch, backward, last.till)?
3623            }
3624            Motion::MatchPair => self.resolve_match(buf, pos)?,
3625            // A mark that was never set is a FAILED motion, not a move to the
3626            // origin: `` `q `` with no `q` must leave the cursor alone, and
3627            // ``d`q`` must not delete to the top of the file.
3628            Motion::MarkExact(name) => {
3629                let at = *self.marks.get(&name)?;
3630                Position::new(
3631                    at.line.min(last_text_line(buf)),
3632                    at.column
3633                        .min(buf.line_len_chars(at.line.min(last_text_line(buf)))),
3634                )
3635            }
3636            Motion::MarkLine(name) => {
3637                let at = *self.marks.get(&name)?;
3638                first_non_blank(buf, at.line.min(last_text_line(buf)))
3639            }
3640            Motion::ParagraphNext => paragraph(buf, pos, true),
3641            Motion::ParagraphPrev => paragraph(buf, pos, false),
3642            Motion::SentenceNext => sentence(buf, pos, true),
3643            Motion::SentencePrev => sentence(buf, pos, false),
3644            // `H` / `M` / `L` are about the VIEWPORT, not the buffer — which
3645            // is what makes them the only motions whose target changes when
3646            // nothing in the text did.
3647            Motion::ScreenTop | Motion::ScreenMiddle | Motion::ScreenBottom => {
3648                let vp = self.layout.active_window().map_or(
3649                    Viewport {
3650                        top_line: 0,
3651                        left_column: 0,
3652                        visible_lines: 1,
3653                        visible_columns: 1,
3654                    },
3655                    |w| w.viewport,
3656                );
3657                let last = last_text_line(buf);
3658                let bottom = vp
3659                    .top_line
3660                    .saturating_add(vp.visible_lines.saturating_sub(1))
3661                    .min(last);
3662                let line = match motion {
3663                    Motion::ScreenTop => vp.top_line.min(last),
3664                    Motion::ScreenBottom => bottom,
3665                    _ => vp.top_line.min(last) + (bottom - vp.top_line.min(last)) / 2,
3666                };
3667                first_non_blank(buf, line)
3668            }
3669            Motion::PageDown | Motion::HalfPageDown => {
3670                Position::new(pos.line.saturating_add(10), pos.column)
3671            }
3672            Motion::PageUp | Motion::HalfPageUp => {
3673                Position::new(pos.line.saturating_sub(10), pos.column)
3674            }
3675            Motion::GotoLine(n) => Position::new(n.saturating_sub(1), 0),
3676            // Structural Lisp motions — stubs for phase 1.B; full paredit
3677            // semantics land when caixa-ast is wired to the active buffer.
3678            Motion::ForwardSexp
3679            | Motion::BackwardSexp
3680            | Motion::UpList
3681            | Motion::DownList
3682            | Motion::BeginningOfDefun
3683            | Motion::EndOfDefun
3684            | Motion::BeginningOfSexp
3685            | Motion::EndOfSexp => pos,
3686        })
3687    }
3688
3689    /// `zt` / `zz` / `zb` — re-frame the window around the cursor's line
3690    /// WITHOUT moving the cursor.
3691    ///
3692    /// The cursor is deliberately untouched: `zz` is what you press when you
3693    /// are already where you want to be and only the framing is wrong. Note
3694    /// that `set_cursor` would undo this — it scrolls the viewport to contain
3695    /// the cursor with a 2-line margin — so this must not route through it,
3696    /// and the next motion legitimately re-frames again.
3697    fn scroll_view(&mut self, align: escriba_core::ViewAlign) {
3698        use escriba_core::ViewAlign;
3699        let line = self.cursor().line;
3700        let Some(w) = self.layout.active_window_mut() else {
3701            return;
3702        };
3703        let h = w.viewport.visible_lines.max(1);
3704        w.viewport.top_line = match align {
3705            ViewAlign::Top => line,
3706            ViewAlign::Center => line.saturating_sub(h / 2),
3707            ViewAlign::Bottom => line.saturating_sub(h.saturating_sub(1)),
3708        };
3709        self.damage = self.damage.join(Damage::Full);
3710        self.bump_gen();
3711    }
3712
3713    /// Claim the operand of a pending `m` / `` ` `` / `'`, or arm one.
3714    ///
3715    /// Same key-layer shape as [`Self::consume_find_key`] and for the same
3716    /// reason: `ma` is `m` plus an OPERAND, and `a` is bound (append). Without
3717    /// claiming it first, `ma` would set no mark and enter Insert mode.
3718    ///
3719    /// Runs BEFORE `consume_object_key`, because `` d`a `` needs it: the
3720    /// object path claims `i` and `a` whenever an operator is armed, and the
3721    /// mark LETTER can be either of them.
3722    ///
3723    /// The two do not fight over the first key. This arms only while
3724    /// `pending_object` is clear, so `di'` — where `'` is a text-object
3725    /// delimiter rather than a mark jump — still reaches the object path. The
3726    /// guard states that dependency locally instead of leaving it implied by
3727    /// call order.
3728    fn consume_mark_key(&mut self, key: Key) -> Option<ObjectKey> {
3729        if let Some(kind) = self.pending_mark.take() {
3730            let Key::Char(name) = key else {
3731                if matches!(self.op_pending.state(), OpState::Awaiting { .. }) {
3732                    self.op_pending
3733                        .dispatch((Action::ChangeMode(Mode::Normal), 1));
3734                }
3735                return Some(ObjectKey::Consumed);
3736            };
3737            return Some(ObjectKey::Compose(match kind {
3738                MarkKey::Set => Action::SetMark(name),
3739                MarkKey::GotoExact => Action::Move(Motion::MarkExact(name)),
3740                MarkKey::GotoLine => Action::Move(Motion::MarkLine(name)),
3741            }));
3742        }
3743        if !matches!(self.modal.mode(), Mode::Normal | Mode::Visual) {
3744            return None;
3745        }
3746        // Half-typed text object (`di` waiting for its `'`) belongs to the
3747        // object path, not here; a key continuing a sequence belongs to the
3748        // sequence (see `consume_find_key` for the `zt` case that proves it).
3749        if self.pending_object.is_some() || !self.pending_keys.is_empty() {
3750            return None;
3751        }
3752        let Key::Char(c) = key else { return None };
3753        let kind = match c {
3754            'm' => MarkKey::Set,
3755            '`' => MarkKey::GotoExact,
3756            '\'' => MarkKey::GotoLine,
3757            _ => return None,
3758        };
3759        self.pending_mark = Some(kind);
3760        Some(ObjectKey::Consumed)
3761    }
3762
3763    /// `%` — brackets, plus this buffer's language word pairs if it has any.
3764    ///
3765    /// When both are candidates the NEARER one on the line wins, because that
3766    /// is the one under the operator's eye: on `if foo() then`, `%` on the
3767    /// `if` means the block and `%` on the `(` means the call. Deciding by
3768    /// distance rather than by precedence is what keeps both usable from the
3769    /// same key without a mode.
3770    fn resolve_match(&self, buf: &escriba_buffer::Buffer, pos: Position) -> Option<Position> {
3771        let Some(pairs) = self.word_pairs_for_active() else {
3772            return match_pair(buf, pos);
3773        };
3774        let bracket_col = line_chars(buf, pos.line)
3775            .into_iter()
3776            .enumerate()
3777            .skip(pos.column as usize)
3778            .find(|(_, c)| MATCH_PAIRS.iter().any(|&(o, cl)| *c == o || *c == cl))
3779            .and_then(|(i, _)| u32::try_from(i).ok());
3780        let word_col = word_hits(buf, pos.line, pairs)
3781            .into_iter()
3782            .find(|h| h.end > pos.column)
3783            .map(|h| h.col);
3784        match (bracket_col, word_col) {
3785            (Some(b), Some(w)) if w < b => match_word_pair(buf, pos, pairs),
3786            (Some(_), _) => match_pair(buf, pos),
3787            (None, Some(_)) => match_word_pair(buf, pos, pairs),
3788            (None, None) => None,
3789        }
3790    }
3791
3792    /// The word pairs for the active buffer's filetype, if the language has
3793    /// any. `None` for brace languages — Rust's `%` is bracket-only, and that
3794    /// is correct rather than missing.
3795    fn word_pairs_for_active(&self) -> Option<WordPairs> {
3796        let path = self.buffers.get(self.active)?.path.as_deref()?;
3797        let name = &self.filetypes.resolve(path)?.name;
3798        WORD_PAIRS
3799            .iter()
3800            .find(|(ft, _)| ft == name)
3801            .map(|(_, pairs)| *pairs)
3802    }
3803
3804    fn apply_motion(&mut self, motion: Motion) {
3805        // A bare search motion is a FAR JUMP and it REPORTS — it records into
3806        // the jumplist, prints vim's "hit BOTTOM" on a wrap, and says E486
3807        // when nothing matches. `resolve_motion` can do none of that: it is
3808        // deliberately pure because the OPERATOR path calls it to find a range
3809        // without moving the cursor. So `n` routes to the one executor that
3810        // owns those side effects, and `Action::SearchRepeat` routes to the
3811        // same place — one code path, two spellings.
3812        if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
3813            self.jump_search(matches!(motion, Motion::SearchPrev));
3814            return;
3815        }
3816        let Some(pos) = self.resolve_motion(self.cursor(), motion) else {
3817            return;
3818        };
3819        // The single cursor-mutation path clamps to the buffer and scrolls
3820        // the viewport to contain the cursor on both axes.
3821        self.set_cursor(pos);
3822    }
3823
3824    /// Apply an operator over a motion — the vim `{operator}{motion}` verbs
3825    /// (`dw` delete-word, `c$` change-to-line-end, `y0` yank-to-line-start).
3826    /// Composition is explicit: the motion resolves a target via
3827    /// [`resolve_motion`](Self::resolve_motion); the operator acts over the
3828    /// `[cursor, target)` range. Register-leaving operators
3829    /// ([`Operator::leaves_register`]) capture the text first.
3830    /// Apply `op` over `motion` resolved `n` times from the cursor.
3831    ///
3832    /// `n == 1` is the ordinary path. Larger `n` walks the motion forward
3833    /// first and operates over the whole span in one go, which is what vim
3834    /// means by `3dw` — and the only way a non-moving operator like yank can
3835    /// honour a count at all.
3836    fn apply_operator_n(&mut self, op: Operator, motion: Motion, n: u32) {
3837        if n <= 1 {
3838            self.apply_operator(op, motion);
3839            return;
3840        }
3841        let from = self.cursor();
3842        let mut to = from;
3843        for _ in 0..n {
3844            match self.resolve_motion(to, motion) {
3845                Some(next) if next != to => to = next,
3846                // The motion stopped making progress (start/end of buffer):
3847                // operate over what we reached rather than aborting, which is
3848                // what vim does for `999dw` near the end of a file.
3849                _ => break,
3850            }
3851        }
3852        if to == from {
3853            // Nothing to operate over. Fall through to the single-step path
3854            // so its error reporting (E35, pattern-not-found) still runs.
3855            self.apply_operator(op, motion);
3856            return;
3857        }
3858        if let Some(extent) = self.operated_extent(motion, from, to) {
3859            self.apply_operator_over(op, extent);
3860        }
3861    }
3862
3863    /// `;` inherits the inclusiveness of the find it repeats — resolve it to
3864    /// that concrete motion rather than teaching [`Motion::is_inclusive`] about
3865    /// state it cannot see. `d;` after `fx` must delete THROUGH the `x`.
3866    ///
3867    /// Returns the motion unchanged when there is no find to repeat, so the
3868    /// caller's `is_inclusive` question gets `false` rather than a wrong answer.
3869    fn concrete_motion(&self, motion: Motion) -> Motion {
3870        match motion {
3871            Motion::RepeatFind { reverse } => match self.last_find {
3872                Some(f) => Motion::FindChar {
3873                    ch: f.ch,
3874                    backward: f.backward != reverse,
3875                    till: f.till,
3876                },
3877                None => motion,
3878            },
3879            m => m,
3880        }
3881    }
3882
3883    /// One character to the right, clamped to the line.
3884    ///
3885    /// The whole of what "inclusive" means to an operator: a range is
3886    /// `[start, end)`, so acting ON a character means the range must end
3887    /// AFTER it.
3888    fn widen_one(&self, pos: Position) -> Position {
3889        let line_len = self
3890            .buffers
3891            .get(self.active)
3892            .map_or(pos.column, |b| b.line_len_chars(pos.line));
3893        Position::new(pos.line, pos.column.saturating_add(1).min(line_len))
3894    }
3895
3896    /// Widen an INCLUSIVE motion's target to the exclusive end an operator
3897    /// range needs. See [`Motion::is_inclusive`].
3898    ///
3899    /// Applied at the OPERATOR, never inside `resolve_motion`: the same
3900    /// resolution has to serve the cursor path, where `e` must land ON the
3901    /// last character, and the range path, where the range must end after it.
3902    /// One target, two readings — putting the widening in the resolver would
3903    /// move `e` itself one character too far.
3904    ///
3905    /// **Forward motions only.** A backward-inclusive motion (`ge`) widens the
3906    /// CURSOR instead, which this function cannot express because it is handed
3907    /// one position; [`Self::operated_extent`] owns that split.
3908    fn operated_end(&self, motion: Motion, to: Position) -> Position {
3909        if !self.concrete_motion(motion).is_inclusive() {
3910            return to;
3911        }
3912        self.widen_one(to)
3913    }
3914
3915    fn apply_operator(&mut self, op: Operator, motion: Motion) {
3916        let from = self.cursor();
3917        let Some(to) = self.resolve_motion(from, motion) else {
3918            // A motion that cannot resolve aborts the operator with the buffer
3919            // untouched. A search motion says WHY — `dn` with no pattern armed
3920            // is otherwise indistinguishable from a dropped keystroke, which
3921            // is the same complaint that motivated E486 on the bare path.
3922            if matches!(motion, Motion::SearchNext | Motion::SearchPrev) {
3923                if self.search.pattern().is_none() {
3924                    self.messages
3925                        .push("E35: No previous regular expression".to_string());
3926                } else {
3927                    self.report_pattern_not_found();
3928                }
3929            }
3930            return;
3931        };
3932        if let Some(extent) = self.operated_extent(motion, from, to) {
3933            self.apply_operator_over(op, extent);
3934        }
3935    }
3936
3937    /// Apply `op` over `[cursor, to)`.
3938    ///
3939    /// Split out of [`Self::apply_operator`] so the operated-search path can
3940    /// reach the same range machinery with a target it resolved itself — the
3941    /// alternative was a second copy of the delete/yank/register logic, which
3942    /// is how the two would drift.
3943    /// The extent an operated MOTION names — vim's three motion kinds
3944    /// resolved in ONE place.
3945    ///
3946    /// Both motion call sites route through here rather than building an
3947    /// extent themselves, because "which kind is this motion" is a property of
3948    /// the motion and must have exactly one answer. It already had two rules
3949    /// (`operated_end`'s inclusive widening); `is_linewise` is the third, and
3950    /// a fourth lands here rather than at whichever call site notices it.
3951    ///
3952    /// A linewise motion that resolves to the SAME line still names that line:
3953    /// `dj` on the last line is a failed motion (`resolve_motion` returns the
3954    /// cursor) and vim refuses it, but `d_` and `dH`-on-the-top-line are one
3955    /// whole line, which is what the caller's own emptiness check decides.
3956    fn operated_extent(&self, motion: Motion, from: Position, to: Position) -> Option<Extent> {
3957        if motion.is_linewise() {
3958            return self.line_span(from.line, to.line);
3959        }
3960        // vim's rule for an inclusive motion is "the last character towards the
3961        // END OF THE BUFFER is included" — not "the target is included". For a
3962        // forward motion those are the same sentence and the distinction never
3963        // shows. For a BACKWARD one (`ge`, `gE`) the buffer-end of the range is
3964        // the CURSOR, so the widening flips sides: `dge` must take the
3965        // character under the cursor, and widening the target instead would
3966        // eat one character too far back and leave the cursor's behind.
3967        //
3968        // Which way the motion ran is knowable only here, after resolution —
3969        // it is a fact about this press, not about the motion.
3970        let inclusive = self.concrete_motion(motion).is_inclusive();
3971        let range = if inclusive && to < from {
3972            Range {
3973                start: to,
3974                end: self.widen_one(from),
3975            }
3976        } else {
3977            Range {
3978                start: from,
3979                end: self.operated_end(motion, to),
3980            }
3981        };
3982        Some(Extent::charwise(range))
3983    }
3984
3985    fn apply_operator_to(&mut self, op: Operator, to: Position) {
3986        let from = self.cursor();
3987        // A motion-shaped operation is charwise by construction: it acts over
3988        // `[cursor, point)`, which is a run of characters even when that run
3989        // happens to span a line break (`d}`).
3990        self.apply_operator_over(
3991            op,
3992            Extent::charwise(Range {
3993                start: from,
3994                end: to,
3995            }),
3996        );
3997    }
3998
3999    /// Apply `op` over an explicit range, capturing it as `kind`.
4000    ///
4001    /// The object path needs this: `gn`'s extent need not begin at the cursor,
4002    /// so it cannot go through the `[cursor, target)` shape the motion path
4003    /// uses. One implementation of the delete/yank/register logic, reached two
4004    /// ways.
4005    ///
4006    /// `kind` is a PARAMETER rather than something inferred from the range,
4007    /// and it has to be: `[(1,0), (2,0))` is the range `dd` produces on line 1
4008    /// AND the range `dj`-ish charwise motions produce, and nothing about the
4009    /// two positions distinguishes them. Only the caller knows which gesture
4010    /// it was. It travels to the register, and the register is what a later
4011    /// `p` reads to decide between splicing and opening a line.
4012    fn apply_operator_over(&mut self, op: Operator, extent: Extent) {
4013        let Extent {
4014            capture,
4015            removal,
4016            kind,
4017        } = extent.normalized();
4018        if capture.is_empty() {
4019            return;
4020        }
4021        // Capture the operated text (for the register) before mutating.
4022        let text = self
4023            .buffers
4024            .get(self.active)
4025            .and_then(|buf| buf.slice(capture).ok());
4026        if op.leaves_register() {
4027            if let Some(t) = &text {
4028                let captured = match kind {
4029                    RegisterKind::Charwise => t.clone(),
4030                    RegisterKind::Linewise => as_linewise_capture(t),
4031                };
4032                self.register = Some(Register::new(captured, kind));
4033            }
4034        }
4035        match op {
4036            // Delete + Change remove the range; Change then enters Insert so
4037            // the operator pairs with immediate typing (`ciw`, `c$`).
4038            //
4039            // They remove DIFFERENT ranges for a linewise extent, which is the
4040            // whole reason `Extent` carries two: `dd` takes the line and its
4041            // terminator, `cc` clears the line's text and KEEPS the line,
4042            // because you are changing its contents rather than removing it.
4043            // Both leave the same thing in the register.
4044            Operator::Delete | Operator::Change => {
4045                let cut = if op == Operator::Change {
4046                    removal
4047                } else {
4048                    capture
4049                };
4050                if cut.is_empty() {
4051                    // `cc` on an already-empty line: nothing to clear, but the
4052                    // gesture still means "type here".
4053                    self.rest_after_operator(kind, cut.start);
4054                    self.modal.enter(Mode::Insert);
4055                    return;
4056                }
4057                if let Some(buf) = self.buffers.get_mut(self.active) {
4058                    let _ = buf.apply(&Edit::delete(cut));
4059                }
4060                self.rest_after_operator(kind, cut.start);
4061                if op == Operator::Change {
4062                    self.modal.enter(Mode::Insert);
4063                }
4064            }
4065            // Yank copies to the register without mutating the buffer, so it
4066            // gets its OWN resting rule rather than the delete rule above: the
4067            // line is still there, and vim moves the cursor only when the yank
4068            // reached BACKWARDS past it.
4069            //
4070            // Keyed on the kind for the same reason everything else here is.
4071            // A linewise yank compares LINES — `yy` and `3yy` both start on
4072            // the cursor's own line, so neither moves, which is why comparing
4073            // POSITIONS was wrong: `yy`'s range starts at column 0, so it read
4074            // as "backwards" and knocked the cursor to the left margin every
4075            // time you copied a line. Invisible for `yw`, whose range starts
4076            // exactly at the cursor, so the move was a no-op there.
4077            Operator::Yank => {
4078                let here = self.cursor();
4079                match kind {
4080                    RegisterKind::Linewise if capture.start.line < here.line => {
4081                        // Keep the column: a backwards linewise yank rests on
4082                        // the first line taken, not at its margin.
4083                        self.set_cursor(Position::new(capture.start.line, here.column));
4084                    }
4085                    RegisterKind::Charwise
4086                        if (capture.start.line, capture.start.column)
4087                            < (here.line, here.column) =>
4088                    {
4089                        self.set_cursor(capture.start);
4090                    }
4091                    _ => {}
4092                }
4093            }
4094            // Indent/Format/structural operators are not yet wired — named,
4095            // not faked (no buffer mutation, register already captured for the
4096            // register-leaving ones above).
4097            _ => {
4098                self.messages
4099                    .push("operator not yet implemented".to_owned());
4100            }
4101        }
4102    }
4103
4104    /// `r{char}` — overwrite `count` characters from the cursor with `char`.
4105    ///
4106    /// Three things it deliberately is NOT, each of which a `Change`-operator
4107    /// composition would get wrong: it does not enter Insert, it does not
4108    /// touch the register, and it REFUSES rather than truncating when the
4109    /// count runs past the end of the line. vim's rule is that `5rx` on a
4110    /// three-character tail does nothing at all — a partial replace would
4111    /// silently destroy two characters you did not mean to name.
4112    fn replace_char(&mut self, ch: char, count: u32) {
4113        let n = count.max(1);
4114        let here = self.cursor();
4115        let Some(buf) = self.buffers.get(self.active) else {
4116            return;
4117        };
4118        let len = buf.line_len_chars(here.line);
4119        if here.column.saturating_add(n) > len {
4120            // Silent, like vim. The line is short — there is nothing to say
4121            // that the unchanged text does not already say.
4122            return;
4123        }
4124        let end = Position::new(here.line, here.column + n);
4125        let mut text = String::with_capacity(n as usize);
4126        for _ in 0..n {
4127            text.push(ch);
4128        }
4129        let Some(buf) = self.buffers.get_mut(self.active) else {
4130            return;
4131        };
4132        if buf
4133            .apply(&Edit::replace(Range::new(here, end), text))
4134            .is_err()
4135        {
4136            return;
4137        }
4138        // vim leaves the cursor on the LAST character replaced, not after it.
4139        self.set_cursor(Position::new(here.line, here.column + n - 1));
4140    }
4141
4142    /// `J` / `gJ` — join `count` lines into one.
4143    ///
4144    /// One `Edit::replace` over the whole span rather than `n` splices, so a
4145    /// `3J` is one `u` away from gone and the damage classifier sees a single
4146    /// line-count change.
4147    ///
4148    /// `space: true` (`J`) drops the next line's leading whitespace and puts a
4149    /// single space in the newline's place, with vim's two exceptions: no
4150    /// space is added when the line already ends in one, or when the next line
4151    /// starts with `)`. `space: false` (`gJ`) splices verbatim — the reason to
4152    /// reach for it is that `J` is lossy.
4153    fn join_lines(&mut self, space: bool, count: u32) {
4154        // `J` and `2J` both mean "join ONE following line": vim counts LINES
4155        // involved, not joins performed, so the join count is `count - 1`
4156        // floored at 1.
4157        let joins = count.max(2) - 1;
4158        let here = self.cursor();
4159        let Some(buf) = self.buffers.get(self.active) else {
4160            return;
4161        };
4162        let last = last_text_line(buf);
4163        if here.line >= last {
4164            // Nothing below to join. vim beeps; escriba says so, because a key
4165            // that silently does nothing is indistinguishable from an unbound
4166            // one — which is how `<C-h>` hid for a month.
4167            self.messages
4168                .push("E36: Not enough lines to join".to_string());
4169            return;
4170        }
4171        let end_line = here.line.saturating_add(joins).min(last);
4172        // `Buffer::line` INCLUDES the trailing newline and `line_len_chars`
4173        // excludes it — a mismatch that made the first cut of this splice the
4174        // terminators back in and then leave the originals behind, so `J`
4175        // produced the file unchanged plus a blank line. `line_chars` is the
4176        // newline-free reading every motion already uses.
4177        let line_text = |l: u32| line_chars(buf, l).into_iter().collect::<String>();
4178        let mut joined = line_text(here.line);
4179        // Where the cursor lands: vim puts it ON the join — the position the
4180        // newline used to occupy, which is the space it inserted.
4181        let mut caret = u32::try_from(joined.chars().count()).unwrap_or(0);
4182        for l in (here.line + 1)..=end_line {
4183            let next = line_text(l);
4184            caret = u32::try_from(joined.chars().count()).unwrap_or(0);
4185            if space {
4186                let trimmed = next.trim_start();
4187                let needs_space = !joined.is_empty()
4188                    && !joined.ends_with(char::is_whitespace)
4189                    && !trimmed.starts_with(')')
4190                    && !trimmed.is_empty();
4191                if needs_space {
4192                    joined.push(' ');
4193                }
4194                joined.push_str(trimmed);
4195            } else {
4196                joined.push_str(&next);
4197            }
4198        }
4199        let span = Range::new(
4200            Position::new(here.line, 0),
4201            Position::new(end_line, buf.line_len_chars(end_line)),
4202        );
4203        let Some(buf) = self.buffers.get_mut(self.active) else {
4204            return;
4205        };
4206        if buf.apply(&Edit::replace(span, joined)).is_err() {
4207            return;
4208        }
4209        self.set_cursor(Position::new(here.line, caret));
4210    }
4211
4212    /// Where the cursor rests once an operator has finished.
4213    ///
4214    /// Keyed on the operated KIND rather than on the operator, because that is
4215    /// what vim keys it on: every linewise operation lands the cursor the same
4216    /// way regardless of which operator produced it.
4217    ///
4218    /// The charwise arm is the old unconditional behaviour — the range start,
4219    /// which is where the text used to begin.
4220    fn rest_after_operator(&mut self, kind: RegisterKind, start: Position) {
4221        match kind {
4222            RegisterKind::Charwise => self.set_cursor(start),
4223            RegisterKind::Linewise => {
4224                // vim's linewise rule: the cursor lands on the FIRST NON-BLANK
4225                // of the line that now occupies the operated line's index, and
4226                // never past the last line that holds text.
4227                //
4228                // Both halves were wrong, and each in a way no unit test could
4229                // see, because both are about WHERE THE CURSOR IS rather than
4230                // what the text says — and every `dd` test asserted the text.
4231                //
4232                //   - Column 0 instead of the first non-blank is untidy on flat
4233                //     prose and actively wrong on indented code: `dd` inside a
4234                //     nested block dropped the cursor into the indentation, so
4235                //     the next `i` typed at the margin.
4236                //   - Landing past the last line of text was worse. A file
4237                //     ending in `\n` makes the rope report a phantom final
4238                //     line (see `last_text_line`); `dd` on the last REAL line
4239                //     parked the cursor on that phantom row, where `x` and `i`
4240                //     had nothing to act on and the next `dd` deleted the
4241                //     file's trailing NEWLINE rather than a line.
4242                let at = match self.buffers.get(self.active) {
4243                    Some(buf) => first_non_blank(buf, start.line.min(last_text_line(buf))),
4244                    None => return,
4245                };
4246                self.set_cursor(at);
4247            }
4248        }
4249    }
4250
4251    /// `p` / `P` — put `count` copies of the register back into the buffer.
4252    ///
4253    /// **The register's [`RegisterKind`] chooses the operation, not the key.**
4254    /// `p` after `dw` splices characters in at a column; `p` after `dd` opens
4255    /// a whole line below. That is why the capture had to become typed before
4256    /// this could exist at all: a `String` register leaves `p` guessing, and
4257    /// the only guess available — splice — drops a whole line, terminator and
4258    /// all, into the middle of whatever line the cursor is on.
4259    ///
4260    /// One [`Edit`] regardless of `count`, so `3p` is one `u` away from gone.
4261    fn put(&mut self, before: bool, count: u32) {
4262        let Some(reg) = self.register.clone() else {
4263            // vim says nothing for a put with an empty register, and neither
4264            // does this — but it must not fall through to an insert of `""`
4265            // either, which would record a `last_change` that `.` then
4266            // replays as a no-op edit.
4267            return;
4268        };
4269        let text = reg.replayed(count);
4270        if text.is_empty() {
4271            return;
4272        }
4273        let Some(buf) = self.buffers.get(self.active) else {
4274            return;
4275        };
4276        let here = self.cursor();
4277        let (at, rest) = match reg.kind {
4278            RegisterKind::Linewise => {
4279                // `p` opens BELOW the cursor's line, `P` above. The insertion
4280                // point is the start of a line either way, and the text ends
4281                // in a newline (`Register::replayed` guarantees it), so the
4282                // splice pushes the existing line down rather than joining it.
4283                //
4284                // `line + 1` is a valid insertion point even on the last line:
4285                // a file ending in `\n` has the phantom row there, and one
4286                // that does not gets the newline from `replayed`.
4287                let line = if before {
4288                    here.line
4289                } else {
4290                    here.line.saturating_add(1)
4291                };
4292                let at = Position::new(line.min(buf.line_count()), 0);
4293                // vim rests on the first non-blank of the FIRST line put.
4294                (at, PutRest::LineStart(at.line))
4295            }
4296            RegisterKind::Charwise => {
4297                // `p` lands AFTER the character under the cursor, `P` on it.
4298                // Appending past the end of the line is legal here — that is
4299                // what makes `p` on the last character of a line work — so
4300                // this clamps to the line length, not to the last character.
4301                let col = if before {
4302                    here.column
4303                } else {
4304                    here.column
4305                        .saturating_add(1)
4306                        .min(buf.line_len_chars(here.line))
4307                };
4308                (Position::new(here.line, col), PutRest::LastCharPut)
4309            }
4310        };
4311        let Some(buf) = self.buffers.get_mut(self.active) else {
4312            return;
4313        };
4314        if buf.apply(&Edit::insert(at, text.clone())).is_err() {
4315            return;
4316        }
4317        match rest {
4318            PutRest::LineStart(line) => {
4319                let to = match self.buffers.get(self.active) {
4320                    Some(b) => first_non_blank(b, line.min(last_text_line(b))),
4321                    None => return,
4322                };
4323                self.set_cursor(to);
4324            }
4325            // vim leaves the cursor ON the last character put, not after it —
4326            // which is what makes `p` then `.`-less repeated puts stack rather
4327            // than march right. Routed through `set_cursor` (an `OnCharacter`
4328            // rest) so Normal mode's on-a-character invariant still applies.
4329            PutRest::LastCharPut => {
4330                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
4331                let end = if let Some(nl) = text.rfind('\n') {
4332                    let tail = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
4333                    Position::new(at.line + added_lines, tail)
4334                } else {
4335                    let n = u32::try_from(text.chars().count()).unwrap_or(0);
4336                    Position::new(at.line, at.column.saturating_add(n))
4337                };
4338                self.set_cursor(Position::new(end.line, end.column.saturating_sub(1)));
4339            }
4340        }
4341    }
4342
4343    /// The text last yanked or deleted into the unnamed register, if any.
4344    /// `p`/`P` read this — through [`Register`], so they can tell a captured
4345    /// LINE from a captured run of characters.
4346    #[must_use]
4347    pub fn register(&self) -> Option<&Register> {
4348        self.register.as_ref()
4349    }
4350
4351    /// The register's raw text, for callers that only want the characters.
4352    #[must_use]
4353    pub fn register_text(&self) -> Option<&str> {
4354        self.register.as_ref().map(|r| r.text.as_str())
4355    }
4356
4357    fn insert_char(&mut self, c: char) {
4358        if self.modal.mode() == Mode::Command {
4359            // A search prompt and an ex-command share Command mode (vim's
4360            // cmdline). `search.is_prompting()` is the typed discriminator —
4361            // it can only be true when `/` or `?` actually opened a prompt.
4362            if self.search.is_prompting() {
4363                // The search prompt is the SOLE store while it is open.
4364                //
4365                // This used to also `push_minibuffer(c)`, and the two stores
4366                // insert differently — `search.push` at the caret, the
4367                // minibuffer always at the end — so `/fo<Left>X` left them
4368                // reading `fXo` and `foX`. That was one of FIVE desync paths;
4369                // the caret moves, forward-delete, delete-word and
4370                // clear-to-start never touched the shadow at all.
4371                //
4372                // Deleting the write costs nothing because `status_model`
4373                // already selects the minibuffer only on the `prompt == None`
4374                // branch — the shadow is the EX-LINE's store, and while a
4375                // search prompt is open nothing reads it.
4376                self.search.push(c);
4377                self.preview_search();
4378            } else {
4379                self.modal.push_minibuffer(c);
4380            }
4381            return;
4382        }
4383        let cursor = self.cursor();
4384        let Some(buf) = self.buffers.get_mut(self.active) else {
4385            return;
4386        };
4387        let edit = Edit::insert(cursor, c.to_string());
4388        if buf.apply(&edit).is_ok() {
4389            let next = if c == '\n' {
4390                Position::new(cursor.line.saturating_add(1), 0)
4391            } else {
4392                cursor.shift_right(1)
4393            };
4394            // Route through the single cursor-mutation path so the viewport
4395            // follows the cursor (both axes) and the cursor stays clamped.
4396            self.place_cursor(next, CursorRest::AtInsertPoint);
4397        }
4398    }
4399
4400    /// Enter Insert mode at `at` — the ONE body behind `i` `I` `a` `A` `o` `O`.
4401    ///
4402    /// # What lets `A` park past the last character
4403    ///
4404    /// [`Self::place_cursor`] pulls a caret back to `len - 1` — but only when
4405    /// **both** halves of its guard hold: `rest == CursorRest::OnCharacter`
4406    /// *and* the mode is `Normal`. `A` and `a`-at-end-of-line need that clamp
4407    /// lifted, and this function lifts it twice over: it enters Insert before
4408    /// placing anything, and it asks for `CursorRest::AtInsertPoint`.
4409    ///
4410    /// **Either one alone is sufficient**, which is worth writing down because
4411    /// it is the opposite of what it looks like. An earlier version of this
4412    /// comment claimed the ORDER was load-bearing on its own; the red run
4413    /// refuted it — reversing the order while keeping `AtInsertPoint` stays
4414    /// green, and so does `OnCharacter` while entering Insert first. Only
4415    /// removing BOTH goes red, and then `a` on the `o` of "hello" reports
4416    /// column 4 instead of 5: the caret sits back on the character it was meant
4417    /// to append after. Belt and braces here is deliberate — the two guards
4418    /// answer different questions ("what kind of place is this?" and "what mode
4419    /// are we in?") and a later refactor is free to change one.
4420    ///
4421    /// `o`/`O` are in this function rather than in an `Edit` action because
4422    /// they are ONE gesture: vim's `o` is not "insert a newline, then enter
4423    /// insert" — the caret must land on the new line, and an operator watching
4424    /// two separate actions would record two dot-repeat entries for one press.
4425    fn enter_insert_at(&mut self, at: InsertAt) {
4426        self.modal.enter_insert();
4427        let cursor = self.cursor();
4428        // Resolve everything that needs the buffer BEFORE mutating, so the
4429        // immutable borrow ends before `place_cursor`/`apply` want `&mut self`.
4430        let Some(buf) = self.buffers.get(self.active) else {
4431            return;
4432        };
4433        let line_len = buf.line_len_chars(cursor.line);
4434        let target = match at {
4435            // `i` — the caret is already the insert point.
4436            InsertAt::Caret => Some(cursor),
4437            // One past the last char is a legal insert point, which is what
4438            // lets `a` on the final character append rather than stall.
4439            InsertAt::AfterCaret => Some(Position::new(
4440                cursor.line,
4441                cursor.column.saturating_add(1).min(line_len),
4442            )),
4443            InsertAt::LineEnd => Some(Position::new(cursor.line, line_len)),
4444            InsertAt::FirstNonBlank => Some(first_non_blank(buf, cursor.line)),
4445            // Handled below — these two edit the buffer first.
4446            InsertAt::OpenBelow | InsertAt::OpenAbove => None,
4447        };
4448        if let Some(pos) = target {
4449            self.place_cursor(pos, CursorRest::AtInsertPoint);
4450            return;
4451        }
4452        // `o`/`O` — open a line by inserting the terminator at the boundary the
4453        // direction names, then land on the fresh line. Expressed as an
4454        // `Edit::insert` through `Buffer::apply` so it joins the undo history
4455        // the same way typed text does.
4456        let (at_pos, land_on) = match at {
4457            InsertAt::OpenBelow => (
4458                Position::new(cursor.line, line_len),
4459                Position::new(cursor.line.saturating_add(1), 0),
4460            ),
4461            // Inserting at column 0 pushes the current line DOWN, so the fresh
4462            // line takes the caret's own line number.
4463            _ => (Position::new(cursor.line, 0), Position::new(cursor.line, 0)),
4464        };
4465        let Some(buf) = self.buffers.get_mut(self.active) else {
4466            return;
4467        };
4468        if buf.apply(&Edit::insert(at_pos, "\n")).is_ok() {
4469            self.place_cursor(land_on, CursorRest::AtInsertPoint);
4470        }
4471    }
4472
4473    /// `<BS>` against the BUFFER — the Insert-mode arm of [`Action::Backspace`].
4474    ///
4475    /// Deletes `[target, cursor)` where `target` is the previous character
4476    /// position, so column 0 JOINS with the line above rather than stopping
4477    /// dead: the range spans the newline and one `Edit::delete` removes it.
4478    /// `Motion::Left` cannot express that — it saturates at column 0, which is
4479    /// why this does not route through `apply_operator`.
4480    ///
4481    /// The other reason it does not: `Operator::Delete` captures the unnamed
4482    /// register, and vim's insert-mode backspace does not. Erasing a typo
4483    /// should not silently overwrite what you yanked to paste.
4484    fn delete_before_cursor(&mut self) {
4485        let cursor = self.cursor();
4486        let Some(buf) = self.buffers.get(self.active) else {
4487            return;
4488        };
4489        let target = if cursor.column > 0 {
4490            Position::new(cursor.line, cursor.column.saturating_sub(1))
4491        } else if cursor.line > 0 {
4492            let above = cursor.line.saturating_sub(1);
4493            Position::new(above, buf.line_len_chars(above))
4494        } else {
4495            // Start of the document — nothing to the left. A no-op, not a
4496            // clamp onto something else.
4497            return;
4498        };
4499        self.erase_back_to(target);
4500    }
4501
4502    /// Delete `[target, cursor)` and park the caret on `target`.
4503    ///
4504    /// The shared body of every BACKWARD erase against the buffer — `<BS>`,
4505    /// `<C-w>`, `<C-u>`. They differ only in how far back they reach, so the
4506    /// two properties that must hold for all three live here once rather than
4507    /// three times: the edit does NOT route through `apply_operator` (see
4508    /// [`Self::delete_before_cursor`] for both reasons), and the caret lands
4509    /// via `set_cursor` so the viewport follows and the clamp still runs.
4510    ///
4511    /// A `target` at or after the cursor is a no-op. That is the guard that
4512    /// makes the callers safe to write as "resolve a position, hand it over":
4513    /// `word_prev` returns the cursor unchanged at column 0 and
4514    /// `first_non_blank` returns a position AHEAD of the cursor inside an
4515    /// indent, and a reversed `Range` would be a delete of unknown extent
4516    /// rather than nothing.
4517    fn erase_back_to(&mut self, target: Position) {
4518        let cursor = self.cursor();
4519        if (target.line, target.column) >= (cursor.line, cursor.column) {
4520            return;
4521        }
4522        let edit = Edit::delete(Range {
4523            start: target,
4524            end: cursor,
4525        });
4526        if let Some(buf) = self.buffers.get_mut(self.active) {
4527            if buf.apply(&edit).is_ok() {
4528                self.set_cursor(target);
4529            }
4530        }
4531    }
4532
4533    /// `<C-w>` against the BUFFER — the Insert-mode arm of
4534    /// [`Action::DeleteWordBefore`].
4535    ///
4536    /// Reaches back over `Motion::WordStartPrev`, the SAME resolver the cursor
4537    /// move and the operator range already stand on, so `<C-w>` and `db` agree
4538    /// on where a word starts by construction instead of by two hand-written
4539    /// scans that drift.
4540    ///
4541    /// `word_prev` is single-line and returns the cursor unchanged at column 0,
4542    /// which would make `<C-w>` a dead key at the start of a line. vim erases
4543    /// the line break there, so the zero-width case falls through to
4544    /// [`Self::delete_before_cursor`] — one character back, which at column 0
4545    /// IS the newline.
4546    fn delete_word_before_cursor(&mut self) {
4547        let cursor = self.cursor();
4548        let Some(target) = self.resolve_motion(cursor, Motion::WordStartPrev) else {
4549            return;
4550        };
4551        if (target.line, target.column) >= (cursor.line, cursor.column) {
4552            self.delete_before_cursor();
4553            return;
4554        }
4555        self.erase_back_to(target);
4556    }
4557
4558    /// `<C-u>` against the BUFFER — the Insert-mode arm of
4559    /// [`Action::DeleteToLineStart`].
4560    ///
4561    /// Two-step, as vim is: the first press erases back to the first non-blank
4562    /// (what you typed), and a second press — now sitting ON the first
4563    /// non-blank, so that target is no longer behind the cursor — erases the
4564    /// indent. Collapsing the two into "always column 0" would destroy
4565    /// alignment on the first press, which is the one the hands reach for.
4566    ///
4567    /// Never joins with the line above: `<C-u>` is a line-scoped verb, and at
4568    /// column 0 it is a no-op rather than a silent line-merge.
4569    fn delete_to_line_start(&mut self) {
4570        let cursor = self.cursor();
4571        let Some(indent) = self.resolve_motion(cursor, Motion::LineFirstNonBlank) else {
4572            return;
4573        };
4574        let target = if (indent.line, indent.column) < (cursor.line, cursor.column) {
4575            indent
4576        } else {
4577            Position::new(cursor.line, 0)
4578        };
4579        self.erase_back_to(target);
4580    }
4581
4582    /// `<Del>` against the BUFFER — the Insert-mode arm of
4583    /// [`Action::DeleteForward`]. The cursor does NOT move: forward-delete
4584    /// pulls the rest of the line leftwards under a stationary caret.
4585    fn delete_after_cursor(&mut self) {
4586        let cursor = self.cursor();
4587        let Some(buf) = self.buffers.get(self.active) else {
4588            return;
4589        };
4590        let target = if cursor.column < buf.line_len_chars(cursor.line) {
4591            Position::new(cursor.line, cursor.column.saturating_add(1))
4592        } else if cursor.line.saturating_add(1) < buf.line_count() {
4593            // At end-of-line the character ahead IS the newline, so this
4594            // joins the line below — the mirror of `delete_before_cursor`.
4595            Position::new(cursor.line.saturating_add(1), 0)
4596        } else {
4597            return;
4598        };
4599        let edit = Edit::delete(Range {
4600            start: cursor,
4601            end: target,
4602        });
4603        if let Some(buf) = self.buffers.get_mut(self.active) {
4604            let _ = buf.apply(&edit);
4605        }
4606    }
4607
4608    /// Backspace inside a prompt. Keeps the search buffer and the displayed
4609    /// minibuffer in lockstep — if only one shrank, the pattern submitted
4610    /// would differ from the text on screen.
4611    fn prompt_backspace(&mut self) -> bool {
4612        if self.modal.mode() != Mode::Command {
4613            return false;
4614        }
4615        if self.search.is_prompting() {
4616            // Backspacing past the `/` closes the prompt, as vim does. No
4617            // `pop_minibuffer` here for the same reason as `insert_char`: the
4618            // shadow is the ex-line's, and popping its TAIL when the caret is
4619            // mid-pattern was another desync path.
4620            if self.search.backspace() {
4621                self.modal.clear_minibuffer();
4622                self.modal.enter(Mode::Normal);
4623            }
4624            // Never `pop_minibuffer` on the search path: it pops the TAIL,
4625            // while `search.backspace()` removes the char before the CARET.
4626            return true;
4627        }
4628        self.modal.pop_minibuffer();
4629        true
4630    }
4631
4632    fn submit_command(&mut self) {
4633        // Read the command line BEFORE leaving Command mode — the minibuffer
4634        // exists only in the `Command` variant, so the escape must come
4635        // after the capture.
4636        let line = self.modal.minibuffer().to_string();
4637        self.modal.escape();
4638        // The ex-name grammar — vim's abbreviations and its `!` — lives in
4639        // `escriba_command::ex` and NOT here. It used to be three arms in a
4640        // `match` at the bottom of this file (`"w" => "save"`, …), which is
4641        // why `:wq` reported "command not found" while `:w` and `:q` both
4642        // worked: there was nowhere for a compound spelling to be known.
4643        let Some(inv) = escriba_command::ex::parse(&line) else {
4644            return;
4645        };
4646        self.run_command(&inv.command, &inv.args);
4647    }
4648
4649    fn run_command(&mut self, name: &str, args: &[String]) {
4650        // Bound the command -> RunCommand slip -> command cycle. Refused and
4651        // reported, never a stack overflow: an editor that dies under the
4652        // operator loses their buffer, and a script that loops is a mistake
4653        // they should be told about, not punished for.
4654        if self.dispatch_depth >= Self::MAX_DISPATCH_DEPTH {
4655            let mut m = String::from("command recursion too deep at `");
4656            m.push_str(name);
4657            m.push_str("` — refusing");
4658            self.messages.push(m);
4659            self.damage = self.damage.join(Damage::Viewport);
4660            self.bump_gen();
4661            return;
4662        }
4663        self.dispatch_depth += 1;
4664        self.run_command_inner(name, args);
4665        self.dispatch_depth -= 1;
4666    }
4667
4668    /// How many nested command dispatches are allowed. Deep enough that no
4669    /// legitimate script notices, shallow enough to fail fast.
4670    const MAX_DISPATCH_DEPTH: u8 = 8;
4671
4672    fn run_command_inner(&mut self, name: &str, args: &[String]) {
4673        // Lazy-activation seam (lazy.nvim `cmd =` model): a user plugin
4674        // gated on `Command: <name>` has its entry applied the first time
4675        // that command runs, BEFORE dispatch — so the activated plugin
4676        // can register the very command being invoked and it resolves on
4677        // this same call.
4678        if self.plugin_host.pending() > 0 {
4679            let pending = self.plugin_host.pending_for_command(name);
4680            for src in pending {
4681                self.apply_plugin_entry(&src);
4682            }
4683        }
4684        // Read through the counter, then interpret. Two immutable borrows of
4685        // `self` (the window and the registry) coexist; the `&mut` comes
4686        // afterwards, once the outcome is owned. That sequencing IS the
4687        // seam: there is no moment where a command body and `&mut self` are
4688        // live at the same time.
4689        let outcome = {
4690            let window = self.window();
4691            self.commands.run(name, &window, args)
4692        };
4693        match outcome {
4694            Ok(o) => self.interpret(o),
4695            // Reported, never fatal (Phase 0). A failed command must not
4696            // take the editor down, but it must not be invisible either.
4697            Err(e) => {
4698                self.messages.push(describe_command_failure(name, &e));
4699                self.damage = self.damage.join(Damage::Viewport);
4700                self.bump_gen();
4701            }
4702        }
4703    }
4704
4705    // ── tatara-lisp runtime bridge (imperative programmability tier) ──
4706
4707    /// Capture a read snapshot of the editor for the tatara-lisp host.
4708    /// Lisp reads (`cursor-line`, `current-line`, …) answer from this.
4709    #[must_use]
4710    pub fn snapshot(&self) -> EditorSnapshot {
4711        let current_line = self
4712            .buffers
4713            .get(self.active)
4714            .and_then(|b| b.line(self.cursor().line))
4715            .map(|s| s.trim_end_matches('\n').to_string())
4716            .unwrap_or_default();
4717        let buffer_name = self
4718            .buffers
4719            .get(self.active)
4720            .and_then(|b| b.path.as_ref())
4721            .map(|p| p.display().to_string())
4722            .unwrap_or_else(|| "[scratch]".to_string());
4723        EditorSnapshot {
4724            cursor_line: i64::from(self.cursor().line),
4725            cursor_column: i64::from(self.cursor().column),
4726            current_line,
4727            mode: self.modal.mode().as_str().to_string(),
4728            buffer_name,
4729        }
4730    }
4731
4732    /// Evaluate tatara-lisp `src` against this editor: capture a
4733    /// snapshot, run it in the embedded VM, then apply the typed effects
4734    /// the program emitted. This is the imperative programmability tier
4735    /// — live Lisp that reads state and drives the editor through the
4736    /// sandboxed effect boundary.
4737    ///
4738    /// **Snapshot semantics:** the read snapshot is captured ONCE before
4739    /// eval, and effects are applied AFTER the program returns. So within
4740    /// a single `run_lisp` call a program cannot observe its own writes —
4741    /// `(insert "x") (cursor-column)` reads the pre-insert column. This
4742    /// snapshot-isolation is deliberate (it's what makes the effect
4743    /// boundary a clean sandbox seam); a program that must read its own
4744    /// effects splits the work across calls. The VM is cached
4745    /// ([`Self::lisp_vm`]) so the stdlib is installed once and top-level
4746    /// `define`s persist across calls (REPL-like).
4747    pub fn run_lisp(&mut self, src: &str) -> Result<(), VmError> {
4748        let mut host = EscribaHost::with_snapshot(self.snapshot());
4749        let vm = self.lisp_vm.get_or_insert_with(EscribaVm::new);
4750        vm.eval(src, &mut host)?;
4751        let effects = host.take_effects();
4752        self.apply_host_effects(effects);
4753        Ok(())
4754    }
4755
4756    /// Apply tatara-lisp effects to live editor state.
4757    ///
4758    /// A thin adapter now. It used to be `apply_host_effects`, a THIRD
4759    /// implementation of message-push / option-insert / insert-text beside
4760    /// the Action executor and the slip interpreter — the same duplication
4761    /// that let `u` and `:undo` drift apart in M3. The VM emits slips; this
4762    /// hands them to the one interpreter.
4763    pub fn apply_host_effects(&mut self, effects: Vec<Negai>) {
4764        self.interpret(Outcome::did(effects));
4765    }
4766
4767    /// Insert a (possibly multi-line) string at the cursor and advance
4768    /// the cursor past it. Used by the `(insert …)` effect.
4769    fn insert_text(&mut self, text: &str) {
4770        if text.is_empty() {
4771            return;
4772        }
4773        let cursor = self.cursor();
4774        let Some(buf) = self.buffers.get_mut(self.active) else {
4775            return;
4776        };
4777        let edit = Edit::insert(cursor, text.to_string());
4778        if buf.apply(&edit).is_ok() {
4779            let next = if let Some(nl) = text.rfind('\n') {
4780                let added_lines = u32::try_from(text.matches('\n').count()).unwrap_or(0);
4781                let last_line_len = u32::try_from(text[nl + 1..].chars().count()).unwrap_or(0);
4782                Position::new(cursor.line + added_lines, last_line_len)
4783            } else {
4784                let n = u32::try_from(text.chars().count()).unwrap_or(0);
4785                cursor.shift_right(n)
4786            };
4787            // Route through the single cursor-mutation path so the viewport
4788            // follows the cursor (both axes) and the cursor stays clamped.
4789            self.place_cursor(next, CursorRest::AtInsertPoint);
4790        }
4791    }
4792}
4793
4794fn first_non_blank(buf: &escriba_buffer::Buffer, line: u32) -> Position {
4795    let Some(text) = buf.line(line) else {
4796        return Position::new(line, 0);
4797    };
4798    let col = text
4799        .chars()
4800        .take_while(|c| c.is_whitespace() && *c != '\n')
4801        .count();
4802    Position::new(line, u32::try_from(col).unwrap_or(0))
4803}
4804
4805/// A character search: which character, which direction, and whether it stops
4806/// ON it (`f`/`F`) or just BEFORE it (`t`/`T`).
4807///
4808/// The same value serves the pending operand and the `;`/`,` memory, so the
4809/// thing repeated is the thing that ran.
4810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4811struct FindSpec {
4812    ch: char,
4813    backward: bool,
4814    till: bool,
4815}
4816
4817/// What the next keystroke means after `m`, `` ` `` or `'`.
4818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4819enum MarkKey {
4820    /// `m{a-z}` — set.
4821    Set,
4822    /// `` `{a-z} `` — jump to the exact position.
4823    GotoExact,
4824    /// `'{a-z}` — jump to the line's first non-blank.
4825    GotoLine,
4826}
4827
4828/// What kind of place a cursor move is asking for.
4829///
4830/// The Normal-mode rule "the cursor sits ON a character" is about where the
4831/// cursor comes to REST. It is not about where text goes next: a write that
4832/// appends `abc` leaves the cursor after the `c`, and that position is one
4833/// past the last character by construction — clamping it back would make the
4834/// next append land inside the text just written. The lisp `(insert …)`
4835/// effect is the case that proves it, because it runs in Normal mode.
4836///
4837/// A parameter rather than two functions, so both readings stay in front of
4838/// whoever changes the clamp.
4839#[derive(PartialEq, Eq, Clone, Copy, Debug)]
4840enum CursorRest {
4841    /// A motion's destination — Normal mode pulls it onto a character.
4842    OnCharacter,
4843    /// Where the next character goes — never pulled back.
4844    AtInsertPoint,
4845}
4846
4847/// What an operator acts over — a CAPTURE range, a REMOVAL range, and the
4848/// kind they were resolved as.
4849///
4850/// Two ranges because for a linewise extent they genuinely differ, and every
4851/// attempt to derive one from the other re-decides which branch produced it:
4852///
4853///   - `dd` removes the line AND its terminator; `cc` clears the line's text
4854///     and KEEPS the line, because you are changing its contents rather than
4855///     removing it. Same lines, same register content, different cut.
4856///   - On the last line of a file with no trailing newline the removal has to
4857///     swallow the PRECEDING newline (there is none following), so it starts
4858///     on a different LINE than the extent names.
4859///
4860/// Charwise extents have `capture == removal`, which is why the old
4861/// single-range signature was right for everything except the linewise change
4862/// and wrong there — `cc` deleted the line.
4863#[derive(Clone, Copy, Debug)]
4864struct Extent {
4865    /// What goes in the register, and what a delete removes.
4866    capture: Range,
4867    /// What a CHANGE removes. Equal to `capture` unless the kind is linewise.
4868    removal: Range,
4869    kind: RegisterKind,
4870}
4871
4872impl Extent {
4873    /// A run of characters: one range, both roles.
4874    const fn charwise(r: Range) -> Self {
4875        Self {
4876            capture: r,
4877            removal: r,
4878            kind: RegisterKind::Charwise,
4879        }
4880    }
4881
4882    /// An extent resolved by a text object, keyed on what the object says it
4883    /// is. The linewise case needs the explicit constructor below, so this is
4884    /// the charwise-or-nothing door.
4885    fn from_object(r: Range, kind: RegisterKind) -> Self {
4886        match kind {
4887            RegisterKind::Charwise => Self::charwise(r),
4888            // A caller that has only a range cannot supply the text-only
4889            // removal, so the two coincide — which is the pre-`cc` behaviour
4890            // and is correct for every linewise object except a change. The
4891            // `Line` object goes through `line_extent` instead.
4892            RegisterKind::Linewise => Self {
4893                capture: r,
4894                removal: r,
4895                kind,
4896            },
4897        }
4898    }
4899
4900    fn normalized(self) -> Self {
4901        Self {
4902            capture: self.capture.normalized(),
4903            removal: self.removal.normalized(),
4904            kind: self.kind,
4905        }
4906    }
4907}
4908
4909/// Normalize a linewise capture: newline-TERMINATED, never newline-LED.
4910///
4911/// The removal range and the register capture are two different views of one
4912/// gesture, and the last line of a file with no trailing newline is where they
4913/// come apart. There is no following terminator to take, so `dd` has to
4914/// swallow the PRECEDING one — the right thing to REMOVE, and the wrong thing
4915/// to put back: the raw slice reads `"\nbravo"`, so `yyp` opened a blank line
4916/// and then a `bravo` with no terminator of its own.
4917///
4918/// Stated once, here, where the kind is known. `Register::replayed` handles
4919/// the other half (a capture that ends without a newline).
4920fn as_linewise_capture(slice: &str) -> String {
4921    match slice.strip_prefix('\n') {
4922        Some(rest) => {
4923            let mut s = String::with_capacity(slice.len());
4924            s.push_str(rest);
4925            s.push('\n');
4926            s
4927        }
4928        None => slice.to_owned(),
4929    }
4930}
4931
4932/// How a captured operand's composed action reaches the executor.
4933#[derive(Clone, Copy, PartialEq, Eq, Debug)]
4934enum OperandCount {
4935    /// The capture already applied its own repeats; run the composed action
4936    /// once. Only the object path does this (`2diw` repeats inside it).
4937    SelfCounted,
4938    /// Drain the pending count and hand it to `apply_counted`, so `3fx` /
4939    /// `3ra` / ``3`a`` repeat on the one path every other motion uses.
4940    Drained,
4941}
4942
4943/// One step of the operand-capture chain.
4944struct OperandCapture {
4945    /// Stable label — what `operand_capture_order.rs` asserts against.
4946    name: &'static str,
4947    claim: fn(&mut EditorState, Key) -> Option<ObjectKey>,
4948    count: OperandCount,
4949}
4950
4951/// **The operand-capture chain, in the order that matters.**
4952///
4953/// Every adjacency is a dependency with a named failure:
4954///
4955/// 1. **mark before object** — the object path claims `i`/`a` whenever an
4956///    operator is armed, and a mark LETTER can be either, so ``d`a`` lost its
4957///    `a` to it. They do not fight over the FIRST key (the mark path arms only
4958///    while `pending_object` is clear, so `di'` still reaches the object
4959///    path); they fight over the SECOND, and the gesture already half-typed
4960///    must win.
4961/// 2. **object before find** — `di(` must not read as `d`, then `i` (insert),
4962///    then a literal `(`.
4963/// 3. **find before replace** — no live conflict; `f`/`t` and `r` arm on
4964///    disjoint keys and neither can be pending while the other is. Ordered
4965///    for stability rather than necessity, and said so rather than implying a
4966///    constraint that is not there.
4967/// 4. **all four before the sequence stepper and the keymap** — this is the
4968///    whole point. Each capture also declines while `pending_keys` is
4969///    non-empty, so a LATER key of a gesture (`zt`'s `t`) belongs to the
4970///    sequence rather than arming a till-find.
4971static OPERAND_CHAIN: &[OperandCapture] = &[
4972    OperandCapture {
4973        name: "mark",
4974        claim: EditorState::consume_mark_key,
4975        count: OperandCount::Drained,
4976    },
4977    OperandCapture {
4978        name: "object",
4979        claim: EditorState::consume_object_key,
4980        count: OperandCount::SelfCounted,
4981    },
4982    OperandCapture {
4983        name: "find",
4984        claim: EditorState::consume_find_key,
4985        count: OperandCount::Drained,
4986    },
4987    OperandCapture {
4988        name: "replace",
4989        claim: EditorState::consume_replace_key,
4990        count: OperandCount::Drained,
4991    },
4992];
4993
4994/// The chain's order, for the gate in `tests/operand_capture_order.rs`.
4995#[must_use]
4996pub fn operand_capture_order() -> Vec<&'static str> {
4997    OPERAND_CHAIN.iter().map(|c| c.name).collect()
4998}
4999
5000/// Does this action ABSORB its count into one operation, or REPEAT?
5001///
5002/// A free function rather than a method on `Action` deliberately: the answer
5003/// is a property of THIS EXECUTOR's arms, not of the action's meaning. An arm
5004/// absorbs its count exactly when it takes one, and a name listed here that no
5005/// arm reads is worse than no list — it reads as handled and behaves as
5006/// repeated. Keep the two in step; the tests below pin every member.
5007fn absorbs_count(action: &Action) -> bool {
5008    matches!(
5009        action,
5010        Action::ApplyOperator { .. }
5011            | Action::Put { .. }
5012            // `3ra` is one replace of three characters (and refuses if there
5013            // are not three), `3J` is one join of three lines. Repeating
5014            // either would walk the cursor and do the wrong thing three times.
5015            | Action::ReplaceChar(_)
5016            | Action::JoinLines { .. }
5017            // Only the LINEWISE object can express an `n`-fold extent today.
5018            // `2diw` still repeats, which is the same over-count-a-yank defect
5019            // waiting on a general "resolve this object n times" — named here
5020            // rather than half-fixed.
5021            | Action::ApplyOperatorObject {
5022                object: escriba_core::TextObject::Line,
5023                ..
5024            }
5025    )
5026}
5027
5028/// Where a put leaves the cursor.
5029///
5030/// Decided BEFORE the insert (from the register's kind) and consumed after,
5031/// because the two arms need different information and only one of them
5032/// survives the edit: the linewise arm needs the line it opened, which the
5033/// pre-edit position names, while the charwise arm needs the extent of the
5034/// text it wrote. Computing either from the post-edit buffer alone means
5035/// re-deriving which gesture happened, which is exactly what
5036/// [`escriba_core::RegisterKind`] exists to stop.
5037#[derive(PartialEq, Eq, Clone, Copy, Debug)]
5038enum PutRest {
5039    /// Linewise: the first non-blank of the first line put.
5040    LineStart(u32),
5041    /// Charwise: ON the last character put — vim's rule, and the one that
5042    /// makes a following `p` stack the copies rather than walk rightward.
5043    LastCharPut,
5044}
5045
5046/// vim's three character classes — the whole of what "a word" means to `w`,
5047/// `b`, `e` and `iw`.
5048///
5049/// One classifier, not four. `object_word` grew its own copy while the word
5050/// MOTIONS were still splitting on whitespace alone, so `diw` on `foo.bar`
5051/// took `foo` and `dw` took `foo.bar` — two answers to "where does this word
5052/// end" from one editor, on the same keystroke's worth of text.
5053#[derive(PartialEq, Eq, Clone, Copy, Debug)]
5054enum WordClass {
5055    Word,
5056    Punct,
5057    Space,
5058}
5059
5060fn word_class(c: char) -> WordClass {
5061    if c.is_alphanumeric() || c == '_' {
5062        WordClass::Word
5063    } else if c.is_whitespace() {
5064        WordClass::Space
5065    } else {
5066        WordClass::Punct
5067    }
5068}
5069
5070/// vim's two word WIDTHS. `w` splits on the three [`WordClass`]es; `W` splits
5071/// on whitespace alone, so `foo.bar` is three words and one WORD.
5072///
5073/// A parameter on the scanners rather than a second family of them: `w` and
5074/// `W` differ in exactly one place — how a character is classified — and two
5075/// copies of the cross-line, empty-line and end-of-buffer rules is how they
5076/// would drift.
5077#[derive(PartialEq, Eq, Clone, Copy, Debug)]
5078enum Width {
5079    /// `w` / `e` / `b` / `ge` — alphanumeric, punctuation and space.
5080    Small,
5081    /// `W` / `E` / `B` / `gE` — non-space and space, nothing else.
5082    Big,
5083}
5084
5085fn class_at(c: char, width: Width) -> WordClass {
5086    match (width, word_class(c)) {
5087        (Width::Big, WordClass::Punct) => WordClass::Word,
5088        (_, k) => k,
5089    }
5090}
5091
5092/// A line's characters WITHOUT its terminator.
5093///
5094/// The newline is not a character the cursor can sit on, and every word scan
5095/// wants the line's own text; `line_len_chars` already strips it for exactly
5096/// this reason, so the two agree on where a line ends by construction.
5097fn line_chars(buf: &escriba_buffer::Buffer, line: u32) -> Vec<char> {
5098    let Some(text) = buf.line(line) else {
5099        return Vec::new();
5100    };
5101    let len = buf.line_len_chars(line) as usize;
5102    text.chars().take(len).collect()
5103}
5104
5105/// The last line that HOLDS text.
5106///
5107/// A file ending in `\n` is one line of text plus a terminator, but the rope
5108/// reports two lines, the second empty — so `line_count() - 1` names a line
5109/// that is not there. A forward word motion walking onto it moves the cursor
5110/// off the end of the file onto a row with nothing on it, which is what `w`
5111/// on the last word of an ordinary file did.
5112///
5113/// Scoped to the word motions on purpose. That phantom row is also DRAWN — it
5114/// gets a gutter number in every face — and hiding it is a buffer-model change
5115/// with a much wider blast radius than a motion fix; it is a separate defect,
5116/// named rather than half-fixed here. What is fixed here is the claim these
5117/// motions make: there is no next word after the last character of the text.
5118fn last_text_line(buf: &escriba_buffer::Buffer) -> u32 {
5119    let last = buf.line_count().saturating_sub(1);
5120    if last > 0 && buf.line_len_chars(last) == 0 {
5121        last - 1
5122    } else {
5123        last
5124    }
5125}
5126
5127/// Where a forward word motion runs out of text — the EXCLUSIVE end, so an
5128/// operator reaches the final character. See [`word_next`].
5129fn buffer_end(buf: &escriba_buffer::Buffer) -> Position {
5130    let line = last_text_line(buf);
5131    Position::new(line, buf.line_len_chars(line))
5132}
5133
5134/// `w` — to the start of the next word.
5135///
5136/// Three vim behaviours this had to grow, each of which was a visible wrong
5137/// answer before:
5138///
5139/// - **Punctuation starts a word.** `w` on `foo.bar` stops at `.` and again
5140///   at `b`; the whitespace-only scan sailed past both to the end.
5141/// - **It crosses lines onto the first non-blank**, not onto column 0. Landing
5142///   on the indent means the next `w` is spent walking out of it.
5143/// - **An empty line is a word.** vim stops on one, and that is what makes `w`
5144///   usable for walking paragraphs.
5145///
5146/// When there is no next word it returns the position PAST the last character
5147/// — not the last character itself. That looks like the bug it is next to and
5148/// is the opposite: an operator needs the exclusive end (`dw` on the final
5149/// word must delete the whole word), and it is the Normal-mode cursor that
5150/// must not sit there. So the clamp lives in [`EditorState::set_cursor`],
5151/// which knows the mode, and this stays a pure range endpoint.
5152fn word_next(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
5153    let mut line = pos.line;
5154    let mut chars = line_chars(buf, line);
5155    let mut col = (pos.column as usize).min(chars.len());
5156
5157    // Leave the run the cursor is standing in. Starting on a blank skips this
5158    // — there is no run to leave, only blanks to cross.
5159    if col < chars.len() {
5160        let start = class_at(chars[col], width);
5161        if start != WordClass::Space {
5162            while col < chars.len() && class_at(chars[col], width) == start {
5163                col += 1;
5164            }
5165        }
5166    }
5167
5168    loop {
5169        while col < chars.len() && class_at(chars[col], width) == WordClass::Space {
5170            col += 1;
5171        }
5172        if col < chars.len() {
5173            return Position::new(line, u32::try_from(col).unwrap_or(pos.column));
5174        }
5175        if line >= last_text_line(buf) {
5176            // Out of text: the exclusive end of the last word.
5177            return Position::new(line, u32::try_from(chars.len()).unwrap_or(pos.column));
5178        }
5179        line += 1;
5180        col = 0;
5181        chars = line_chars(buf, line);
5182        if chars.is_empty() {
5183            return Position::new(line, 0);
5184        }
5185    }
5186}
5187
5188/// `b` — back to the start of the current or previous word.
5189///
5190/// Class-aware like [`word_next`], so `b` and `w` agree on where a word
5191/// begins; a disagreement between them is felt as `dw` and `db` deleting
5192/// different things from the same spot.
5193///
5194/// Single-line, and that is load-bearing: `<C-w>` reaches back over this
5195/// motion and relies on it returning the cursor UNCHANGED at column 0, which
5196/// is what makes the insert-mode erase fall through to `delete_before_cursor`
5197/// and join with the line above. Teaching this to cross lines would silently
5198/// change that key.
5199fn word_prev(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
5200    let chars = line_chars(buf, pos.line);
5201    let mut i = (pos.column as usize).min(chars.len());
5202    while i > 0 && class_at(chars[i - 1], width) == WordClass::Space {
5203        i -= 1;
5204    }
5205    if i > 0 {
5206        let run = class_at(chars[i - 1], width);
5207        while i > 0 && class_at(chars[i - 1], width) == run {
5208            i -= 1;
5209        }
5210    }
5211    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
5212}
5213
5214/// `ge` / `gE` — back to the LAST character of the previous word.
5215///
5216/// The mirror of [`word_end`], and INCLUSIVE like it: `dge` deletes through
5217/// the character it lands on. Single-line for the same reason [`word_prev`]
5218/// is — the backward scanners are what the insert-mode erases stand on, and
5219/// teaching them to cross lines changes those keys silently.
5220fn word_end_prev(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
5221    let chars = line_chars(buf, pos.line);
5222    let start = (pos.column as usize).min(chars.len());
5223    // `ge` always retreats at least one character before it starts looking,
5224    // so standing on the last character of a word does not stand still.
5225    let Some(mut i) = start.checked_sub(1) else {
5226        return pos;
5227    };
5228    // Leave the run the cursor is standing in FIRST. Without this, `ge` from
5229    // the middle (or the end) of a word lands one character to its left —
5230    // inside the same word, which is the one place `ge` must never stop.
5231    if let Some(&here) = chars.get(start) {
5232        let run = class_at(here, width);
5233        if run != WordClass::Space {
5234            while i > 0 && class_at(chars[i], width) == run {
5235                i -= 1;
5236            }
5237        }
5238    }
5239    while i > 0 && class_at(chars[i], width) == WordClass::Space {
5240        i -= 1;
5241    }
5242    Position::new(pos.line, u32::try_from(i).unwrap_or(0))
5243}
5244
5245/// `e` — to the LAST character of the current or next word.
5246///
5247/// Always moves, which is what separates it from "the end of this word": on
5248/// the last character of a word, `e` goes to the last character of the NEXT
5249/// one rather than standing still.
5250///
5251/// This motion is INCLUSIVE — it names a character to act on, not a boundary
5252/// to stop before — see [`Motion::is_inclusive`]. `WordEndNext` used to
5253/// resolve through [`word_next`], so `e` and `w` were the same key with two
5254/// names.
5255fn word_end(buf: &escriba_buffer::Buffer, pos: Position, width: Width) -> Position {
5256    let mut line = pos.line;
5257    let mut chars = line_chars(buf, line);
5258    // `e` always advances at least one character before it starts looking.
5259    let mut col = (pos.column as usize).saturating_add(1);
5260
5261    loop {
5262        while col < chars.len() && class_at(chars[col], width) == WordClass::Space {
5263            col += 1;
5264        }
5265        if col < chars.len() {
5266            break;
5267        }
5268        if line >= last_text_line(buf) {
5269            return buffer_end(buf);
5270        }
5271        line += 1;
5272        col = 0;
5273        chars = line_chars(buf, line);
5274    }
5275
5276    let run = class_at(chars[col], width);
5277    while col + 1 < chars.len() && class_at(chars[col + 1], width) == run {
5278        col += 1;
5279    }
5280    Position::new(line, u32::try_from(col).unwrap_or(pos.column))
5281}
5282
5283/// `f` / `F` / `t` / `T` — the character search, resolved on ONE line.
5284///
5285/// vim's character search never crosses a line, which is what makes it safe
5286/// to compose with an operator: `df;` can only ever delete within the line.
5287/// `None` when the character is not there — the motion fails and the operator
5288/// aborts with the buffer untouched, rather than deleting to the line edge.
5289fn find_char(
5290    buf: &escriba_buffer::Buffer,
5291    pos: Position,
5292    ch: char,
5293    backward: bool,
5294    till: bool,
5295) -> Option<Position> {
5296    let chars = line_chars(buf, pos.line);
5297    let cur = (pos.column as usize).min(chars.len());
5298    let hit = if backward {
5299        // `T` stops AFTER the character, so it has to start one further back
5300        // or a repeated `T` would never leave the spot it already reached.
5301        let from = if till { cur.checked_sub(1)? } else { cur };
5302        (0..from).rev().find(|&i| chars[i] == ch)?
5303    } else {
5304        let from = if till { cur.saturating_add(2) } else { cur + 1 };
5305        (from.min(chars.len())..chars.len()).find(|&i| chars[i] == ch)?
5306    };
5307    let col = match (backward, till) {
5308        (false, true) => hit - 1,
5309        (true, true) => hit + 1,
5310        _ => hit,
5311    };
5312    Some(Position::new(pos.line, u32::try_from(col).ok()?))
5313}
5314
5315/// The four bracket pairs `%` knows.
5316const MATCH_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')];
5317
5318/// A language's WORD pairs for `%` — vim's `matchit`, typed.
5319///
5320/// `(open, middles, close)`. A middle (`else`, `elif`, `when`) is a word `%`
5321/// steps THROUGH on its way round the group; without them `%` on a shell `if`
5322/// jumps straight past `elif` to `fi`, which is right for a scanner and wrong
5323/// for a reader.
5324///
5325/// A TABLE keyed by filetype name, not a per-language scanner: every entry is
5326/// the same depth-counting walk over a different word list, so a new language
5327/// is a row. Deliberately small — these are the languages whose blocks are
5328/// words rather than braces, which is exactly the set where bracket-only `%`
5329/// is useless.
5330type WordPairs = &'static [(&'static str, &'static [&'static str], &'static str)];
5331
5332const WORD_PAIRS: &[(&str, WordPairs)] = &[
5333    (
5334        "lua",
5335        &[
5336            ("if", &["elseif", "else"], "end"),
5337            ("for", &[], "end"),
5338            ("while", &[], "end"),
5339            ("function", &[], "end"),
5340            ("do", &[], "end"),
5341            ("repeat", &[], "until"),
5342        ],
5343    ),
5344    (
5345        "ruby",
5346        &[
5347            ("if", &["elsif", "else"], "end"),
5348            ("unless", &["else"], "end"),
5349            ("case", &["when", "else"], "end"),
5350            ("begin", &["rescue", "ensure", "else"], "end"),
5351            ("def", &[], "end"),
5352            ("class", &[], "end"),
5353            ("module", &[], "end"),
5354            ("do", &[], "end"),
5355            ("while", &[], "end"),
5356        ],
5357    ),
5358    (
5359        "sh",
5360        &[
5361            ("if", &["elif", "else"], "fi"),
5362            ("case", &[], "esac"),
5363            ("do", &[], "done"),
5364        ],
5365    ),
5366    (
5367        "bash",
5368        &[
5369            ("if", &["elif", "else"], "fi"),
5370            ("case", &[], "esac"),
5371            ("do", &[], "done"),
5372        ],
5373    ),
5374    (
5375        "elixir",
5376        &[
5377            ("do", &["else", "rescue", "after", "catch"], "end"),
5378            ("fn", &[], "end"),
5379        ],
5380    ),
5381    (
5382        "vim",
5383        &[
5384            ("if", &["elseif", "else"], "endif"),
5385            ("function", &[], "endfunction"),
5386            ("while", &[], "endwhile"),
5387            ("for", &[], "endfor"),
5388            ("try", &["catch", "finally"], "endtry"),
5389        ],
5390    ),
5391];
5392
5393/// A word occurrence: its position, and which group + role it plays.
5394#[derive(Clone, Copy)]
5395struct WordHit {
5396    line: u32,
5397    col: u32,
5398    end: u32,
5399    group: usize,
5400    /// `0` = opener, `1` = middle, `2` = closer.
5401    role: u8,
5402}
5403
5404/// `%` — to the match of the bracket under the cursor, or of the first
5405/// bracket to its right on the same line (vim scans forward to find one).
5406///
5407/// Depth-counting and buffer-wide, because a brace pair that fits on one line
5408/// is the case `%` is least needed for.
5409fn match_pair(buf: &escriba_buffer::Buffer, pos: Position) -> Option<Position> {
5410    let chars = line_chars(buf, pos.line);
5411    let start = (pos.column as usize).min(chars.len());
5412    let (col, open, close, forward) = (start..chars.len()).find_map(|i| {
5413        MATCH_PAIRS.iter().find_map(|&(o, c)| {
5414            if chars[i] == o {
5415                Some((i, o, c, true))
5416            } else if chars[i] == c {
5417                Some((i, o, c, false))
5418            } else {
5419                None
5420            }
5421        })
5422    })?;
5423
5424    let last = buf.line_count().saturating_sub(1);
5425    let mut depth = 0i32;
5426    let (mut line, mut i) = (pos.line, col);
5427    let mut text = chars;
5428    loop {
5429        let c = text[i];
5430        if c == open {
5431            depth += if forward { 1 } else { -1 };
5432        } else if c == close {
5433            depth += if forward { -1 } else { 1 };
5434        }
5435        if depth == 0 {
5436            return Some(Position::new(line, u32::try_from(i).ok()?));
5437        }
5438        if forward {
5439            i += 1;
5440            while i >= text.len() {
5441                if line >= last {
5442                    return None;
5443                }
5444                line += 1;
5445                text = line_chars(buf, line);
5446                i = 0;
5447            }
5448        } else {
5449            while i == 0 {
5450                if line == 0 {
5451                    return None;
5452                }
5453                line -= 1;
5454                text = line_chars(buf, line);
5455                i = text.len();
5456            }
5457            i -= 1;
5458        }
5459    }
5460}
5461
5462/// Every word-pair keyword on `line`, in column order.
5463///
5464/// Word-bounded on both sides, so `endif` is not read as `end`, `define` is
5465/// not read as `def`, and a `do` inside `window` is not a block opener. That
5466/// boundary check is the whole difference between matchit and a substring
5467/// search, and skipping it is worse than having no word pairs at all — a `%`
5468/// that jumps to the middle of an identifier is a silent wrong answer.
5469fn word_hits(buf: &escriba_buffer::Buffer, line: u32, pairs: WordPairs) -> Vec<WordHit> {
5470    let chars = line_chars(buf, line);
5471    let mut out = Vec::new();
5472    let mut i = 0usize;
5473    while i < chars.len() {
5474        if word_class(chars[i]) != WordClass::Word {
5475            i += 1;
5476            continue;
5477        }
5478        let start = i;
5479        while i < chars.len() && word_class(chars[i]) == WordClass::Word {
5480            i += 1;
5481        }
5482        let word: String = chars[start..i].iter().collect();
5483        for (group, (open, middles, close)) in pairs.iter().enumerate() {
5484            let role = if word == *open {
5485                0
5486            } else if word == *close {
5487                2
5488            } else if middles.contains(&word.as_str()) {
5489                1
5490            } else {
5491                continue;
5492            };
5493            out.push(WordHit {
5494                line,
5495                col: u32::try_from(start).unwrap_or(0),
5496                end: u32::try_from(i).unwrap_or(0),
5497                group,
5498                role,
5499            });
5500            break;
5501        }
5502    }
5503    out
5504}
5505
5506/// `%` over WORD pairs — matchit's half of the motion.
5507///
5508/// Finds the keyword at (or right of) the cursor and walks to the next member
5509/// of its group at the same depth: opener → first middle → … → closer →
5510/// opener. Cycling rather than jumping straight to the closer is what makes
5511/// `%` usable for reading an `if`/`elif`/`else`/`fi` chain.
5512fn match_word_pair(
5513    buf: &escriba_buffer::Buffer,
5514    pos: Position,
5515    pairs: WordPairs,
5516) -> Option<Position> {
5517    let here = word_hits(buf, pos.line, pairs)
5518        .into_iter()
5519        .find(|h| h.end > pos.column)?;
5520    let last = last_text_line(buf);
5521    let forward = here.role != 2;
5522    let mut depth = 0i32;
5523    let mut line = here.line;
5524    loop {
5525        let hits = word_hits(buf, line, pairs);
5526        // Only the hits strictly beyond the starting keyword on its own line.
5527        let scan: Vec<WordHit> = if line == here.line {
5528            let mut v: Vec<WordHit> = hits
5529                .into_iter()
5530                .filter(|h| {
5531                    if forward {
5532                        h.col > here.col
5533                    } else {
5534                        h.col < here.col
5535                    }
5536                })
5537                .collect();
5538            if !forward {
5539                v.reverse();
5540            }
5541            v
5542        } else {
5543            let mut v = hits;
5544            if !forward {
5545                v.reverse();
5546            }
5547            v
5548        };
5549        for h in scan {
5550            if h.group != here.group {
5551                continue;
5552            }
5553            match (h.role, forward) {
5554                (0, true) | (2, false) => depth += 1,
5555                (2, true) | (0, false) => {
5556                    if depth == 0 {
5557                        return Some(Position::new(h.line, h.col));
5558                    }
5559                    depth -= 1;
5560                }
5561                // A middle at the SAME depth is the next stop; nested ones are
5562                // somebody else's `else`.
5563                (1, _) if depth == 0 => return Some(Position::new(h.line, h.col)),
5564                _ => {}
5565            }
5566        }
5567        if forward {
5568            if line >= last {
5569                return None;
5570            }
5571            line += 1;
5572        } else {
5573            if line == 0 {
5574                return None;
5575            }
5576            line -= 1;
5577        }
5578    }
5579}
5580
5581/// `{` / `}` — to the nearest blank line in `dir`, or the buffer edge.
5582///
5583/// vim's paragraph boundary is an EMPTY line, not an indentation change; a
5584/// line of spaces is not one. `line_len_chars` already excludes the
5585/// terminator, so "empty" is exactly `len == 0`.
5586fn paragraph(buf: &escriba_buffer::Buffer, pos: Position, forward: bool) -> Position {
5587    let last = last_text_line(buf);
5588    let mut line = pos.line;
5589    loop {
5590        if forward {
5591            if line >= last {
5592                return buffer_end(buf);
5593            }
5594            line += 1;
5595        } else {
5596            if line == 0 {
5597                return Position::ZERO;
5598            }
5599            line -= 1;
5600        }
5601        if buf.line_len_chars(line) == 0 {
5602            return Position::new(line, 0);
5603        }
5604    }
5605}
5606
5607/// `(` / `)` — to the start of the adjacent sentence.
5608///
5609/// A sentence ends at `.`/`!`/`?` followed by whitespace or end-of-line; the
5610/// next one starts at the following non-blank. A paragraph boundary is also a
5611/// sentence boundary, which is what stops `)` at the end of a block of prose
5612/// instead of sailing into the next one.
5613fn sentence(buf: &escriba_buffer::Buffer, pos: Position, forward: bool) -> Position {
5614    let starts = sentence_starts(buf);
5615    let here = (pos.line, pos.column);
5616    if forward {
5617        starts
5618            .iter()
5619            .find(|&&(l, c)| (l, c) > here)
5620            .map_or_else(|| buffer_end(buf), |&(l, c)| Position::new(l, c))
5621    } else {
5622        starts
5623            .iter()
5624            .rev()
5625            .find(|&&(l, c)| (l, c) < here)
5626            .map_or(Position::ZERO, |&(l, c)| Position::new(l, c))
5627    }
5628}
5629
5630/// Every sentence start in the buffer, in order.
5631///
5632/// Computed wholesale rather than scanned directionally: the backward and
5633/// forward cases are then the same list read two ways, so `(` and `)` cannot
5634/// disagree about where a sentence begins.
5635fn sentence_starts(buf: &escriba_buffer::Buffer) -> Vec<(u32, u32)> {
5636    let mut out = vec![(0u32, 0u32)];
5637    let mut ended = false;
5638    for line in 0..=last_text_line(buf) {
5639        let chars = line_chars(buf, line);
5640        if chars.is_empty() {
5641            // A blank line is a paragraph break, and so a sentence break.
5642            out.push((line, 0));
5643            ended = false;
5644            continue;
5645        }
5646        for (i, &c) in chars.iter().enumerate() {
5647            if ended && !c.is_whitespace() {
5648                out.push((line, u32::try_from(i).unwrap_or(0)));
5649                ended = false;
5650            }
5651            if matches!(c, '.' | '!' | '?') {
5652                ended = true;
5653            } else if !matches!(c, ')' | ']' | '"' | '\'') && !c.is_whitespace() {
5654                ended = false;
5655            }
5656        }
5657    }
5658    out.sort_unstable();
5659    out.dedup();
5660    out
5661}
5662
5663#[cfg(test)]
5664mod tests {
5665    use super::*;
5666    use madori::event::{KeyCode, KeyEvent, Modifiers};
5667
5668    // ── search wiring (escriba-search integration) ────────────────────
5669    //
5670    // The engine is proven in escriba-search's own 61 tests. These prove the
5671    // WIRING: that keys reach it, that the cursor lands where it says, and
5672    // that a search prompt and an ex-command can share Command mode without
5673    // being confused for one another.
5674
5675    fn type_search(st: &mut EditorState, dir: SearchDirection, pat: &str) {
5676        st.apply(&Action::SearchOpen(dir));
5677        for c in pat.chars() {
5678            st.apply(&Action::InsertChar(c));
5679        }
5680        st.apply(&Action::SubmitCommand);
5681    }
5682
5683    #[test]
5684    fn slash_search_moves_the_cursor_to_the_match() {
5685        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
5686        type_search(&mut st, SearchDirection::Forward, "charlie");
5687        assert_eq!(st.cursor().line, 2, "cursor lands on the matching line");
5688        assert_eq!(st.modal.mode(), Mode::Normal, "prompt closes on submit");
5689        assert_eq!(st.search.matches().len(), 1);
5690    }
5691
5692    #[test]
5693    // `N` is a DIFFERENT vim key from `n` — see escriba-search.
5694    #[allow(non_snake_case)]
5695    fn n_and_N_walk_matches_in_both_directions() {
5696        let mut st = new_state_with("foo\nbar\nfoo\nbaz\nfoo\n");
5697        type_search(&mut st, SearchDirection::Forward, "foo");
5698        let first = st.cursor().line;
5699        st.apply(&Action::SearchRepeat { reverse: false });
5700        let second = st.cursor().line;
5701        assert!(second > first, "n advances ({first} -> {second})");
5702        st.apply(&Action::SearchRepeat { reverse: true });
5703        assert_eq!(st.cursor().line, first, "N comes back");
5704    }
5705
5706    #[test]
5707    fn star_searches_the_word_under_the_cursor() {
5708        let mut st = new_state_with("needle\nhaystack\nneedle\n");
5709        st.apply(&Action::SearchWord { reverse: false });
5710        assert_eq!(st.search.pattern().unwrap().raw(), r"\bneedle\b");
5711        assert_eq!(st.cursor().line, 2, "jumps to the other occurrence");
5712    }
5713
5714    #[test]
5715    fn escape_abandons_the_prompt_and_keeps_the_previous_search() {
5716        let mut st = new_state_with("foo\nbar\nfoo\n");
5717        type_search(&mut st, SearchDirection::Forward, "foo");
5718        let matches_before = st.search.matches().len();
5719
5720        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5721        st.apply(&Action::InsertChar('z'));
5722        st.apply(&Action::ChangeMode(Mode::Normal));
5723
5724        assert!(!st.search.is_prompting(), "prompt gone");
5725        assert_eq!(
5726            st.search.pattern().unwrap().raw(),
5727            "foo",
5728            "old pattern survives"
5729        );
5730        assert_eq!(
5731            st.search.matches().len(),
5732            matches_before,
5733            "old highlights survive"
5734        );
5735    }
5736
5737    #[test]
5738    fn a_search_prompt_and_an_ex_command_are_not_confused() {
5739        let mut st = new_state_with("foo\n");
5740        // No `/` pressed: Command mode belongs to the ex-command line.
5741        st.apply(&Action::ChangeMode(Mode::Command));
5742        assert!(!st.search.is_prompting(), "`:` must not open a search");
5743        st.apply(&Action::InsertChar('w'));
5744        assert!(
5745            st.search.prompt().is_none(),
5746            "typed char went to the ex line"
5747        );
5748    }
5749
5750    #[test]
5751    fn a_missing_pattern_reports_instead_of_failing_silently() {
5752        let mut st = new_state_with("alpha\nbravo\n");
5753        type_search(&mut st, SearchDirection::Forward, "zzz");
5754        assert!(
5755            st.messages.iter().any(|m| m.contains("E486")),
5756            "must report not-found, got {:?}",
5757            st.messages
5758        );
5759    }
5760
5761    #[test]
5762    fn n_without_any_search_reports_rather_than_moving() {
5763        let mut st = new_state_with("alpha\nbravo\n");
5764        let before = st.cursor();
5765        st.apply(&Action::SearchRepeat { reverse: false });
5766        assert_eq!(st.cursor(), before, "cursor must not move");
5767        assert!(
5768            st.messages.iter().any(|m| m.contains("E35")),
5769            "got {:?}",
5770            st.messages
5771        );
5772    }
5773
5774    #[test]
5775    fn search_as_a_motion_composes_with_an_operator() {
5776        // The point of Motion::SearchNext: `d` + search deletes to the match.
5777        let mut st = new_state_with("alpha bravo charlie\n");
5778        type_search(&mut st, SearchDirection::Forward, "charlie");
5779        st.set_cursor(Position::new(0, 0));
5780        let target = st.resolve_motion(Position::new(0, 0), Motion::SearchNext);
5781        assert!(target.is_some(), "search must resolve as a motion");
5782        assert_eq!(target.unwrap().column, 12, "at `charlie`");
5783    }
5784
5785    #[test]
5786    fn search_motion_without_a_pattern_fails_the_motion_instead_of_moving_to_zero() {
5787        // A silent fallback to offset 0 would make `d` + search delete to the
5788        // start of the file — the worst possible failure for an operator.
5789        let st = new_state_with("alpha bravo\n");
5790        assert!(
5791            st.resolve_motion(Position::new(0, 5), Motion::SearchNext)
5792                .is_none()
5793        );
5794    }
5795
5796    #[test]
5797    fn clear_highlight_keeps_the_pattern_usable() {
5798        let mut st = new_state_with("foo\nbar\nfoo\n");
5799        type_search(&mut st, SearchDirection::Forward, "foo");
5800        st.apply(&Action::ClearSearchHighlight);
5801        assert!(st.search.highlights().is_empty(), "nothing lit");
5802        st.apply(&Action::SearchRepeat { reverse: false });
5803        assert!(st.search.pattern().is_some(), "but n still works");
5804    }
5805
5806    #[test]
5807    fn typing_previews_incrementally_before_commit() {
5808        let mut st = new_state_with("alpha\nbravo\ncharlie\n");
5809        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5810        for c in "charlie".chars() {
5811            st.apply(&Action::InsertChar(c));
5812        }
5813        // incsearch: the cursor has already moved, with nothing committed.
5814        assert_eq!(st.cursor().line, 2, "preview moved the cursor");
5815        assert!(st.search.pattern().is_none(), "but nothing is committed");
5816    }
5817
5818    #[test]
5819    fn backspace_corrects_the_prompt_and_reruns_the_preview() {
5820        let mut st = new_state_with("alpha\nbravo\n");
5821        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5822        for c in "bravox".chars() {
5823            st.apply(&Action::InsertChar(c));
5824        }
5825        assert_eq!(st.search.prompt().unwrap().text(), "bravox");
5826        st.apply(&Action::Backspace);
5827        assert_eq!(
5828            st.search.prompt().unwrap().text(),
5829            "bravo",
5830            "typo corrected"
5831        );
5832        assert_eq!(
5833            st.status_model().prompt_text,
5834            "bravo",
5835            "the model reads the PROMPT — the minibuffer is the ex-line's store",
5836        );
5837        assert_eq!(st.cursor().line, 1, "preview re-ran and found it");
5838    }
5839
5840    #[test]
5841    fn backspacing_past_the_slash_closes_the_prompt() {
5842        let mut st = new_state_with("alpha\n");
5843        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5844        st.apply(&Action::InsertChar('a'));
5845        st.apply(&Action::Backspace);
5846        st.apply(&Action::Backspace);
5847        assert!(!st.search.is_prompting(), "prompt closed");
5848        assert_eq!(st.modal.mode(), Mode::Normal);
5849    }
5850
5851    #[test]
5852    fn noh_clears_highlights_and_keeps_the_pattern() {
5853        let mut st = new_state_with("foo\nbar\nfoo\n");
5854        type_search(&mut st, SearchDirection::Forward, "foo");
5855        assert!(!st.search.highlights().is_empty());
5856        st.run_command("noh", &[]);
5857        assert!(st.search.highlights().is_empty(), ":noh turns them off");
5858        assert!(st.search.pattern().is_some(), "but n still works");
5859    }
5860
5861    #[test]
5862    fn noh_accepts_the_vim_aliases() {
5863        for name in ["noh", "nohl", "nohlsearch"] {
5864            let mut st = new_state_with("foo\nfoo\n");
5865            type_search(&mut st, SearchDirection::Forward, "foo");
5866            st.run_command(name, &[]);
5867            assert!(st.search.highlights().is_empty(), "{name} must clear");
5868        }
5869    }
5870
5871    #[test]
5872    fn backspace_on_the_ex_line_does_not_touch_search_state() {
5873        let mut st = new_state_with("foo\n");
5874        st.apply(&Action::ChangeMode(Mode::Command));
5875        st.apply(&Action::InsertChar('w'));
5876        st.apply(&Action::InsertChar('q'));
5877        st.apply(&Action::Backspace);
5878        assert_eq!(st.status_model().prompt_text, "w");
5879        assert!(st.search.prompt().is_none(), "no search was involved");
5880    }
5881
5882    #[test]
5883    fn up_arrow_recalls_the_previous_search() {
5884        let mut st = new_state_with("alpha\nbravo\n");
5885        type_search(&mut st, SearchDirection::Forward, "bravo");
5886        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5887        st.apply(&Action::PromptHistory { back: true });
5888        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
5889        assert_eq!(
5890            st.status_model().prompt_text,
5891            "bravo",
5892            "display follows the prompt"
5893        );
5894    }
5895
5896    #[test]
5897    fn arrowing_back_down_restores_the_half_typed_pattern() {
5898        let mut st = new_state_with("alpha\nbravo\n");
5899        type_search(&mut st, SearchDirection::Forward, "bravo");
5900        st.apply(&Action::SearchOpen(SearchDirection::Forward));
5901        st.apply(&Action::InsertChar('a'));
5902        st.apply(&Action::PromptHistory { back: true });
5903        assert_eq!(st.search.prompt().unwrap().text(), "bravo");
5904        st.apply(&Action::PromptHistory { back: false });
5905        assert_eq!(
5906            st.search.prompt().unwrap().text(),
5907            "a",
5908            "the draft comes back"
5909        );
5910        assert_eq!(st.status_model().prompt_text, "a");
5911    }
5912
5913    #[test]
5914    fn history_arrows_do_nothing_on_the_ex_line() {
5915        let mut st = new_state_with("alpha\n");
5916        st.apply(&Action::ChangeMode(Mode::Command));
5917        st.apply(&Action::InsertChar('w'));
5918        st.apply(&Action::PromptHistory { back: true });
5919        assert_eq!(st.status_model().prompt_text, "w", "ex line untouched");
5920    }
5921
5922    // ── trouble.* — the findings view ────────────────────────────────
5923    //
5924    // These assert on the ROWS the picker would be built from, not on the
5925    // registry: the registry is already tested, and what could be wrong
5926    // here is the projection — scoping, freshness, and whether a row goes
5927    // anywhere when pressed.
5928
5929    fn finding_at(buffer: BufferId, line: u32, msg: &str) -> escriba_shirube::Finding {
5930        use escriba_core::{Position, Range};
5931        escriba_shirube::Finding::new(
5932            escriba_shirube::Site::in_buffer(
5933                buffer,
5934                Range::new(Position::new(line, 0), Position::new(line, 1)),
5935            ),
5936            escriba_shirube::Severity::Error,
5937            msg.to_string(),
5938            escriba_shirube::Origin::Text("test"),
5939        )
5940    }
5941
5942    #[test]
5943    fn published_findings_become_picker_rows() {
5944        let mut st = new_state_with("a\nb\nc\n");
5945        let world = st.world();
5946        st.results.publish(
5947            "test",
5948            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
5949        );
5950        let rows = st.finding_items(true, None);
5951        assert_eq!(rows.len(), 1, "the published finding produces a row");
5952        // The row must SAY something an operator can act on: severity,
5953        // 1-based line, and the message.
5954        let label = &rows[0].label;
5955        assert!(label.contains("ERROR"), "{label}");
5956        assert!(label.contains(":2"), "lines are 1-based on screen: {label}");
5957        assert!(label.contains("boom"), "{label}");
5958    }
5959
5960    #[test]
5961    fn a_stale_list_contributes_no_rows() {
5962        // THE load-bearing one. A list anchored to a revision the buffer has
5963        // moved past must vanish from the view rather than offer a line that
5964        // has since shifted — which is the whole reason findings carry an
5965        // anchor instead of just a position.
5966        let mut st = new_state_with("a\nb\nc\n");
5967        let world = st.world();
5968        st.results.publish(
5969            "test",
5970            escriba_shirube::ResultList::new(vec![finding_at(st.active, 1, "boom")], world),
5971        );
5972        assert_eq!(st.finding_items(true, None).len(), 1, "fresh to begin with");
5973
5974        st.apply(&Action::InsertChar('x'));
5975        assert!(
5976            st.finding_items(true, None).is_empty(),
5977            "an edit moved the text on; the list is stale and must not be shown"
5978        );
5979    }
5980
5981    #[test]
5982    fn document_scope_excludes_another_buffer() {
5983        // `trouble.document` vs `trouble.workspace` is one bool, so this is
5984        // the only thing that can distinguish them.
5985        let mut st = new_state_with("a\nb\n");
5986        let other = st.buffers.scratch("z\n");
5987        let world = st.world();
5988        st.results.publish(
5989            "test",
5990            escriba_shirube::ResultList::new(
5991                vec![
5992                    finding_at(st.active, 0, "mine"),
5993                    finding_at(other, 0, "theirs"),
5994                ],
5995                world,
5996            ),
5997        );
5998        let ws = st.finding_items(true, None);
5999        assert_eq!(ws.len(), 2, "workspace scope shows both");
6000        let doc = st.finding_items(false, None);
6001        assert_eq!(doc.len(), 1, "document scope shows only the active buffer");
6002        assert!(doc[0].label.contains("mine"), "{}", doc[0].label);
6003    }
6004
6005    #[test]
6006    fn files_under_a_root_produces_rows() {
6007        // `files.open-parent` differs from `files.open` only in the root, so
6008        // what must hold is that a root is actually honoured.
6009        let mut st = new_state_with("");
6010        let rows = st.file_items(std::path::Path::new("."));
6011        assert!(!rows.is_empty(), "the working directory has files");
6012    }
6013
6014    // ── vim text objects ─────────────────────────────────────────────
6015    //
6016    // Asserted through `apply` on real buffer text, so a wrong RANGE shows
6017    // up as wrong text rather than as a range that merely looks plausible.
6018
6019    fn after(text: &str, line: u32, col: u32, act: Action) -> String {
6020        let mut st = new_state_with(text);
6021        st.set_cursor(Position::new(line, col));
6022        st.apply(&act);
6023        st.buffers
6024            .get(st.active)
6025            .map(|b| b.to_string())
6026            .unwrap_or_default()
6027    }
6028
6029    fn del_obj(o: escriba_core::TextObject) -> Action {
6030        Action::ApplyOperatorObject {
6031            op: escriba_core::Operator::Delete,
6032            object: o,
6033        }
6034    }
6035
6036    #[test]
6037    fn dd_removes_the_line_not_just_its_contents() {
6038        // The distinction the newline makes: without it, `dd` blanks a line
6039        // and leaves it behind.
6040        let got = after("a\nb\nc\n", 1, 0, del_obj(escriba_core::TextObject::Line));
6041        assert_eq!(got, "a\nc\n");
6042    }
6043
6044    #[test]
6045    fn dd_on_the_last_line_leaves_no_blank_behind() {
6046        // The case a naive start-of-line..start-of-next range gets wrong:
6047        // there is no following newline to take, so it must take the
6048        // preceding one.
6049        let got = after("a\nb\nc\n", 2, 0, del_obj(escriba_core::TextObject::Line));
6050        assert_eq!(got, "a\nb\n", "no trailing empty line: {got:?}");
6051    }
6052
6053    #[test]
6054    fn dd_on_the_only_line_clears_it_but_keeps_the_line() {
6055        let got = after("solo\n", 0, 2, del_obj(escriba_core::TextObject::Line));
6056        assert!(got.starts_with('\n') || got.is_empty(), "{got:?}");
6057    }
6058
6059    #[test]
6060    fn diw_takes_the_word_and_daw_takes_its_trailing_space() {
6061        let inner = after(
6062            "one two three\n",
6063            0,
6064            5,
6065            del_obj(escriba_core::TextObject::Word { around: false }),
6066        );
6067        assert_eq!(inner, "one  three\n", "iw leaves both spaces");
6068        let around = after(
6069            "one two three\n",
6070            0,
6071            5,
6072            del_obj(escriba_core::TextObject::Word { around: true }),
6073        );
6074        assert_eq!(around, "one three\n", "aw takes the trailing space");
6075    }
6076
6077    #[test]
6078    fn iw_from_any_column_inside_the_word_takes_the_whole_word() {
6079        for col in 4..=6 {
6080            let got = after(
6081                "one two three\n",
6082                0,
6083                col,
6084                del_obj(escriba_core::TextObject::Word { around: false }),
6085            );
6086            assert_eq!(got, "one  three\n", "from column {col}");
6087        }
6088    }
6089
6090    #[test]
6091    fn iw_on_punctuation_takes_the_punctuation_run() {
6092        // vim's three classes: word / punctuation / whitespace. A `::` is a
6093        // run of punctuation, not part of either identifier.
6094        let got = after(
6095            "foo::bar\n",
6096            0,
6097            3,
6098            del_obj(escriba_core::TextObject::Word { around: false }),
6099        );
6100        assert_eq!(got, "foobar\n");
6101    }
6102
6103    #[test]
6104    fn i_paren_takes_the_inside_and_a_paren_takes_the_brackets_too() {
6105        let inner = after(
6106            "f(a, b)\n",
6107            0,
6108            3,
6109            del_obj(escriba_core::TextObject::Delimited {
6110                open: '(',
6111                close: ')',
6112                around: false,
6113            }),
6114        );
6115        assert_eq!(inner, "f()\n");
6116        let around = after(
6117            "f(a, b)\n",
6118            0,
6119            3,
6120            del_obj(escriba_core::TextObject::Delimited {
6121                open: '(',
6122                close: ')',
6123                around: true,
6124            }),
6125        );
6126        assert_eq!(around, "f\n");
6127    }
6128
6129    #[test]
6130    fn nested_brackets_resolve_to_the_enclosing_pair() {
6131        // THE reason the bracket scan counts depth: an inner pair must not
6132        // terminate the search for the one the cursor is actually inside.
6133        let got = after(
6134            "f(g(x), y)\n",
6135            0,
6136            8,
6137            del_obj(escriba_core::TextObject::Delimited {
6138                open: '(',
6139                close: ')',
6140                around: false,
6141            }),
6142        );
6143        assert_eq!(got, "f()\n", "took the outer pair");
6144    }
6145
6146    #[test]
6147    fn quotes_do_not_nest_so_the_nearest_pair_wins() {
6148        let got = after(
6149            r#"say "hi there" ok"#,
6150            0,
6151            7,
6152            del_obj(escriba_core::TextObject::Delimited {
6153                open: '"',
6154                close: '"',
6155                around: false,
6156            }),
6157        );
6158        assert_eq!(got, "say \"\" ok");
6159    }
6160
6161    #[test]
6162    fn an_unmatched_delimiter_resolves_to_nothing_rather_than_guessing() {
6163        let mut st = new_state_with("f(a, b\n");
6164        st.set_cursor(Position::new(0, 3));
6165        let before = st
6166            .buffers
6167            .get(st.active)
6168            .map(|b| b.to_string())
6169            .unwrap_or_default();
6170        st.apply(&del_obj(escriba_core::TextObject::Delimited {
6171            open: '(',
6172            close: ')',
6173            around: false,
6174        }));
6175        let got_after = st
6176            .buffers
6177            .get(st.active)
6178            .map(|b| b.to_string())
6179            .unwrap_or_default();
6180        assert_eq!(got_after, before, "no closing bracket: change nothing");
6181    }
6182
6183    fn new_state_with(text: &str) -> EditorState {
6184        let mut bufs = BufferSet::new();
6185        let id = bufs.scratch(text);
6186        EditorState::new_with_buffer(bufs, id)
6187    }
6188
6189    /// A breakpoint toggle reaches the GPU face's rebuild gate.
6190    ///
6191    /// ## What this does and does not claim
6192    ///
6193    /// The GPU face is the only one that CACHES its gutter: it shapes a
6194    /// glyphon buffer and rebuilds it only when `s.edit_gen() != self.last_gen`
6195    /// (`escriba-render/src/gpu.rs:260`). Both testable faces repaint from
6196    /// scratch every draw, so no rendered-cells test can see this — measured
6197    /// 2026-08-12 by removing the bump and watching all eight breakpoint
6198    /// render tests stay green.
6199    ///
6200    /// The guarantee is STRUCTURAL, not local: [`EditorState::honour`] widens
6201    /// the damage and bumps the generation after every slip, so the property
6202    /// holds for `ToggleBreakpoint` the way it holds for the other thirty.
6203    /// This pins the INSTANCE, and says so rather than pretending to gate a
6204    /// line inside `toggle_breakpoint` — there is no such line, deliberately.
6205    ///
6206    /// RED RUN (2026-08-12): deleting `self.bump_gen()` from `honour` fails
6207    /// this (and much else, which is the honest shape of a structural
6208    /// guarantee). The evidence is the generation counter itself — the exact
6209    /// value the GPU gate compares — not a restatement of "was a method
6210    /// called".
6211    #[test]
6212    fn setting_a_breakpoint_repaints() {
6213        let mut s = new_state_with("alpha\nbravo\ncharlie\n");
6214        let before = s.edit_gen();
6215        s.run_command("dap.toggle-breakpoint", &[]);
6216        assert!(
6217            s.breakpoints().is_set(s.active, 0),
6218            "precondition: the toggle ran",
6219        );
6220        assert_ne!(
6221            s.edit_gen(),
6222            before,
6223            "the GPU face rebuilds its cached gutter ONLY on a generation \
6224             change — without this the mark never reaches that screen",
6225        );
6226        assert!(
6227            !s.damage().is_none(),
6228            "and a scoped-repaint face has to be told the viewport moved",
6229        );
6230    }
6231
6232    #[test]
6233    fn a_breakpoint_toggle_with_no_open_buffer_marks_nothing() {
6234        // A mark on a buffer that does not exist is one no future DAP client
6235        // could ever name, and the honest report is silence rather than a
6236        // confirmation of something that did not happen. `active` names no
6237        // open buffer here, which is the state a `--no-defaults` boot and a
6238        // just-closed buffer both pass through.
6239        let mut s = new_state_with("alpha\n");
6240        s.active = BufferId(9_999);
6241        s.run_command("dap.toggle-breakpoint", &[]);
6242        assert!(!s.breakpoints().is_set(s.active, 0), "nothing was marked");
6243        assert!(
6244            !s.messages.iter().any(|m| m.contains("breakpoint")),
6245            "and nothing was claimed: {:?}",
6246            s.messages,
6247        );
6248    }
6249
6250    /// The refresh-seal driver (theory/ESCRIBA.md §Refresh-Seal): an applied
6251    /// action advances `edit_gen` (so the renderer repaints), and merely
6252    /// reading the generation does not. This is what lets `gpu.rs` gate the
6253    /// re-highlight/re-shape on a generation change — an idle frame observes an
6254    /// unchanged generation and reuses its cached buffer.
6255    #[test]
6256    fn edit_gen_advances_on_applied_action_not_on_read() {
6257        let mut s = new_state_with("hello\nworld\n");
6258        let g0 = s.edit_gen();
6259        s.apply(&Action::InsertChar('X'));
6260        assert_ne!(
6261            s.edit_gen(),
6262            g0,
6263            "an applied action must advance the refresh generation",
6264        );
6265        // Reading the generation is not a mutation — idle frames stay put.
6266        let g1 = s.edit_gen();
6267        assert_eq!(s.edit_gen(), g1, "reading edit_gen must not advance it");
6268    }
6269
6270    /// The M1 refresh node (theory/ESCRIBA.md §X): a mutation widens the typed
6271    /// `Damage` to cover exactly what changed — local for an in-place edit,
6272    /// to-end-of-document when the line count shifts — and the renderer drains
6273    /// it per frame. `Damage ⊇ changed` by construction; it never narrows.
6274    #[test]
6275    fn damage_tracks_edit_scope_and_drains() {
6276        let mut s = new_state_with("hello\nworld\n");
6277        assert!(s.damage().is_none(), "a fresh state has no damage");
6278
6279        s.apply(&Action::InsertChar('X')); // in-place edit on line 0
6280        assert_eq!(
6281            s.damage(),
6282            Damage::Lines { from: 0, to: 0 },
6283            "a local edit damages just its line",
6284        );
6285
6286        let drained = s.take_damage();
6287        assert_eq!(drained, Damage::Lines { from: 0, to: 0 });
6288        assert!(s.damage().is_none(), "take_damage drains to None");
6289
6290        s.apply(&Action::InsertChar('\n')); // splits line 0 → line count grows
6291        assert_eq!(
6292            s.damage(),
6293            Damage::Lines {
6294                from: 0,
6295                to: u32::MAX,
6296            },
6297            "a line-count change damages to end-of-document",
6298        );
6299    }
6300
6301    /// A state whose active window is a deliberately tiny viewport
6302    /// (`visible_lines` × `visible_columns`) so the scroll-to-contain
6303    /// invariant is exercised on small inputs.
6304    fn new_state_small_viewport(text: &str, vis_lines: u32, vis_cols: u32) -> EditorState {
6305        let mut s = new_state_with(text);
6306        for w in s.layout.windows_mut() {
6307            w.viewport.visible_lines = vis_lines;
6308            w.viewport.visible_columns = vis_cols;
6309        }
6310        s
6311    }
6312
6313    /// The core regression invariant: the active window's viewport CONTAINS
6314    /// the cursor on BOTH axes. This is the operator's exact complaint —
6315    /// "typing past the bottom (or right) leaves the cursor off-screen" —
6316    /// made into a checkable property.
6317    fn assert_cursor_in_viewport(s: &EditorState, ctx: &str) {
6318        let w = s.layout.active_window().expect("active window");
6319        let v = w.viewport;
6320        let c = s.cursor();
6321        assert!(
6322            v.top_line <= c.line && c.line < v.top_line + v.visible_lines,
6323            "[{ctx}] cursor line {} not in vertical window [{}, {}); viewport={v:?}",
6324            c.line,
6325            v.top_line,
6326            v.top_line + v.visible_lines,
6327        );
6328        assert!(
6329            v.left_column <= c.column && c.column < v.left_column + v.visible_columns,
6330            "[{ctx}] cursor column {} not in horizontal window [{}, {}); viewport={v:?}",
6331            c.column,
6332            v.left_column,
6333            v.left_column + v.visible_columns,
6334        );
6335    }
6336
6337    /// `dd` from the KEYBOARD, not from a synthesized action.
6338    ///
6339    /// The FSM composition is unit-tested, but what an operator actually
6340    /// does is press `d` twice — and that path goes through the keymap and
6341    /// the sequence stepper, either of which could swallow the second `d`.
6342    #[test]
6343    fn pressing_d_twice_deletes_the_line() {
6344        let mut st = new_state_with("alpha\nbeta\ngamma\n");
6345        st.set_cursor(Position::new(1, 0));
6346        st.tick(&press(KeyCode::Char('d')));
6347        st.tick(&press(KeyCode::Char('d')));
6348        let got = st
6349            .buffers
6350            .get(st.active)
6351            .map(|b| b.to_string())
6352            .unwrap_or_default();
6353        assert_eq!(got, "alpha\ngamma\n", "dd from the keyboard");
6354    }
6355
6356    #[test]
6357    fn pressing_2_d_d_deletes_two_lines() {
6358        let mut st = new_state_with("a\nb\nc\nd\n");
6359        st.set_cursor(Position::new(0, 0));
6360        for k in ['2', 'd', 'd'] {
6361            st.tick(&press(KeyCode::Char(k)));
6362        }
6363        let got = st
6364            .buffers
6365            .get(st.active)
6366            .map(|b| b.to_string())
6367            .unwrap_or_default();
6368        assert_eq!(got, "c\nd\n", "count applies to the doubled operator");
6369    }
6370
6371    // ── The insert-entry family: `i` `I` `a` `A` `o` `O` ─────────────────
6372    //
6373    // Measured before the family existed (escriba 0.1.71, live 80×24 TUI):
6374    // pressing `A` moved nothing, changed no mode and printed nothing, because
6375    // an unbound Normal key resolves to `Action::Pending`. Only `i` was bound.
6376
6377    /// Press one key on `text` from `at`, and report (mode, cursor, buffer).
6378    ///
6379    /// The buffer is part of the tuple on purpose: four of the six entries must
6380    /// leave it byte-identical, and a caret-only assertion cannot see a stray
6381    /// edit — which is the exact shape of the phantom-space report that started
6382    /// this work.
6383    fn entry(text: &str, at: Position, key: char) -> (Mode, Position, String) {
6384        let mut st = new_state_with(text);
6385        st.set_cursor(at);
6386        st.tick(&press(KeyCode::Char(key)));
6387        (
6388            st.modal.mode(),
6389            st.cursor(),
6390            st.buffers
6391                .get(st.active)
6392                .map(|b| b.to_string())
6393                .unwrap_or_default(),
6394        )
6395    }
6396
6397    /// The whole family, one row per entry — vim's caret placement exactly.
6398    ///
6399    /// A matrix rather than six tests so the ★★ CLOSED-LOOP MASS-SYNTHESIS
6400    /// rule has something to bite on: `every_insert_entry_has_a_key` below
6401    /// fails the build when a seventh `InsertAt` variant lands without a row.
6402    #[test]
6403    fn the_insert_entry_family_places_the_caret_like_vim() {
6404        // "  hello" — two leading blanks, so `I` and `0` differ, and 7 chars,
6405        // so "one past the end" is column 7.
6406        const TEXT: &str = "  hello\nworld\n";
6407        let from = Position::new(0, 4); // on the first `l`
6408        for (key, want_cursor, want_text, why) in [
6409            (
6410                'i',
6411                Position::new(0, 4),
6412                TEXT,
6413                "`i` inserts before the caret",
6414            ),
6415            (
6416                'I',
6417                Position::new(0, 2),
6418                TEXT,
6419                "`I` goes to the first NON-BLANK, not to column 0",
6420            ),
6421            (
6422                'a',
6423                Position::new(0, 5),
6424                TEXT,
6425                "`a` appends after the caret",
6426            ),
6427            (
6428                'A',
6429                Position::new(0, 7),
6430                TEXT,
6431                "`A` parks one PAST the last char — the whole point of the key",
6432            ),
6433            (
6434                'o',
6435                Position::new(1, 0),
6436                "  hello\n\nworld\n",
6437                "`o` opens below and lands on the new line",
6438            ),
6439            (
6440                'O',
6441                Position::new(0, 0),
6442                "\n  hello\nworld\n",
6443                "`O` opens above; the fresh line takes the caret's line number",
6444            ),
6445        ] {
6446            let (mode, cursor, text) = entry(TEXT, from, key);
6447            assert_eq!(mode, Mode::Insert, "`{key}` must enter Insert");
6448            assert_eq!(cursor, want_cursor, "{why}");
6449            assert_eq!(text, want_text, "`{key}`: {why}");
6450        }
6451    }
6452
6453    /// Every `InsertAt` has a Normal-mode key, and every one of those keys
6454    /// actually resolves to it.
6455    ///
6456    /// The forcing function. Adding a variant to `InsertAt` without binding it
6457    /// fails here rather than shipping a key that silently does nothing — which
6458    /// is precisely how `a`, `A`, `I`, `o` and `O` were missing for so long
6459    /// without any test noticing.
6460    #[test]
6461    fn every_insert_entry_has_a_key() {
6462        let km = escriba_keymap::Keymap::default_vim();
6463        let bound: Vec<InsertAt> = km
6464            .entries_sorted()
6465            .into_iter()
6466            .filter_map(|(mode, _, b)| match (mode, &b.action) {
6467                (Mode::Normal, Action::EnterInsert(at)) => Some(*at),
6468                _ => None,
6469            })
6470            .collect();
6471        for at in InsertAt::ALL {
6472            assert!(
6473                bound.contains(&at),
6474                "InsertAt::{at:?} ({}) has no Normal-mode key",
6475                at.as_str()
6476            );
6477        }
6478        assert_eq!(
6479            bound.len(),
6480            InsertAt::ALL.len(),
6481            "one key per entry, no duplicates: {bound:?}"
6482        );
6483    }
6484
6485    /// `A` then typing appends at the end — the end-to-end gesture, not just
6486    /// the caret placement.
6487    ///
6488    /// The caret assertion above would still pass if Insert mode refused to
6489    /// write at a column past the last character; this is what proves the
6490    /// `CursorRest::AtInsertPoint` rest actually holds through a keystroke.
6491    #[test]
6492    fn shift_a_then_typing_appends_at_the_end_of_the_line() {
6493        let mut st = new_state_with("hello\nworld\n");
6494        st.set_cursor(Position::new(0, 0));
6495        st.tick(&press(KeyCode::Char('A')));
6496        for c in "!!".chars() {
6497            st.tick(&press(KeyCode::Char(c)));
6498        }
6499        assert_eq!(
6500            st.buffers
6501                .get(st.active)
6502                .map(|b| b.to_string())
6503                .unwrap_or_default(),
6504            "hello!!\nworld\n"
6505        );
6506    }
6507
6508    /// `a` on the LAST character still appends, rather than stalling.
6509    ///
6510    /// The case the Normal-mode clamp would break, and the test that measured
6511    /// how. RED RUN 2026-08-12: `place_cursor`'s clamp needs BOTH
6512    /// `CursorRest::OnCharacter` and `Mode::Normal`, so this stays green if
6513    /// either guard is removed and goes red only when both are — reporting
6514    /// `column: 4` (back on the `o`) instead of 5. See `enter_insert_at`, whose
6515    /// doc comment originally over-claimed that the ordering alone carried it.
6516    #[test]
6517    fn a_on_the_last_character_appends_after_it() {
6518        let mut st = new_state_with("hello\n");
6519        st.set_cursor(Position::new(0, 4)); // the `o`
6520        st.tick(&press(KeyCode::Char('a')));
6521        assert_eq!(st.cursor(), Position::new(0, 5), "one past the `o`");
6522        st.tick(&press(KeyCode::Char('?')));
6523        assert_eq!(
6524            st.buffers
6525                .get(st.active)
6526                .map(|b| b.to_string())
6527                .unwrap_or_default(),
6528            "hello?\n"
6529        );
6530    }
6531
6532    /// No insert-entry key touches the buffer except `o`/`O`.
6533    ///
6534    /// The direct gate on the reported symptom: "hitting insert creates a
6535    /// space". It never did — the space was a RENDER defect (see
6536    /// `escriba-tui`'s `entering_insert_does_not_widen_the_rendered_line`) —
6537    /// and this test is what keeps the two explanations from being confused
6538    /// again, by pinning that the text really is untouched.
6539    #[test]
6540    fn entering_insert_types_nothing() {
6541        const TEXT: &str = "  hello\nworld\n";
6542        for key in ['i', 'I', 'a', 'A'] {
6543            let (_, _, text) = entry(TEXT, Position::new(0, 4), key);
6544            assert_eq!(text, TEXT, "`{key}` must not write a character");
6545        }
6546        for key in ['o', 'O'] {
6547            let (_, _, text) = entry(TEXT, Position::new(0, 4), key);
6548            assert_eq!(
6549                text.chars().filter(|c| *c == '\n').count(),
6550                3,
6551                "`{key}` adds exactly one line terminator and no other char"
6552            );
6553            assert!(
6554                text.contains("  hello") && text.contains("world"),
6555                "`{key}` must not disturb the existing lines: {text:?}"
6556            );
6557        }
6558    }
6559
6560    /// Binding bare `a` and `i` must NOT shadow the text objects.
6561    ///
6562    /// The regression this whole family risked. `escriba-keymap`'s rule is that
6563    /// a single binding beats a sequence prefix, so a naive `a` binding would
6564    /// have made `daw` mean "delete, then append". It does not, because
6565    /// `consume_object_key` runs before both and claims `i`/`a` only while an
6566    /// operator is armed — this test is the evidence for that sentence.
6567    #[test]
6568    fn the_insert_entry_keys_do_not_shadow_text_objects() {
6569        assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
6570        assert_eq!(keys("one two three\n", 0, 5, "diw"), "one  three\n");
6571        assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
6572        // And the operator-free path still reaches the new bindings.
6573        let (mode, cursor, _) = entry("one two\n", Position::new(0, 0), 'a');
6574        assert_eq!(mode, Mode::Insert);
6575        assert_eq!(cursor, Position::new(0, 1), "no operator ⇒ `a` appends");
6576    }
6577
6578    /// Text objects FROM THE KEYBOARD.
6579    ///
6580    /// Every bracket is unbound, and `i`/`a` are claimed by
6581    /// `consume_object_key` only while an operator waits, so all of this is
6582    /// decided on the KEY rather than in the binding table. (Until 2026-08-12
6583    /// this comment read "`i` is `ChangeMode(Insert)` in Normal and `a` … are
6584    /// unbound" — true when written, and made false by the insert-entry family
6585    /// above.)
6586
6587    fn keys(text: &str, line: u32, col: u32, seq: &str) -> String {
6588        let mut st = new_state_with(text);
6589        st.set_cursor(Position::new(line, col));
6590        for c in seq.chars() {
6591            st.tick(&press(KeyCode::Char(c)));
6592        }
6593        st.buffers
6594            .get(st.active)
6595            .map(|b| b.to_string())
6596            .unwrap_or_default()
6597    }
6598
6599    #[test]
6600    fn diw_from_the_keyboard() {
6601        assert_eq!(keys("one two three\n", 0, 5, "diw"), "one  three\n");
6602    }
6603
6604    #[test]
6605    fn daw_from_the_keyboard_takes_the_space() {
6606        assert_eq!(keys("one two three\n", 0, 5, "daw"), "one three\n");
6607    }
6608
6609    #[test]
6610    fn ciw_deletes_and_enters_insert() {
6611        let mut st = new_state_with("one two\n");
6612        st.set_cursor(Position::new(0, 5));
6613        for c in "ciw".chars() {
6614            st.tick(&press(KeyCode::Char(c)));
6615        }
6616        assert_eq!(st.modal.mode(), Mode::Insert, "change leaves you inserting");
6617        let got = st
6618            .buffers
6619            .get(st.active)
6620            .map(|b| b.to_string())
6621            .unwrap_or_default();
6622        assert_eq!(got, "one \n");
6623    }
6624
6625    #[test]
6626    fn di_paren_and_da_paren_from_the_keyboard() {
6627        assert_eq!(keys("f(a, b)\n", 0, 3, "di("), "f()\n");
6628        assert_eq!(keys("f(a, b)\n", 0, 3, "da("), "f\n");
6629    }
6630
6631    #[test]
6632    fn the_closing_bracket_and_b_are_aliases() {
6633        // vim accepts `i(`, `i)` and `ib` for the same object.
6634        for sel in ["di(", "di)", "dib"] {
6635            assert_eq!(keys("f(a, b)\n", 0, 3, sel), "f()\n", "{sel}");
6636        }
6637    }
6638
6639    #[test]
6640    fn di_quote_from_the_keyboard() {
6641        assert_eq!(keys("say \"hi\" ok\n", 0, 6, "di\""), "say \"\" ok\n");
6642    }
6643
6644    #[test]
6645    fn i_alone_still_enters_insert_when_no_operator_is_pending() {
6646        // The load-bearing negative: the object layer must not steal `i`
6647        // from ordinary use.
6648        let mut st = new_state_with("abc\n");
6649        st.tick(&press(KeyCode::Char('i')));
6650        assert_eq!(st.modal.mode(), Mode::Insert);
6651    }
6652
6653    #[test]
6654    fn an_unknown_object_key_cancels_rather_than_staying_armed() {
6655        // `diz` is not an object. The operator must disarm, and the buffer
6656        // must be untouched — not left waiting to eat the next keystroke.
6657        let mut st = new_state_with("one two\n");
6658        st.set_cursor(Position::new(0, 5));
6659        for c in "diz".chars() {
6660            st.tick(&press(KeyCode::Char(c)));
6661        }
6662        let got = st
6663            .buffers
6664            .get(st.active)
6665            .map(|b| b.to_string())
6666            .unwrap_or_default();
6667        assert_eq!(got, "one two\n", "nothing was deleted");
6668        assert_eq!(*st.op_pending.state(), OpState::Resting, "and it disarmed");
6669    }
6670
6671    // ── the register under a count ───────────────────────────────────
6672
6673    #[test]
6674    fn a_counted_delete_puts_all_of_it_in_the_register() {
6675        // `3dw` is one delete of three words as far as the register is
6676        // concerned. Each repetition emits its own Yank, and each used to
6677        // overwrite — so `3dwP` put back only the third word and silently
6678        // lost two.
6679        let mut st = new_state_with("one two three four\n");
6680        st.set_cursor(Position::new(0, 0));
6681        for c in "3dw".chars() {
6682            st.tick(&press(KeyCode::Char(c)));
6683        }
6684        assert_eq!(
6685            st.register_text(),
6686            Some("one two three "),
6687            "all three words, in the order they were deleted"
6688        );
6689    }
6690
6691    #[test]
6692    fn an_uncounted_delete_still_replaces_the_register() {
6693        // The combining flag must not leak: a later single delete replaces.
6694        let mut st = new_state_with("alpha beta\n");
6695        st.set_cursor(Position::new(0, 0));
6696        for c in "3dw".chars() {
6697            st.tick(&press(KeyCode::Char(c)));
6698        }
6699        let mut st2 = new_state_with("gamma delta\n");
6700        st2.set_cursor(Position::new(0, 0));
6701        for c in "dw".chars() {
6702            st2.tick(&press(KeyCode::Char(c)));
6703        }
6704        assert_eq!(st2.register_text(), Some("gamma "));
6705    }
6706
6707    #[test]
6708    fn two_separate_counted_deletes_do_not_accumulate_into_each_other() {
6709        // The flag is cleared after each group, so the second `2dw` starts
6710        // from empty rather than appending to the first.
6711        let mut st = new_state_with("a b c d e f\n");
6712        st.set_cursor(Position::new(0, 0));
6713        for c in "2dw".chars() {
6714            st.tick(&press(KeyCode::Char(c)));
6715        }
6716        let first = st.register_text().map(str::to_owned);
6717        for c in "2dw".chars() {
6718            st.tick(&press(KeyCode::Char(c)));
6719        }
6720        assert_eq!(first.as_deref(), Some("a b "));
6721        assert_eq!(st.register_text(), Some("c d "), "not \"a b c d \"");
6722    }
6723
6724    #[test]
6725    fn a_counted_yank_accumulates_without_changing_the_buffer() {
6726        let mut st = new_state_with("one two three\n");
6727        st.set_cursor(Position::new(0, 0));
6728        let before = st
6729            .buffers
6730            .get(st.active)
6731            .map(|b| b.to_string())
6732            .unwrap_or_default();
6733        for c in "2yw".chars() {
6734            st.tick(&press(KeyCode::Char(c)));
6735        }
6736        assert_eq!(st.register_text(), Some("one two "));
6737        let after = st
6738            .buffers
6739            .get(st.active)
6740            .map(|b| b.to_string())
6741            .unwrap_or_default();
6742        assert_eq!(after, before, "yank does not edit");
6743    }
6744
6745    // ── the anchored reply (Negai::ErrandReply) ──────────────────────
6746    //
6747    // Landed BEFORE the courier that will produce these. The class being
6748    // closed: a reply computed off the tick, applied against a world that
6749    // has since moved, and RESEALED as fresh by the interpreter — which is
6750    // what every synchronous slip correctly does and what an async one must
6751    // never do.
6752
6753    fn a_finding(buffer: BufferId, line: u32) -> escriba_shirube::Finding {
6754        use escriba_core::{Position, Range};
6755        escriba_shirube::Finding::new(
6756            escriba_shirube::Site::in_buffer(
6757                buffer,
6758                Range::new(Position::new(line, 0), Position::new(line, 1)),
6759            ),
6760            escriba_shirube::Severity::Error,
6761            "computed off the tick".to_string(),
6762            escriba_shirube::Origin::Text("test"),
6763        )
6764    }
6765
6766    #[test]
6767    fn a_fresh_errand_reply_is_honoured() {
6768        let mut st = new_state_with("a\nb\nc\n");
6769        let anchor = st.world();
6770        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
6771            anchor,
6772            then: Box::new(escriba_madoguchi::Negai::PublishFindings {
6773                list: "lsp".to_string(),
6774                findings: vec![a_finding(st.active, 1)],
6775            }),
6776        });
6777        assert_eq!(
6778            st.finding_items(true, None).len(),
6779            1,
6780            "the world had not moved"
6781        );
6782    }
6783
6784    /// THE red run. Without the freshness check this passes findings
6785    /// straight through, and `PublishFindings` reseals them with the
6786    /// CURRENT world — so they are reported fresh at columns that moved.
6787    #[test]
6788    fn a_stale_errand_reply_is_dropped_not_resealed() {
6789        let mut st = new_state_with("a\nb\nc\n");
6790        // Capture the world the "server" computed against...
6791        let anchor = st.world();
6792        // ...then let the operator keep typing, which is the whole point.
6793        st.apply(&Action::InsertChar('x'));
6794
6795        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
6796            anchor,
6797            then: Box::new(escriba_madoguchi::Negai::PublishFindings {
6798                list: "lsp".to_string(),
6799                findings: vec![a_finding(st.active, 1)],
6800            }),
6801        });
6802        assert!(
6803            st.finding_items(true, None).is_empty(),
6804            "a reply computed against an older text revision must be DROPPED, \
6805             not resealed against the current one"
6806        );
6807    }
6808
6809    /// The failure the wrapper exists for, and the reason it wraps a slip
6810    /// rather than adding an anchor field to PublishFindings: a stale EDIT
6811    /// corrupts the file, where a stale diagnostic merely mis-decorates it.
6812    #[test]
6813    fn a_stale_errand_reply_cannot_edit_the_buffer() {
6814        let mut st = new_state_with("hello\n");
6815        let anchor = st.world();
6816        st.apply(&Action::InsertChar('!'));
6817        let before = st
6818            .buffers
6819            .get(st.active)
6820            .map(|b| b.to_string())
6821            .unwrap_or_default();
6822
6823        st.honour_one(escriba_madoguchi::Negai::ErrandReply {
6824            anchor,
6825            then: Box::new(escriba_madoguchi::Negai::Edit {
6826                buffer: st.active,
6827                edit: escriba_core::Edit {
6828                    range: Range::new(Position::new(0, 0), Position::new(0, 0)),
6829                    kind: escriba_core::EditKind::Insert {
6830                        text: "FORMATTED".to_string(),
6831                    },
6832                },
6833            }),
6834        });
6835        let after = st
6836            .buffers
6837            .get(st.active)
6838            .map(|b| b.to_string())
6839            .unwrap_or_default();
6840        assert_eq!(
6841            after, before,
6842            "a stale formatter reply must not touch the text"
6843        );
6844    }
6845
6846    fn press(kc: KeyCode) -> AppEvent {
6847        AppEvent::Key(KeyEvent {
6848            key: kc,
6849            pressed: true,
6850            modifiers: Modifiers::default(),
6851            text: None,
6852        })
6853    }
6854
6855    // ── operator-over-motion (the `dw`/`c$`/`y0` verbs) ──────────────
6856
6857    fn line0_len(s: &EditorState) -> u32 {
6858        s.buffers.get(s.active).unwrap().line_len_chars(0)
6859    }
6860
6861    #[test]
6862    fn delete_to_line_end_clears_line_and_fills_register() {
6863        let mut s = new_state_with("hello world");
6864        s.apply(&Action::ApplyOperator {
6865            op: Operator::Delete,
6866            motion: Motion::LineEnd,
6867        });
6868        assert_eq!(line0_len(&s), 0, "d$ deletes to end of line");
6869        assert_eq!(
6870            s.register_text(),
6871            Some("hello world"),
6872            "delete fills the register"
6873        );
6874        assert_eq!(
6875            s.cursor(),
6876            Position::ZERO,
6877            "cursor lands at the range start"
6878        );
6879    }
6880
6881    #[test]
6882    fn delete_over_right_motion_removes_one_char() {
6883        let mut s = new_state_with("abc");
6884        s.apply(&Action::ApplyOperator {
6885            op: Operator::Delete,
6886            motion: Motion::Right,
6887        });
6888        assert_eq!(
6889            s.buffers.get(s.active).unwrap().line(0).as_deref(),
6890            Some("bc")
6891        );
6892        assert_eq!(s.register_text(), Some("a"));
6893    }
6894
6895    #[test]
6896    fn change_to_line_end_deletes_and_enters_insert() {
6897        let mut s = new_state_with("hello world");
6898        assert_eq!(s.modal.mode(), Mode::Normal);
6899        s.apply(&Action::ApplyOperator {
6900            op: Operator::Change,
6901            motion: Motion::LineEnd,
6902        });
6903        assert_eq!(line0_len(&s), 0, "c$ deletes the range");
6904        assert_eq!(
6905            s.modal.mode(),
6906            Mode::Insert,
6907            "change enters Insert to type the replacement"
6908        );
6909        assert_eq!(
6910            s.register_text(),
6911            Some("hello world"),
6912            "change fills the register"
6913        );
6914    }
6915
6916    #[test]
6917    fn yank_to_line_end_fills_register_without_mutating() {
6918        let mut s = new_state_with("hello world");
6919        s.apply(&Action::ApplyOperator {
6920            op: Operator::Yank,
6921            motion: Motion::LineEnd,
6922        });
6923        assert_eq!(line0_len(&s), 11, "yank does not mutate the buffer");
6924        assert_eq!(
6925            s.register_text(),
6926            Some("hello world"),
6927            "yank fills the register"
6928        );
6929        assert_eq!(s.modal.mode(), Mode::Normal, "yank stays in Normal");
6930    }
6931
6932    #[test]
6933    fn resolve_motion_is_the_shared_target_for_move_and_operator() {
6934        // The encapsulation proof: apply_motion (cursor move) and
6935        // apply_operator (range end) BOTH stand on resolve_motion.
6936        //
6937        // They read its answer differently AT THE BUFFER EDGE, and the
6938        // difference is vim's: `d$` deletes the last character, so the RANGE
6939        // ends after it; `$` puts the cursor ON it, because Normal mode has
6940        // nowhere past the last character to stand. One resolver, one target,
6941        // two readings — the reading is the mode's, not the motion's, which
6942        // is why the rest rule lives in `place_cursor` and not in here.
6943        let mut s = new_state_with("hello world");
6944        let target = s.resolve_motion(Position::ZERO, Motion::LineEnd).unwrap();
6945        assert_eq!(target, Position::new(0, 11), "the exclusive range end");
6946
6947        s.apply_motion(Motion::LineEnd);
6948        assert_eq!(
6949            s.cursor(),
6950            Position::new(0, 10),
6951            "`$` rests on the last character, not past it",
6952        );
6953
6954        let mut d = new_state_with("hello world");
6955        d.apply(&Action::ApplyOperator {
6956            op: Operator::Delete,
6957            motion: Motion::LineEnd,
6958        });
6959        assert_eq!(
6960            line0_len(&d),
6961            0,
6962            "`d$` deletes through the last character — the range ends where \
6963             resolve_motion said, not where the cursor may rest",
6964        );
6965    }
6966
6967    #[test]
6968    fn empty_motion_range_is_a_no_op() {
6969        // An operator over a zero-width motion (cursor already at line start)
6970        // mutates nothing and leaves the register untouched.
6971        let mut s = new_state_with("abc");
6972        s.apply(&Action::ApplyOperator {
6973            op: Operator::Delete,
6974            motion: Motion::LineStart,
6975        });
6976        assert_eq!(
6977            s.buffers.get(s.active).unwrap().line(0).as_deref(),
6978            Some("abc")
6979        );
6980        assert_eq!(s.register_text(), None);
6981    }
6982
6983    #[test]
6984    fn operator_then_motion_composes_through_the_pending_fsm() {
6985        // The full keymap→FSM→engine path: dispatching the `d` operator action
6986        // then a `$` motion composes `d$` via the zenmai operator-pending FSM —
6987        // the operator key alone does nothing until the motion arrives.
6988        let mut s = new_state_with("hello world");
6989        s.apply(&Action::Operator(Operator::Delete));
6990        assert_eq!(line0_len(&s), 11, "the operator key alone mutates nothing");
6991        s.apply(&Action::Move(Motion::LineEnd));
6992        assert_eq!(
6993            line0_len(&s),
6994            0,
6995            "d then $ composes d$ and deletes the line"
6996        );
6997        assert_eq!(s.register_text(), Some("hello world"));
6998    }
6999
7000    #[test]
7001    fn change_operator_through_fsm_enters_insert() {
7002        let mut s = new_state_with("hello world");
7003        s.apply(&Action::Operator(Operator::Change));
7004        s.apply(&Action::Move(Motion::LineEnd));
7005        assert_eq!(s.modal.mode(), Mode::Insert, "c$ deletes and enters Insert");
7006    }
7007
7008    #[test]
7009    fn lone_motion_after_no_operator_just_moves() {
7010        // Without a preceding operator the motion passes through unchanged —
7011        // and comes to rest on the last character, as Normal mode requires.
7012        let mut s = new_state_with("hello world");
7013        s.apply(&Action::Move(Motion::LineEnd));
7014        assert_eq!(s.cursor(), Position::new(0, 10));
7015        assert_eq!(line0_len(&s), 11, "a bare motion never mutates");
7016    }
7017
7018    #[test]
7019    fn counted_operator_deletes_count_times() {
7020        // `3d` + a right-motion = `3dl` = delete 3 chars. The operator's count
7021        // flows through the FSM to the composed motion (the bug fix: previously
7022        // the count repeated the operator key and toggled the FSM).
7023        let mut s = new_state_with("abcdef");
7024        s.apply_counted(&Action::Operator(Operator::Delete), 3);
7025        assert_eq!(line0_len(&s), 6, "the operator key alone mutates nothing");
7026        s.apply(&Action::Move(Motion::Right));
7027        assert_eq!(
7028            s.buffers.get(s.active).unwrap().line(0).as_deref(),
7029            Some("def")
7030        );
7031    }
7032
7033    #[test]
7034    fn operator_and_motion_counts_multiply_end_to_end() {
7035        // `2d3l` = delete 2×3 = 6 chars.
7036        let mut s = new_state_with("abcdefgh");
7037        s.apply_counted(&Action::Operator(Operator::Delete), 2);
7038        s.apply_counted(&Action::Move(Motion::Right), 3);
7039        assert_eq!(
7040            s.buffers.get(s.active).unwrap().line(0).as_deref(),
7041            Some("gh")
7042        );
7043    }
7044
7045    #[test]
7046    fn bare_counted_motion_still_repeats_no_regression() {
7047        // `3j` still moves down 3 lines — the count passes through the FSM
7048        // unchanged when no operator is pending.
7049        let mut s = new_state_with("a\nb\nc\nd\ne");
7050        s.apply_counted(&Action::Move(Motion::Down), 3);
7051        assert_eq!(s.cursor().line, 3, "5j-style counted motion preserved");
7052    }
7053
7054    /// A monotonic clock for the key-repeat gate in tests — each `next()`
7055    /// jumps a full second past the previous, so every press it stamps is
7056    /// well outside the 80ms debounce window and therefore an INTENTIONAL
7057    /// press (never a storm tick). Used by tests that fire the *same*
7058    /// navigation key twice and assert editor logic, not debounce timing.
7059    struct SpacedClock(std::time::Instant);
7060    impl SpacedClock {
7061        fn new() -> Self {
7062            Self(std::time::Instant::now())
7063        }
7064        fn next(&mut self) -> std::time::Instant {
7065            self.0 += std::time::Duration::from_secs(1);
7066            self.0
7067        }
7068    }
7069
7070    #[test]
7071    fn hjkl_moves_cursor() {
7072        let mut s = new_state_with("hello\nworld");
7073        s.tick(&press(KeyCode::Char('l')));
7074        assert_eq!(s.cursor().column, 1);
7075        s.tick(&press(KeyCode::Char('j')));
7076        assert_eq!(s.cursor().line, 1);
7077        s.tick(&press(KeyCode::Char('h')));
7078        assert_eq!(s.cursor().column, 0);
7079    }
7080
7081    #[test]
7082    fn insert_mode_inserts_chars() {
7083        let mut s = new_state_with("");
7084        s.tick(&press(KeyCode::Char('i')));
7085        assert_eq!(s.modal.mode(), Mode::Insert);
7086        s.tick(&press(KeyCode::Char('h')));
7087        s.tick(&press(KeyCode::Char('i')));
7088        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "hi");
7089        assert_eq!(s.cursor().column, 2);
7090    }
7091
7092    #[test]
7093    fn esc_returns_to_normal() {
7094        let mut s = new_state_with("");
7095        s.tick(&press(KeyCode::Char('i')));
7096        s.tick(&press(KeyCode::Escape));
7097        assert_eq!(s.modal.mode(), Mode::Normal);
7098    }
7099
7100    #[test]
7101    fn count_prefix_repeats_motion() {
7102        let mut s = new_state_with("abcdefghij");
7103        s.tick(&press(KeyCode::Char('5')));
7104        s.tick(&press(KeyCode::Char('l')));
7105        assert_eq!(s.cursor().column, 5);
7106    }
7107
7108    #[test]
7109    fn close_event_requests_quit() {
7110        let mut s = new_state_with("");
7111        s.tick(&AppEvent::CloseRequested);
7112        assert!(s.quit_requested);
7113    }
7114
7115    #[test]
7116    fn word_next_jumps_past_whitespace() {
7117        let mut s = new_state_with("foo bar baz");
7118        // Two INTENTIONAL `w` presses, spaced past the key-repeat window so
7119        // the gate passes both (a real user's two taps are ≥80ms apart).
7120        let mut clk = SpacedClock::new();
7121        s.tick_at(&press(KeyCode::Char('w')), clk.next());
7122        assert_eq!(s.cursor().column, 4);
7123        s.tick_at(&press(KeyCode::Char('w')), clk.next());
7124        assert_eq!(s.cursor().column, 8);
7125    }
7126
7127    // ── Multi-key / leader pending-stroke ───────────────────────────
7128
7129    #[test]
7130    fn leader_sequence_holds_then_resolves() {
7131        let mut s = new_state_with("a\nbb\nccc");
7132        s.keymap.bind_sequence(
7133            Mode::Normal,
7134            vec![Key::Char(','), Key::Char('g')],
7135            Action::Move(Motion::DocEnd),
7136            "doc end",
7137        );
7138        // `,` begins the sequence — held pending, nothing applied yet.
7139        s.on_key(&Key::Char(','));
7140        assert_eq!(s.pending_keys, vec![Key::Char(',')]);
7141        assert_eq!(s.cursor(), Position::ZERO);
7142        // `g` completes `<leader>g` → DocEnd; pending clears.
7143        s.on_key(&Key::Char('g'));
7144        assert!(s.pending_keys.is_empty());
7145        assert_eq!(s.cursor().line, 2);
7146    }
7147
7148    #[test]
7149    fn two_key_gg_jumps_doc_start() {
7150        let mut s = new_state_with("a\nbb\nccc");
7151        s.keymap.bind_sequence(
7152            Mode::Normal,
7153            vec![Key::Char('g'), Key::Char('g')],
7154            Action::Move(Motion::DocStart),
7155            "doc start",
7156        );
7157        let mut clk = SpacedClock::new();
7158        s.tick_at(&press(KeyCode::Char('j')), clk.next());
7159        s.tick_at(&press(KeyCode::Char('j')), clk.next());
7160        assert_eq!(s.cursor().line, 2);
7161        s.on_key(&Key::Char('g')); // pending
7162        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
7163        s.on_key(&Key::Char('g')); // resolve
7164        assert_eq!(s.cursor(), Position::ZERO);
7165    }
7166
7167    #[test]
7168    fn broken_sequence_aborts_and_clears_pending() {
7169        let mut s = new_state_with("hello");
7170        s.keymap.bind_sequence(
7171            Mode::Normal,
7172            vec![Key::Char('g'), Key::Char('g')],
7173            Action::Move(Motion::DocEnd),
7174            "doc end",
7175        );
7176        s.on_key(&Key::Char('g')); // pending [g]
7177        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
7178        s.on_key(&Key::Char('x')); // breaks gg → abort; x is unbound → no-op
7179        assert!(s.pending_keys.is_empty());
7180        assert_eq!(s.cursor(), Position::ZERO);
7181    }
7182
7183    #[test]
7184    fn single_binding_wins_over_sequence_prefix() {
7185        // A key that is BOTH a complete single binding and the start of
7186        // a sequence fires the single binding immediately (no chord
7187        // timeout needed). Here `h` (move-left) also prefixes `hz`.
7188        let mut s = new_state_with("abcde");
7189        let mut clk = SpacedClock::new();
7190        s.tick_at(&press(KeyCode::Char('l')), clk.next());
7191        s.tick_at(&press(KeyCode::Char('l')), clk.next());
7192        assert_eq!(s.cursor().column, 2);
7193        s.keymap.bind_sequence(
7194            Mode::Normal,
7195            vec![Key::Char('h'), Key::Char('z')],
7196            Action::Move(Motion::DocEnd),
7197            "shadowed",
7198        );
7199        s.on_key(&Key::Char('h'));
7200        assert!(s.pending_keys.is_empty(), "single binding should not pend");
7201        assert_eq!(s.cursor().column, 1, "h moved left immediately");
7202    }
7203
7204    // ── tatara-lisp runtime bridge (imperative programmability) ─────
7205
7206    #[test]
7207    fn lisp_set_option_writes_live_options() {
7208        let mut s = new_state_with("");
7209        s.run_lisp(r#"(set-option "number" "true")"#).unwrap();
7210        assert_eq!(s.options.get("number").map(String::as_str), Some("true"));
7211    }
7212
7213    #[test]
7214    fn lisp_insert_modifies_buffer_and_advances_cursor() {
7215        let mut s = new_state_with("");
7216        s.run_lisp(r#"(insert "abc")"#).unwrap();
7217        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
7218        assert_eq!(s.cursor(), Position::new(0, 3));
7219    }
7220
7221    #[test]
7222    fn lisp_message_appends_to_messages() {
7223        let mut s = new_state_with("");
7224        s.run_lisp(r#"(message "hello from lisp")"#).unwrap();
7225        assert_eq!(s.messages, vec!["hello from lisp".to_string()]);
7226    }
7227
7228    #[test]
7229    fn lisp_reads_snapshot_and_branches_to_effect() {
7230        // Genuine programmability: Lisp reads the live cursor line and
7231        // an `if` decides which option to set.
7232        let mut s = new_state_with("one\ntwo\nthree");
7233        // cursor at line 0 → "top" branch
7234        s.run_lisp(r#"(if (= (cursor-line) 0) (set-option "pos" "top") (set-option "pos" "mid"))"#)
7235            .unwrap();
7236        assert_eq!(s.options.get("pos").map(String::as_str), Some("top"));
7237    }
7238
7239    #[test]
7240    fn lisp_run_command_effect_drives_registry() {
7241        // `(run-command "undo")` reaches the live command registry and
7242        // reverts a prior Lisp-driven insert — proving the RunCommand
7243        // effect dispatches through real editor commands.
7244        let mut s = new_state_with("");
7245        s.run_lisp(r#"(insert "abc")"#).unwrap();
7246        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "abc");
7247        s.run_lisp(r#"(run-command "undo")"#).unwrap();
7248        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "");
7249    }
7250
7251    #[test]
7252    fn lisp_run_command_quit_sets_quit_requested_via_typed_flag() {
7253        // The full imperative-quit path: (run-command "quit") routes
7254        // through the registry's typed `quit_requested` signal — no string
7255        // sentinel, and no minibuffer pollution (the editor stays in a
7256        // clean Normal state, which has no minibuffer at all).
7257        let mut s = new_state_with("");
7258        s.run_lisp(r#"(run-command "quit")"#).unwrap();
7259        assert!(s.quit_requested, "lisp-driven quit must set quit_requested");
7260        assert_eq!(
7261            s.modal.minibuffer(),
7262            "",
7263            "quit must not pollute any command line — Normal mode has no minibuffer",
7264        );
7265    }
7266
7267    // ── Lazy plugin activation (PluginHost) ────────────────────────
7268
7269    #[test]
7270    fn lazy_plugin_activates_on_command_trigger() {
7271        // A user plugin gated on `Command: LazyGo` has its entry applied
7272        // the first time that command runs — proving the lazy.nvim
7273        // `cmd =` model works end-to-end against live editor state.
7274        let mut s = new_state_with("");
7275        s.register_lazy_plugin(
7276            "user-lazy",
7277            vec![LazyTrigger::Command("LazyGo".into())],
7278            r#"(defoption :name "lazy-loaded" :value "yes")
7279               (defcmd :name "LazyGo" :description "noop" :action "editor.noop")"#,
7280        );
7281        assert_eq!(s.plugin_host.pending(), 1);
7282        assert!(
7283            s.options.get("lazy-loaded").is_none(),
7284            "entry not applied yet"
7285        );
7286
7287        // Drive the command through the public imperative path.
7288        s.run_lisp(r#"(run-command "LazyGo")"#).unwrap();
7289
7290        assert_eq!(
7291            s.options.get("lazy-loaded").map(String::as_str),
7292            Some("yes"),
7293            "the command trigger applied the plugin's entry",
7294        );
7295        assert_eq!(s.plugin_host.pending(), 0, "plugin activated exactly once");
7296    }
7297
7298    #[test]
7299    fn lazy_plugin_activates_on_filetype() {
7300        let mut s = new_state_with("");
7301        s.register_lazy_plugin(
7302            "user-rust",
7303            vec![LazyTrigger::FileType("rust".into())],
7304            r#"(defoption :name "rust-plugin" :value "on")"#,
7305        );
7306        let n = s.activate_filetype_plugins("rust");
7307        assert_eq!(n, 1);
7308        assert_eq!(s.options.get("rust-plugin").map(String::as_str), Some("on"));
7309        // A second open of the same filetype is a no-op (one-shot).
7310        assert_eq!(s.activate_filetype_plugins("rust"), 0);
7311    }
7312
7313    #[test]
7314    fn cached_vm_serves_multiple_run_lisp_calls() {
7315        let mut s = new_state_with("");
7316        s.run_lisp(r#"(message "one")"#).unwrap();
7317        assert!(
7318            s.lisp_vm.is_some(),
7319            "VM should be cached after first run_lisp"
7320        );
7321        s.run_lisp(r#"(message "two")"#).unwrap();
7322        assert_eq!(s.messages, vec!["one".to_string(), "two".to_string()]);
7323    }
7324
7325    #[test]
7326    fn lisp_define_persists_across_run_lisp_calls() {
7327        // The cached VM's top-level env persists across calls (REPL
7328        // semantics): a `define` in one call is visible in the next.
7329        let mut s = new_state_with("");
7330        s.run_lisp(r#"(define greeting "hi")"#).unwrap();
7331        s.run_lisp(r#"(message greeting)"#).unwrap();
7332        assert_eq!(s.messages, vec!["hi".to_string()]);
7333    }
7334
7335    #[test]
7336    fn snapshot_is_isolated_within_one_run_lisp_call_and_refreshes_across() {
7337        // Within ONE call a program cannot observe its own writes — the
7338        // read snapshot is captured before eval, effects apply after. A
7339        // later call sees the refreshed snapshot.
7340        let mut s = new_state_with("");
7341        s.run_lisp(
7342            r#"(insert "ab") (set-option "col" (if (= (cursor-column) 0) "stale-zero" "live"))"#,
7343        )
7344        .unwrap();
7345        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "ab");
7346        assert_eq!(
7347            s.options.get("col").map(String::as_str),
7348            Some("stale-zero"),
7349            "cursor-column within the same call reads the pre-eval snapshot",
7350        );
7351        // After the first call the cursor advanced to column 2; the next
7352        // call's snapshot reflects it.
7353        s.run_lisp(r#"(set-option "col2" (if (= (cursor-column) 2) "live-two" "other"))"#)
7354            .unwrap();
7355        assert_eq!(
7356            s.options.get("col2").map(String::as_str),
7357            Some("live-two"),
7358            "a later call sees the refreshed snapshot",
7359        );
7360    }
7361
7362    #[test]
7363    fn insert_text_effect_multiline_lands_cursor_on_last_line() {
7364        let mut s = new_state_with("");
7365        s.apply_host_effects(vec![Negai::InsertText("foo\nbar".to_string())]);
7366        assert_eq!(s.buffers.get(s.active).unwrap().to_string(), "foo\nbar");
7367        assert_eq!(s.cursor(), Position::new(1, 3));
7368    }
7369
7370    #[test]
7371    fn visual_mode_sequence_resolves() {
7372        let mut s = new_state_with("abc");
7373        s.modal.enter(Mode::Visual);
7374        s.keymap.bind_sequence(
7375            Mode::Visual,
7376            vec![Key::Char('g'), Key::Char('e')],
7377            Action::Move(Motion::DocEnd),
7378            "ge",
7379        );
7380        s.on_key(&Key::Char('g'));
7381        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
7382        s.on_key(&Key::Char('e'));
7383        assert!(s.pending_keys.is_empty());
7384        assert_eq!(
7385            s.cursor().column,
7386            3,
7387            "ge resolved to doc-end in visual mode"
7388        );
7389    }
7390
7391    #[test]
7392    fn sequence_abort_with_bound_breaking_key_redispatches() {
7393        // gg is a sequence; `l` (move-right) is a bound single key. After
7394        // `g` pends, `l` breaks gg, aborts, and is re-dispatched fresh.
7395        let mut s = new_state_with("abcde");
7396        s.keymap.bind_sequence(
7397            Mode::Normal,
7398            vec![Key::Char('g'), Key::Char('g')],
7399            Action::Move(Motion::DocEnd),
7400            "gg",
7401        );
7402        s.on_key(&Key::Char('g'));
7403        assert_eq!(s.pending_keys, vec![Key::Char('g')]);
7404        s.on_key(&Key::Char('l'));
7405        assert!(s.pending_keys.is_empty());
7406        assert_eq!(
7407            s.cursor().column,
7408            1,
7409            "the breaking key l should re-dispatch as move-right",
7410        );
7411    }
7412
7413    // ── Viewport-follows-cursor invariant (both axes) ───────────────
7414
7415    #[test]
7416    fn viewport_contains_cursor_after_every_op() {
7417        // Tiny window: 5 visible lines × 10 visible columns. Drive a
7418        // representative scripted sequence and assert the viewport contains
7419        // the cursor after EVERY mutating step.
7420        let mut s = new_state_small_viewport("", 5, 10);
7421        assert_cursor_in_viewport(&s, "initial");
7422
7423        // Enter insert mode and type 30 newline-separated lines — this is
7424        // the exact "type past the bottom" complaint.
7425        s.tick(&press(KeyCode::Char('i')));
7426        assert_eq!(s.modal.mode(), Mode::Insert);
7427        for line in 0..30u32 {
7428            for c in "line".chars() {
7429                s.tick(&press(KeyCode::Char(c)));
7430                assert_cursor_in_viewport(&s, "typing chars");
7431            }
7432            s.tick(&press(KeyCode::Enter));
7433            assert_cursor_in_viewport(&s, &format!("newline after line {line}"));
7434        }
7435
7436        // Type a long (200-char) line — the "type past the right edge"
7437        // complaint. The cursor must stay horizontally visible the whole way.
7438        for i in 0..200u32 {
7439            s.tick(&press(KeyCode::Char('x')));
7440            assert_cursor_in_viewport(&s, &format!("long-line char {i}"));
7441        }
7442
7443        // Multi-line insert_text effect (the `(insert …)` Lisp path).
7444        s.insert_text("alpha\nbeta\ngamma delta epsilon zeta");
7445        assert_cursor_in_viewport(&s, "insert_text multiline");
7446
7447        // Back to normal mode and move in all directions / to extremes.
7448        s.tick(&press(KeyCode::Escape));
7449        assert_eq!(s.modal.mode(), Mode::Normal);
7450        for m in [
7451            Motion::DocStart,
7452            Motion::DocEnd,
7453            Motion::Down,
7454            Motion::Down,
7455            Motion::Up,
7456            Motion::Right,
7457            Motion::Right,
7458            Motion::Left,
7459            Motion::LineEnd,
7460            Motion::LineStart,
7461            Motion::GotoLine(1),
7462            Motion::GotoLine(40),
7463            Motion::PageDown,
7464            Motion::PageUp,
7465        ] {
7466            s.apply_motion(m);
7467            assert_cursor_in_viewport(&s, &format!("after motion {m:?}"));
7468        }
7469
7470        // Undo many times — the buffer shrinks; the viewport must re-follow
7471        // the (now clamped) cursor.
7472        for i in 0..50u32 {
7473            s.apply(&Action::Undo);
7474            assert_cursor_in_viewport(&s, &format!("undo {i}"));
7475        }
7476        // Redo back up.
7477        for i in 0..50u32 {
7478            s.apply(&Action::Redo);
7479            assert_cursor_in_viewport(&s, &format!("redo {i}"));
7480        }
7481    }
7482
7483    #[test]
7484    fn insert_at_eof_keeps_cursor_in_bounds() {
7485        // Inserting at the end of the buffer must leave the cursor clamped
7486        // to a valid position (and inside the viewport).
7487        let mut s = new_state_small_viewport("abc", 5, 10);
7488        s.apply_motion(Motion::DocEnd);
7489        s.tick(&press(KeyCode::Char('i')));
7490        s.tick(&press(KeyCode::Char('d')));
7491        let buf = s.buffers.get(s.active).unwrap();
7492        let clamped = buf.clamp(s.cursor());
7493        assert_eq!(
7494            s.cursor(),
7495            clamped,
7496            "cursor must be clamped in-bounds at EOF"
7497        );
7498        assert_cursor_in_viewport(&s, "insert at eof");
7499    }
7500
7501    #[test]
7502    fn count_prefix_then_sequence_repeats() {
7503        // `2` then `gj` (→ move-down) repeats the resolved action twice.
7504        let mut s = new_state_with("a\nb\nc\nd\ne");
7505        s.keymap.bind_sequence(
7506            Mode::Normal,
7507            vec![Key::Char('g'), Key::Char('j')],
7508            Action::Move(Motion::Down),
7509            "gj",
7510        );
7511        s.on_key(&Key::Char('2'));
7512        s.on_key(&Key::Char('g'));
7513        s.on_key(&Key::Char('j'));
7514        assert_eq!(s.cursor().line, 2, "count 2 should repeat the gj motion");
7515    }
7516
7517    // ── Key-repeat gate (awase::KeyRepeatGate) ──────────────────────────
7518
7519    #[test]
7520    fn held_key_repeat_storm_is_debounced_in_normal_mode() {
7521        // The audit's exact complaint: holding `j` floods motion events
7522        // and thrashes the viewport. Simulate an OS key-repeat storm — 20
7523        // identical `j` KeyDowns at 50ms intervals (typical repeat cadence)
7524        // — and assert only the gated subset (one per 80ms window) actually
7525        // moves the cursor.
7526        let mut s = new_state_with(&"x\n".repeat(40));
7527        let t0 = std::time::Instant::now();
7528        let mut delivered = 0u32;
7529        for i in 0..20u32 {
7530            let before = s.cursor().line;
7531            s.tick_at(
7532                &press(KeyCode::Char('j')),
7533                t0 + std::time::Duration::from_millis(u64::from(i) * 50),
7534            );
7535            if s.cursor().line != before {
7536                delivered += 1;
7537            }
7538        }
7539        // 20 events over ~1s at 50ms spacing, 80ms gate ⇒ ~13 pass — far
7540        // fewer than the 20 the ungated path would have applied.
7541        assert!(
7542            (10..=14).contains(&delivered),
7543            "expected the storm debounced to ~13 moves, got {delivered}",
7544        );
7545        assert!(
7546            delivered < 20,
7547            "the gate must drop SOME storm ticks, not pass all 20",
7548        );
7549    }
7550
7551    #[test]
7552    fn spaced_intentional_taps_all_pass() {
7553        // Intentional taps spaced past the debounce window must ALL reach
7554        // the editor — the gate filters storms, never deliberate input.
7555        let mut s = new_state_with(&"x\n".repeat(10));
7556        let t0 = std::time::Instant::now();
7557        for i in 0..5u32 {
7558            s.tick_at(
7559                &press(KeyCode::Char('j')),
7560                // 100ms apart — comfortably past the 80ms window.
7561                t0 + std::time::Duration::from_millis(u64::from(i) * 100),
7562            );
7563        }
7564        assert_eq!(s.cursor().line, 5, "all 5 spaced `j` taps moved the cursor");
7565    }
7566
7567    #[test]
7568    fn distinct_keys_have_independent_clocks() {
7569        // Holding `j` must not block a simultaneous `l` — the gate keys on
7570        // the Key, so independent keys have independent windows.
7571        let mut s = new_state_with("abc\ndef\nghi");
7572        let t = std::time::Instant::now();
7573        s.tick_at(&press(KeyCode::Char('j')), t);
7574        // `j` again within the window is dropped…
7575        s.tick_at(
7576            &press(KeyCode::Char('j')),
7577            t + std::time::Duration::from_millis(10),
7578        );
7579        assert_eq!(s.cursor().line, 1, "second `j` within window dropped");
7580        // …but `l` at the same instant passes (its own clock).
7581        s.tick_at(
7582            &press(KeyCode::Char('l')),
7583            t + std::time::Duration::from_millis(10),
7584        );
7585        assert_eq!(s.cursor().column, 1, "`l` is not blocked by `j`'s clock");
7586    }
7587
7588    // ── Cursors newtype is the single cursor home ──────────────────────
7589
7590    #[test]
7591    fn cursor_home_preserves_single_cursor_behavior() {
7592        // The typed `Cursors` wrapper behaves exactly like the old bare
7593        // `Position` field for single-cursor editing: the read accessor
7594        // tracks every mutation routed through `set_cursor`, and there is
7595        // exactly one caret.
7596        let mut s = new_state_with("hello\nworld\nthere");
7597        assert_eq!(s.cursor(), Position::ZERO);
7598        assert_eq!(s.cursors.count(), 1, "phase-1 holds exactly one caret");
7599
7600        s.apply_motion(Motion::Down);
7601        s.apply_motion(Motion::Right);
7602        s.apply_motion(Motion::Right);
7603        assert_eq!(s.cursor(), Position::new(1, 2));
7604        // Still a single caret after a sequence of motions.
7605        assert_eq!(s.cursors.count(), 1);
7606
7607        // The accessor is the SAME value the viewport-follow path read.
7608        let w = s.layout.active_window().unwrap();
7609        assert!(w.viewport.top_line <= s.cursor().line);
7610    }
7611
7612    #[test]
7613    fn insert_mode_is_ungated_so_repeat_typing_works() {
7614        // Holding a key to repeat-type a character is intended in Insert
7615        // mode — the gate must NOT suppress it. 10 rapid identical `x`
7616        // keystrokes at the same instant must all land as text.
7617        let mut s = new_state_with("");
7618        s.tick(&press(KeyCode::Char('i')));
7619        assert_eq!(s.modal.mode(), Mode::Insert);
7620        let t = std::time::Instant::now();
7621        for _ in 0..10 {
7622            s.tick_at(&press(KeyCode::Char('x')), t);
7623        }
7624        assert_eq!(
7625            s.buffers.get(s.active).unwrap().to_string(),
7626            "xxxxxxxxxx",
7627            "insert-mode repeat typing is ungated",
7628        );
7629    }
7630
7631    // ── the courier seam (denrei) ────────────────────────────────────
7632    //
7633    // What these pin is not "work happens off-thread" — that is the runner's
7634    // business. It is that a reply computed against one world cannot be
7635    // applied against a different one, and that the machinery says so out loud
7636    // when it declines to do something.
7637
7638    mod courier_seam {
7639        use super::new_state_with;
7640        use escriba_madoguchi::Negai;
7641        use escriba_madoguchi::errand::{Crew, Errand, Freight, Parcel, Runner};
7642        use escriba_shirube::{Anchor, Axis, ResultList, SessionKind};
7643        use std::sync::Arc;
7644        use std::sync::atomic::AtomicBool;
7645        use std::sync::mpsc::Sender;
7646
7647        fn a_scan() -> Freight {
7648            Freight::Scan {
7649                raw: "needle".into(),
7650                case: escriba_search::CaseMode::Smart,
7651                root: ".".into(),
7652            }
7653        }
7654
7655        /// Replies with whatever slip it was built with, immediately and on the
7656        /// calling thread — so these tests assert the SEAM, not thread timing.
7657        struct Says(Negai);
7658        impl Runner for Says {
7659            fn start(&self, e: Errand, _c: Arc<AtomicBool>, reply: Sender<Parcel>) {
7660                let _ = reply.send(Parcel {
7661                    id: e.id,
7662                    slip: self.0.clone(),
7663                });
7664            }
7665        }
7666
7667        /// Replies by wrapping its payload in the anchor the DISPATCHER sealed
7668        /// — which is what a real runner does: it echoes back the seal it was
7669        /// handed, because it has no way to mint its own.
7670        struct EchoesSeal(Negai);
7671        impl Runner for EchoesSeal {
7672            fn start(&self, e: Errand, _c: Arc<AtomicBool>, reply: Sender<Parcel>) {
7673                let _ = reply.send(Parcel {
7674                    id: e.id,
7675                    slip: Negai::ErrandReply {
7676                        anchor: e.anchor.into_anchor(),
7677                        then: Box::new(self.0.clone()),
7678                    },
7679                });
7680            }
7681        }
7682
7683        fn crew_with_scan(r: impl Runner + 'static) -> Crew {
7684            Crew {
7685                scan: Box::new(r),
7686                diagnostics: Box::new(escriba_madoguchi::errand::Idle("t")),
7687                format: Box::new(escriba_madoguchi::errand::Idle("t")),
7688            }
7689        }
7690
7691        /// The whole path in one test: a handler names a class of work, the
7692        /// dispatcher seals it, a runner answers, and the reply is applied at a
7693        /// tick boundary.
7694        #[test]
7695        fn an_errand_is_dispatched_sealed_and_its_reply_applied_at_the_drain() {
7696            let mut st = new_state_with("x\n");
7697            st.hire(crew_with_scan(EchoesSeal(Negai::Message("done".into()))));
7698
7699            st.honour_one(Negai::Errand(Box::new(a_scan())));
7700            assert!(
7701                !st.messages.iter().any(|m| m == "done"),
7702                "nothing is applied before the drain"
7703            );
7704
7705            st.deliver();
7706            assert!(
7707                st.messages.iter().any(|m| m == "done"),
7708                "the reply lands at the drain: {:?}",
7709                st.messages
7710            );
7711        }
7712
7713        /// **The reason the whole seam exists.** A reply sealed against the
7714        /// world at dispatch must be discarded once that world has moved.
7715        #[test]
7716        fn a_reply_whose_world_moved_is_dropped() {
7717            let mut st = new_state_with("x\n");
7718            st.hire(crew_with_scan(EchoesSeal(Negai::Message("late".into()))));
7719
7720            st.honour_one(Negai::Errand(Box::new(a_scan())));
7721            // The surface the scan feeds closed while it was running.
7722            st.bump_scan_gen();
7723            st.deliver();
7724
7725            assert!(
7726                !st.messages.iter().any(|m| m == "late"),
7727                "a superseded reply must not be applied: {:?}",
7728                st.messages
7729            );
7730        }
7731
7732        /// The converse, so the test above is not passing because nothing ever
7733        /// applies.
7734        #[test]
7735        fn a_reply_whose_world_held_is_applied() {
7736            let mut st = new_state_with("x\n");
7737            st.hire(crew_with_scan(EchoesSeal(Negai::Message("ok".into()))));
7738            st.honour_one(Negai::Errand(Box::new(a_scan())));
7739            st.deliver();
7740            assert!(st.messages.iter().any(|m| m == "ok"));
7741        }
7742
7743        /// A scan must NOT be staled by typing. It reads the filesystem; no
7744        /// text revision has anything to say about it, and anchoring one on the
7745        /// buffers would kill every result on the next keystroke.
7746        #[test]
7747        fn typing_does_not_stale_a_scan_reply() {
7748            let mut st = new_state_with("x\n");
7749            st.hire(crew_with_scan(EchoesSeal(Negai::Message("rows".into()))));
7750            st.honour_one(Negai::Errand(Box::new(a_scan())));
7751
7752            st.insert_text("hello");
7753            st.deliver();
7754            assert!(
7755                st.messages.iter().any(|m| m == "rows"),
7756                "a scan does not depend on buffer text: {:?}",
7757                st.messages
7758            );
7759        }
7760
7761        /// The seal's OWN anchor becomes the list's seal. Re-sealing at the
7762        /// arrival world would widen a one-axis claim into an every-buffer one,
7763        /// so the findings would die on the next unrelated edit.
7764        #[test]
7765        fn findings_from_an_errand_keep_the_narrow_seal_they_were_computed_with() {
7766            let mut st = new_state_with("x\n");
7767            st.hire(crew_with_scan(EchoesSeal(Negai::PublishFindings {
7768                list: "grep".into(),
7769                findings: vec![],
7770            })));
7771            st.honour_one(Negai::Errand(Box::new(a_scan())));
7772            st.deliver();
7773
7774            let sealed_with = st.results.get("grep").expect("published").anchor().clone();
7775            let axes = sealed_with.axes();
7776            assert_eq!(axes.len(), 1, "narrow, not the whole world: {axes:?}");
7777            assert!(
7778                matches!(axes[0], Axis::Session(SessionKind::Scan, _)),
7779                "sealed on the scan session: {axes:?}"
7780            );
7781
7782            // …and the consequence that makes it worth doing: an edit
7783            // elsewhere does not discard it.
7784            st.insert_text("more");
7785            assert!(
7786                !st.results
7787                    .get("grep")
7788                    .expect("still there")
7789                    .is_stale(&st.world()),
7790                "an unrelated edit must not stale a scan list"
7791            );
7792        }
7793
7794        /// A directly-dispatched `PublishFindings` — an on-tick producer like
7795        /// the marker scan — still seals at the world, which is correct for it.
7796        /// The special case must not have changed that.
7797        #[test]
7798        fn a_direct_publish_still_seals_at_the_world() {
7799            let mut st = new_state_with("x\n");
7800            st.honour_one(Negai::PublishFindings {
7801                list: "todo".into(),
7802                findings: vec![],
7803            });
7804            let axes = st.results.get("todo").expect("published").anchor().axes();
7805            assert!(
7806                axes.len() > 1,
7807                "the on-tick path anchors on the whole world: {axes:?}"
7808            );
7809        }
7810
7811        /// An empty anchor is fresh against every world, so a forged reply
7812        /// carrying one bypasses the gate entirely. The courier cannot produce
7813        /// this — `seal` returns a `NonEmptyAnchor` — and the test exists to
7814        /// document why that type is not decoration.
7815        #[test]
7816        fn an_empty_anchor_would_bypass_the_gate_which_is_why_seal_cannot_mint_one() {
7817            let mut st = new_state_with("x\n");
7818            st.bump_scan_gen();
7819            st.bump_lsp_gen();
7820            st.insert_text("moved a long way");
7821
7822            st.honour_one(Negai::ErrandReply {
7823                anchor: Anchor::new(),
7824                then: Box::new(Negai::Message("forged".into())),
7825            });
7826            assert!(
7827                st.messages.iter().any(|m| m == "forged"),
7828                "an empty anchor passes any world — the hazard NonEmptyAnchor removes"
7829            );
7830        }
7831
7832        /// Closing the picker supersedes the scan feeding it. Both closing
7833        /// paths must do it — choosing a row closes the overlay exactly as Esc
7834        /// does, and only handling Esc leaves a scan running after every pick.
7835        #[test]
7836        fn closing_the_picker_supersedes_the_scan_it_was_feeding() {
7837            let mut st = new_state_with("x\n");
7838            st.hire(crew_with_scan(EchoesSeal(Negai::Message("rows".into()))));
7839            st.honour_one(Negai::Errand(Box::new(a_scan())));
7840
7841            st.close_picker();
7842            st.deliver();
7843            assert!(
7844                !st.messages.iter().any(|m| m == "rows"),
7845                "rows must not reopen a picker the operator closed: {:?}",
7846                st.messages
7847            );
7848        }
7849
7850        /// The default state. An errand with nobody hired must report that it
7851        /// went nowhere — a request that silently does nothing is the exact
7852        /// failure the pre-courier stub had.
7853        #[test]
7854        fn an_errand_with_no_crew_hired_says_so() {
7855            let mut st = new_state_with("x\n");
7856            st.honour_one(Negai::Errand(Box::new(a_scan())));
7857            st.deliver();
7858            assert!(
7859                st.messages.iter().any(|m| m.contains("scan")),
7860                "the inert crew announces: {:?}",
7861                st.messages
7862            );
7863        }
7864
7865        /// A quiet tick must be free — `deliver` is called on every frame.
7866        #[test]
7867        fn delivering_nothing_does_not_repaint() {
7868            let mut st = new_state_with("x\n");
7869            let before = st.edit_gen();
7870            st.deliver();
7871            assert_eq!(st.edit_gen(), before, "an empty drain is not a change");
7872        }
7873
7874        /// …and a tick that DID deliver must repaint, or the result sits in
7875        /// state that nothing draws.
7876        #[test]
7877        fn delivering_something_repaints() {
7878            let mut st = new_state_with("x\n");
7879            st.hire(crew_with_scan(Says(Negai::Message("hi".into()))));
7880            st.honour_one(Negai::Errand(Box::new(a_scan())));
7881            let before = st.edit_gen();
7882            st.deliver();
7883            assert_ne!(st.edit_gen(), before, "a delivered reply repaints");
7884        }
7885
7886        /// The two session kinds must not alias at the runtime level either: an
7887        /// LSP restart must not discard scan results, and vice versa.
7888        #[test]
7889        fn the_two_session_generations_are_independent() {
7890            let mut st = new_state_with("x\n");
7891            let scan_sealed = ResultList::new(
7892                vec![],
7893                Anchor::new().on(Axis::Session(SessionKind::Scan, st.scan_gen)),
7894            );
7895            st.bump_lsp_gen();
7896            assert!(
7897                !scan_sealed.is_stale(&st.world()),
7898                "an LSP restart must not discard scan results"
7899            );
7900            st.bump_scan_gen();
7901            assert!(scan_sealed.is_stale(&st.world()), "…but a scan bump does");
7902        }
7903
7904        #[test]
7905        fn every_freight_class_seals_on_something() {
7906            let mut st = new_state_with("x\n");
7907            let active = st.active;
7908            for freight in [
7909                a_scan(),
7910                Freight::Diagnostics {
7911                    buffer: active,
7912                    path: "a.nix".into(),
7913                    language: None,
7914                    text: String::new(),
7915                },
7916                Freight::Format {
7917                    buffer: active,
7918                    path: "a.nix".into(),
7919                    language: None,
7920                    text: String::new(),
7921                },
7922            ] {
7923                let sealed = st.seal(&freight);
7924                assert!(
7925                    !sealed.as_anchor().is_empty(),
7926                    "{} sealed on nothing",
7927                    freight.label()
7928                );
7929            }
7930            let _ = &mut st;
7931        }
7932    }
7933}