Skip to main content

escriba_runtime/
lib.rs

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