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