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