Skip to main content

escriba_runtime/
lib.rs

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