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