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