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