lattice_grammar/effect.rs
1//! What a `CommandInvocation` produced once executed.
2//!
3//! [`Effect`] is the **host boundary**: every evaluator, ex-command, mode
4//! action handler and WASM plugin describes what it wants done as an
5//! `Effect` value, and the host (`lattice-host`'s `handle_effect`, then the
6//! renderer peers for the few renderer-coupled arms) applies it. Producers
7//! never hold `&mut Editor`; that is what makes built-ins, modes and plugins
8//! peers, and what lets macros, dot-repeat and the async inbound path replay
9//! the same values.
10//!
11//! `Effect::None` is for read-only or selection-only commands. `Effect::Edits`
12//! carries the `AppliedEdit`s that the dispatcher applied to the document
13//! (suitable for `Event::DocumentChanged`). `Effect::SelectionChange` carries
14//! the new selection set (suitable for `Event::SelectionsChanged`). Effects
15//! compose; a single command can yield multiple via `Effect::Many`.
16//!
17//! # Coordinates
18//!
19//! Every [`Position`](lattice_protocol::position::Position) here is 0-based
20//! `(line, byte)` -- a UTF-8 byte offset within the line, not a char or
21//! UTF-16 column -- and every protocol `Range` is half-open. The one
22//! exception is [`Utf16Pos`], which exists precisely to carry an
23//! unconverted LSP column.
24//!
25//! # Which buffer
26//!
27//! Unless a variant names a buffer (`target`, `view`, a `BufferId`, a
28//! path or a synthetic name), it acts on the **focused** buffer / active
29//! pane *at apply time*. That is right for a chord-time effect and wrong
30//! for an async one; see [`Effect::CursorMoveIn`] and
31//! [`Effect::ApplyEdit`] for the addressed forms.
32//!
33//! # Where each variant is applied
34//!
35//! Most arms run host-side in `lattice_host::dispatch::handle_effect`,
36//! synchronously and in order. A minority are *peer-applied* (the TUI's
37//! `apply_effect_app_arms` and the GPUI peer): `QuitEditor`, `OpenBuffer`,
38//! `OpenBufferAt`, `OpenInTarget`, `Global`, the picker / prompt /
39//! transient / popup / file-tree / oil openers, most `Lsp*` commands. A
40//! peer-applied effect runs *after* every host-applied effect of the same
41//! batch, and is dropped on the off-keystroke inbound path (which applies
42//! host-side only) -- which is why host-applied twins such as
43//! [`Effect::OpenBufferAtColumn`] exist.
44//!
45//! Ex-command effects (`SaveBuffer`, `QuitEditor`, `OpenBuffer`, `SetOption`,
46//! `ClearSearchHighlight`, `Echo`, `EchoRegisters`, `EchoMarks`, `Substitute`,
47//! `Global`) carry the typed intent of an ex-command. The host applies them
48//! using its own state (registers, marks, view options, document loader);
49//! the closure inside the registry only needs to package args into the
50//! correct effect, which is what makes plugin- and built-in ex-commands
51//! peers (DESIGN.md §5.2.1, §5.2.4).
52
53use std::path::PathBuf;
54
55use lattice_core::buffer::AppliedEdit;
56use lattice_protocol::selection::SelectionSet;
57
58use crate::app_effect::AppEffect;
59use crate::command::CommandInvocation;
60use crate::modal::ModalState;
61use crate::register::Register;
62
63/// How a yank captured its content. Drives paste behavior:
64/// charwise yanks land at the cursor, linewise yanks land on the next
65/// line below, blockwise yanks paste each '\n'-separated row at the
66/// same column on consecutive lines (vim's `Ctrl-V` selection then
67/// `y`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub enum YankKind {
70 /// A span of characters; `p` inserts after the cursor, `P` at it.
71 Charwise,
72 /// Whole lines (content ends with a newline); `p` opens below the
73 /// cursor line, `P` above.
74 Linewise,
75 /// A Visual-block rectangle: rows joined by `'\n'`, pasted one per
76 /// line at the cursor's column.
77 Blockwise,
78}
79
80/// Severity tier for `Effect::Echo`. The host's echo-area renderer maps
81/// these to its own colour scheme.
82///
83/// **msg-mode.1 (tracing bridge):** the five variants mirror
84/// `tracing::Level` exactly so `App::set_message` can route through
85/// a single `tracing::event!` call without lossy conversion. `Trace` +
86/// `Debug` are below the default `Info` filter so they don't show
87/// in the echo area today — they exist for subsystems that want
88/// verbose records in `*messages*` (e.g. `editor=trace`, `lsp=debug`)
89/// to surface without inventing a parallel severity scale.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub enum EchoLevel {
92 /// `tracing::Level::TRACE`; recorded, never echoed by default.
93 Trace,
94 /// `tracing::Level::DEBUG`; recorded, never echoed by default.
95 Debug,
96 /// Ordinary feedback ("3 substitutions").
97 Info,
98 /// Something the user should notice but that did not fail.
99 Warn,
100 /// A failed command (vim's `E…` messages).
101 Error,
102}
103
104impl From<EchoLevel> for tracing::Level {
105 fn from(level: EchoLevel) -> Self {
106 match level {
107 EchoLevel::Trace => tracing::Level::TRACE,
108 EchoLevel::Debug => tracing::Level::DEBUG,
109 EchoLevel::Info => tracing::Level::INFO,
110 EchoLevel::Warn => tracing::Level::WARN,
111 EchoLevel::Error => tracing::Level::ERROR,
112 }
113 }
114}
115
116impl From<tracing::Level> for EchoLevel {
117 fn from(level: tracing::Level) -> Self {
118 // `tracing::Level` is a unit-struct wrapper; match via
119 // associated consts because the inner repr is not pub.
120 match level {
121 tracing::Level::TRACE => EchoLevel::Trace,
122 tracing::Level::DEBUG => EchoLevel::Debug,
123 tracing::Level::INFO => EchoLevel::Info,
124 tracing::Level::WARN => EchoLevel::Warn,
125 tracing::Level::ERROR => EchoLevel::Error,
126 }
127 }
128}
129
130/// Scope for `Effect::Substitute`. Mirrors vim's `:s/.../.../` (current
131/// line) vs. `:%s/.../.../` (whole buffer).
132#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
133pub enum SubstituteScope {
134 /// `:s` -- the cursor's line only.
135 CurrentLine,
136 /// `:%s` -- every line of the buffer.
137 Whole,
138}
139
140/// Scope for `Effect::QuitEditor`. Mirrors vim's `:q` (close the active
141/// pane; quit only when it is the last one) vs. `:qa` (quit the editor
142/// regardless of how many panes / tabs are open). `:q` and `:qa` stay
143/// distinct *commands* (separate registrations + aliases); `QuitScope`
144/// is the one axis on which they differ, so the shared shutdown + dirty
145/// guard stays in a single `Editor::do_quit`.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147pub enum QuitScope {
148 /// `:q[!]` -- close the active pane when more than one is open;
149 /// run the dirty guard and shut the editor only on the last pane.
150 Pane,
151 /// `:qa[!]` -- ignore pane / tab count; run the dirty guard and
152 /// shut the editor outright.
153 All,
154}
155
156/// BC.8c: a UTF-16 code-unit column on a given line. LSP's default
157/// position encoding counts UTF-16 code units, not bytes; converting to
158/// Lattice's byte offset (`lattice_protocol::Position.byte`) needs the
159/// target line's text, which only exists after the file is open. So
160/// [`Effect::OpenBufferAtColumn`] carries this *unconverted* column for
161/// the host to resolve post-open. Plain `u32`s — no `lsp_types` leak.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163pub struct Utf16Pos {
164 /// 0-based line.
165 pub line: u32,
166 /// 0-based column in UTF-16 code units (LSP's default encoding), not
167 /// bytes.
168 pub col: u32,
169}
170
171/// L7 (lsp-architecture.md §16): which LSP **navigation** request a
172/// mode-owned nav chord (`K` / `gd` / `gD` / `gy` / `gI` / `gr` / `gx`)
173/// wants the host to fire. Pure data — no `lsp_types`, no `lattice-lsp`
174/// dependency — so it can ride inside the host-owned [`Effect::Lsp`]
175/// boundary. `lsp-mode`'s `action_handlers()` closure returns
176/// `Effect::Lsp(LspRequest::X)`; the host's `editor.lsp_request`
177/// dispatcher maps each arm onto the existing (unchanged) async request
178/// substrate (`lsp_hover_request` / `lsp_nav_request` /
179/// `lsp_references_request` / `do_lsp_follow_link_at_cursor`). The
180/// handler carries no position — the substrate reads live `Editor`
181/// cursor/scroll, so the popup/jump anchors to the symbol the chord
182/// fired on.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum LspRequest {
185 /// `K` -- `textDocument/hover`.
186 Hover,
187 /// `gd` -- `textDocument/definition`.
188 Definition,
189 /// `gD` -- `textDocument/declaration`.
190 Declaration,
191 /// `gy` -- `textDocument/typeDefinition`.
192 TypeDefinition,
193 /// `gI` -- `textDocument/implementation`.
194 Implementation,
195 /// `gr` -- `textDocument/references`.
196 References,
197 /// `gx` -- follow the `textDocument/documentLink` covering the cursor.
198 FollowLink,
199 /// LR.2 (2026-08-11): `:lsp-references` -- the same
200 /// `textDocument/references` query as [`Self::References`], landing
201 /// at the **editable multibuffer** terminus instead of the picker.
202 ///
203 /// A data arm rather than a new `Effect` variant, per §16's grain:
204 /// a further LSP surface adds an arm here, not a host `Action`, not
205 /// a renderer classifier entry. The request, cancellation token,
206 /// anchor stash and wake are the unchanged substrate; only what the
207 /// drain does with the result differs.
208 ReferencesView,
209 /// LR.3 (2026-08-11): `gr` **inside** a references view — re-run the
210 /// query at the view's stored origin and rebuild it in place.
211 ///
212 /// Distinct from [`Self::ReferencesView`] because the position it
213 /// queries is different: this one must NOT read the live cursor.
214 /// By the time a refresh fires the cursor sits inside the
215 /// multibuffer, so querying there would ask about whatever symbol
216 /// happens to be under it — a different question with a
217 /// plausible-looking answer. The substrate reads the origin the
218 /// view recorded when it opened.
219 ReferencesViewRefresh,
220 /// EP.6 (2026-08-11): `:lsp-references-to-error-list` — the same
221 /// query, landing in the **error list** instead of a surface.
222 ///
223 /// A third terminus rather than a cache snapshot: unlike
224 /// diagnostics there is no standing "current references" state to
225 /// pull from, so the command must run the query.
226 ReferencesToErrorList,
227}
228
229/// Where in a target file an [`Effect::WriteToFile`] lands.
230///
231/// # Examples
232///
233/// ```
234/// use lattice_grammar::effect::FileAnchor;
235///
236/// // A 3-line file: append inserts before line 3 (one past the last).
237/// assert_eq!(FileAnchor::End.resolve_line(3), 3);
238/// assert_eq!(FileAnchor::Start.resolve_line(3), 0);
239/// assert_eq!(FileAnchor::Line(1).resolve_line(3), 1);
240/// // Past the end clamps to append instead of failing.
241/// assert_eq!(FileAnchor::Line(9).resolve_line(3), 3);
242/// ```
243///
244/// A **position**, not a range, and the asymmetry with [`Effect::ApplyEdit`]
245/// is the answer rather than an inconsistency to tidy away
246/// (`cross-file-writes.md` §4).
247///
248/// For its own buffer a producer holds a document handle: it can read lines,
249/// count them, and compute a range that means something. For another file it
250/// holds nothing — it has never seen the bytes, so a range it invented would
251/// be a guess. These are the three positions namable without reading, and
252/// they are exactly the three archive, refile and capture need.
253///
254/// Insert-only also bounds the blast radius: it cannot silently destroy
255/// content in a file the user was not looking at, where a range-carrying
256/// primitive could on an off-by-one and the user would find out later.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum FileAnchor {
259 /// After the last line — the common case. Archive, refile and capture all
260 /// append by default.
261 End,
262 /// Before the first line.
263 Start,
264 /// Before this 0-based line. Past the end clamps to [`FileAnchor::End`]
265 /// rather than erroring: a producer computing a line from a file it has
266 /// not read can legitimately be off, and refusing to file the text at all
267 /// is worse than filing it at the end.
268 Line(u32),
269}
270
271impl FileAnchor {
272 /// The 0-based line an insert should happen *before*, given the target's
273 /// line count.
274 ///
275 /// `line_count` is the number of content lines. `End` yields
276 /// `line_count`, i.e. one past the last — which is what "append" means to
277 /// the caller building a `Position`.
278 pub fn resolve_line(self, line_count: u32) -> u32 {
279 match self {
280 FileAnchor::Start => 0,
281 FileAnchor::End => line_count,
282 // Clamp, per the variant's doc. `>=` and not `>`: a `Line` equal
283 // to the count already means "past the last line", which is End.
284 FileAnchor::Line(n) => n.min(line_count),
285 }
286 }
287}
288
289/// What a command asks the host to do. See the [module docs](self) for
290/// the coordinate convention, which buffer an un-addressed variant acts on,
291/// and which variants are host- vs. peer-applied.
292///
293/// # Contract for producers
294///
295/// - An `Effect` is a *request*; it cannot report failure back
296/// (`apply_effect_host` returns nothing to the producer). Put anything that
297/// must happen only after a write lands *after* it in an
298/// [`Effect::Many`] -- a [`Effect::WriteToFile`] that fails stops the
299/// rest of its batch.
300/// - An evaluator that fails returns `Err` instead of an effect; nothing is
301/// committed (see [`crate::CommandError`]).
302/// - Async producers address their buffer explicitly
303/// ([`Effect::CursorMoveIn`], [`Effect::ApplyEdit`]) rather than
304/// assuming the focused one.
305///
306/// # Examples
307///
308/// ```
309/// use lattice_grammar::{Effect, EchoLevel, Register, YankKind};
310///
311/// // `yy` on "hello": yank the line, tell the user nothing.
312/// let yank = Effect::Yank {
313/// register: Register::Unnamed,
314/// content: "hello\n".into(),
315/// kind: YankKind::Linewise,
316/// explicit_yank: true,
317/// };
318///
319/// // Effects compose; the host applies `Many` children in order.
320/// let e = Effect::Many(vec![
321/// yank,
322/// Effect::Echo { level: EchoLevel::Info, text: "1 line yanked".into() },
323/// ]);
324/// assert!(!e.is_none());
325/// assert!(Effect::None.is_none());
326/// ```
327#[derive(Debug, Clone)]
328pub enum Effect {
329 /// Nothing to apply; the chord is consumed. Read-only commands and
330 /// commands whose work already happened return this. Not forwarded to
331 /// the renderer.
332 None,
333 /// AP.0.2: the action DECLINES this chord — it did nothing, and the
334 /// dispatcher should re-resolve the chord as if this action's keymap layer
335 /// weren't present, falling through to the next binding (a lower-priority
336 /// minor, the builtin/user layer, or Insert-mode self-insert). The
337 /// `with-eval-after-load` of keymaps: a plugin action (auto-pair's manual
338 /// close key / backspace) declines when it has nothing to do, so the key
339 /// still does whatever else is bound (completion nav, a normal backspace, a
340 /// user remap). Distinct from `None` (a no-op that CONSUMES the chord).
341 Declined,
342 /// Edits the grammar dispatcher has **already applied** to the focused
343 /// document (the operator ran against the document actor). The host
344 /// only routes the side effects: `DocumentChanged` (LSP `didChange`,
345 /// syntax reparse, highlight shift, dot-repeat recording). It also
346 /// moves the cursor to the first edit's `original_range.start` -- a
347 /// following [`Effect::CursorMove`] / [`Effect::SelectionChange`] in
348 /// the same [`Effect::Many`] overrides that.
349 ///
350 /// Not for code outside the dispatcher: an edit that has not been
351 /// applied yet goes through [`Effect::ApplyEdit`].
352 Edits(Vec<AppliedEdit>),
353 /// CR.0: a generic "apply this edit to this buffer" primitive.
354 ///
355 /// Mode-contributed action handlers (the snippet / lsp
356 /// `action_handlers()` pattern) compute an edit against their own
357 /// state — a diff hunk get/put, a conflict resolution — and hand it
358 /// back through this effect. The host applies `edit` to `target`,
359 /// routing through the active-document pipeline (LSP `didChange` +
360 /// syntax reparse + highlight shift) when `target` is the focused
361 /// buffer, or the peer-buffer registry handle otherwise; when
362 /// `cursor` is `Some`, it then parks the active cursor at that
363 /// **position** (line + byte) — column-precise, so a plugin action
364 /// (auto-pair) can place the caret *between* an inserted pair, not
365 /// only at a row start (AP.2). Native row-start callers pass
366 /// `Position::new(row, 0)`.
367 ///
368 /// Distinct from [`Effect::Edits`], which carries `AppliedEdit`s the
369 /// grammar dispatcher already applied to the active document (routed
370 /// only for side effects). `ApplyEdit` carries a *pending* `Edit` the
371 /// host has yet to apply, addressed at an explicit `target`.
372 ///
373 /// The Effect *vocabulary* is the host boundary by design
374 /// (`feedback_effect_vocabulary_is_host_boundary`): this lets a mode
375 /// drive an arbitrary document edit without the host growing a
376 /// feature-specific `Action` variant + `do_<x>` method per feature.
377 ///
378 /// **Deferred:** the host translates it into an `Action::ApplyEdit`
379 /// queued on the outcome's `next_actions`, so it lands after the
380 /// current effect batch has been applied, not in sequence with it.
381 ApplyEdit {
382 /// The buffer to edit. Need not be focused.
383 target: lattice_core::BufferId,
384 /// The pending edit: a half-open `(line, byte)` range in `target`'s
385 /// current (pre-edit) coordinates, plus what replaces it.
386 edit: lattice_protocol::edit::Edit,
387 /// Where to leave the **active** cursor afterwards, in post-edit
388 /// coordinates; `None` leaves it alone.
389 cursor: Option<lattice_protocol::position::Position>,
390 },
391 /// XF.1: move text into a file the editor has not necessarily opened.
392 ///
393 /// Design: [`cross-file-writes.md`](../../../docs/dev/architecture/cross-file-writes.md).
394 /// The primitive `org-archive-subtree`, `org-refile` and `org-capture`
395 /// were all blocked on — every one of them is "take text from here and
396 /// put it in a different file", and nothing in the vocabulary could say
397 /// the second half. [`Effect::ApplyEdit`] addresses a `BufferId`, which a
398 /// producer cannot learn for a file that has never been opened.
399 ///
400 /// **Through the document pipeline, not to disk.** The host resolves
401 /// `path` to a buffer, opening it in the background if needed and REUSING
402 /// it if it is already open. So an open target sees the edit, `u` covers
403 /// it, and the LSP hears about it — a direct write would leave the buffer
404 /// and the disk disagreeing with nobody told.
405 ///
406 /// **The target is left modified, not saved.** Emacs's `org-refile` and
407 /// `org-archive-subtree` both do; a plugin that silently writes files is a
408 /// larger authority than one that edits buffers, and should be an explicit
409 /// later decision if it is ever wanted.
410 WriteToFile {
411 /// Absolute, or relative to the editor's working directory.
412 ///
413 /// When this effect arrives from a plugin it has already been checked
414 /// against that plugin's `fs:write` grant AT THE BOUNDARY, where the
415 /// provenance is still known — the host's applier deliberately cannot
416 /// tell a plugin's effect from a native mode's, so the gate cannot
417 /// live there (XF.4).
418 path: std::path::PathBuf,
419 /// Where in the target the text lands.
420 anchor: FileAnchor,
421 /// Inserted verbatim. A trailing newline is the producer's business.
422 text: String,
423 /// When present, this range is removed from the buffer the action ran
424 /// in — and ONLY after the insert has landed.
425 ///
426 /// Folding the pair into one effect is what makes the bad outcome
427 /// unrepresentable: as two effects, "insert succeeded, delete failed"
428 /// duplicates the text and "delete succeeded, insert failed" LOSES
429 /// it. The second is data loss from a keystroke. An effect cannot
430 /// report failure at all (pinned by XF.0), so two ordered effects
431 /// could not be made to depend on each other without changing every
432 /// effect's signature.
433 cut: Option<lattice_protocol::position::Range>,
434 /// OR.10: create missing parent directories rather than refusing.
435 ///
436 /// **Off by default, and that default is the rule rather than caution.**
437 /// The host refuses a missing parent because creating directories is a
438 /// larger authority than creating a file, and a typo'd path must not
439 /// silently build a tree ([`crate::Effect::WriteToFile`]'s applier says
440 /// so in as many words). That stays true for every producer that does
441 /// not ask.
442 ///
443 /// A producer asks when the directory is part of the *layout it owns*
444 /// rather than something the user typed. org-roam's
445 /// `daily/YYYY-MM-DD.org` is the case that forced this: the folder is
446 /// named by an option with a default, no user ever types it, and
447 /// without this the very first `:org-roam-dailies-today` on a fresh
448 /// corpus fails — the one use where the feature has to work.
449 ///
450 /// Still bounded: a plugin's path is checked against its `fs:write`
451 /// grant at the boundary before this is read, so asking widens what is
452 /// created inside the grant and never what is reachable.
453 create_parents: bool,
454 /// OC.9: persist the target to disk once the write has landed, instead
455 /// of leaving the buffer modified.
456 ///
457 /// **Off by default, and the default is the rule.**
458 /// `cross-file-writes.md` §7 leaves a target open, listed and MODIFIED
459 /// — which is what emacs's `org-refile` and `org-archive-subtree` do,
460 /// and the user writes it themselves after reviewing. Every producer
461 /// that does not ask keeps exactly that.
462 ///
463 /// A producer asks when its whole operation *is* "commit this
464 /// somewhere". org-capture is that case, and emacs agrees loudly:
465 /// `org-capture-finalize` runs
466 /// `(unless (org-capture-get :no-save) (save-buffer))`, so saving is
467 /// the default there and `:no-save` is the opt-OUT. The asymmetry with
468 /// refile is not an inconsistency — a refile moves text you are
469 /// looking at, a capture files text you are finished with.
470 ///
471 /// It is also what decides whether readers of the FILE ever see the
472 /// write. The org agenda scans from disk, so an unsaved capture cannot
473 /// appear in a refresh however correct the buffer is.
474 ///
475 /// §7 recorded a `save: bool` as **rejected** ("the answer is uniformly
476 /// no, which is an easier thing for a user to know"). That argument was
477 /// sound for the producers that existed when it was written and wrong
478 /// for capture, whose entire contract is durability; the flag keeps the
479 /// uniform answer for everyone who does not opt in, and the design doc
480 /// records the reversal rather than dropping the paragraph.
481 ///
482 /// **Only after a landed insert**, and after any `cut`. The ordering
483 /// `cut` documents extends here: a write that failed saves nothing, so
484 /// this can never persist a half-applied effect.
485 save: bool,
486 },
487 /// Replace the focused buffer's selection set; motions return this with
488 /// a collapsed primary (`anchor == head`). The host moves the cursor to
489 /// the primary's head. In Visual / Select mode it keeps the selection
490 /// alive: a collapsed primary *extends* from the running Visual anchor
491 /// (a motion), a non-collapsed one is adopted whole (a text object
492 /// such as `viw`). Outside Visual only the cursor moves.
493 SelectionChange(SelectionSet),
494 /// Move the focused buffer's cursor to this `(line, byte)`.
495 /// The semantically-clean cursor-only jump — use this for navigation
496 /// chords (`]]`/`[[`, `]c`/`[c`, `]f`/`[f`) rather than overloading SelectionChange
497 /// with a collapsed cursor. The host writes `editor.cursor`; in Visual /
498 /// Select mode it also extends the selection from the Visual anchor to
499 /// the new position, exactly as a motion would.
500 ///
501 /// Chord-time only. An async producer must use
502 /// [`Effect::CursorMoveIn`]: by the time its result lands the focused
503 /// buffer may be a different one.
504 CursorMove(lattice_protocol::position::Position),
505 /// MG.18d: [`Effect::CursorMove`] addressed at a **specific buffer** —
506 /// the host moves the cursor only while `target` is the focused
507 /// buffer, and drops the effect otherwise.
508 ///
509 /// The peer of [`Effect::ApplyEdit`]'s `target` field, for the
510 /// cursor half. A chord-time `CursorMove` needs no target because the
511 /// buffer it fired in *is* the focused one; an **async** producer has
512 /// no such guarantee. Between a magit stage and the refresh that
513 /// finishes it lie two git calls, and a `q` or `C-^` in that window
514 /// would otherwise land the jump in whatever buffer the user moved
515 /// to — a caret teleporting in a file they were about to type in.
516 ///
517 /// Dropping (rather than stashing for the buffer's return) is
518 /// deliberate: the position was computed against content that will
519 /// have been rebuilt again by the time focus comes back, and a
520 /// stale jump is worse than none. Producers that want position
521 /// restored on return use marks / position history, which are
522 /// per-buffer by construction.
523 ///
524 /// Unlike `CursorMove` it does not touch a Visual selection.
525 CursorMoveIn {
526 /// The buffer `position` was computed against.
527 target: lattice_core::BufferId,
528 /// 0-based `(line, byte)` in `target`.
529 position: lattice_protocol::position::Position,
530 },
531 /// Write text into a register (and the host's yank ring). Every
532 /// operator that captures text emits one: yank, delete, change, `x`.
533 ///
534 /// The host always updates the unnamed register too, stores into
535 /// `register` when it names one, and ignores the whole effect for
536 /// [`Register::BlackHole`]. It moves no cursor and edits nothing.
537 Yank {
538 /// Destination register; [`Register::Unnamed`] when none was
539 /// named with `"<x>`.
540 register: Register,
541 /// The captured text, verbatim. Linewise content ends with `\n`.
542 content: String,
543 /// Shape of the capture; decides how a later put lays it out.
544 kind: YankKind,
545 /// `true` when this write came from an explicit **yank** (`y`,
546 /// `yy`, Visual `y`); `false` for the register writes that
547 /// delete / change / `x` also perform. Drives the *yank-only*
548 /// system-clipboard mirror (`clipboard.md` §5): the host mirrors
549 /// to the OS clipboard only when this is an explicit yank (under
550 /// the `clipboard` option) or the target is the `+`/`*` register,
551 /// so an incidental delete never clobbers the clipboard.
552 explicit_yank: bool,
553 },
554 /// Transition the modal state machine. Used by operators that change
555 /// modes after committing edits (vim's `c` -> Insert, future `s`,
556 /// `gv` reselect Visual, etc.).
557 EnterMode(ModalState),
558
559 // --- Ex-command effects (DESIGN.md §5.2.1) ---
560 /// `:w [path]` -- write the current buffer (to the given path, or the
561 /// document's known path).
562 ///
563 /// Host-applied (works on the off-keystroke inbound path too). In an oil
564 /// buffer it applies the listing's edits to the filesystem instead.
565 SaveBuffer {
566 /// Write-as target. A user-typed path: `~` expands and a relative
567 /// path joins the editor's `:cd` directory. `None` writes the
568 /// buffer's own file (an error echo if it has none).
569 path: Option<PathBuf>,
570 },
571 /// `:q[!]` (`scope = Pane`) / `:qa[!]` (`scope = All`) -- quit.
572 /// `force = true` ignores dirty state. The two are distinct
573 /// *commands* but one *effect*: quit is a single host operation
574 /// parameterized by scope, exactly as `force` is a parameter.
575 /// `Pane` closes the active pane when more than one is open and
576 /// only shuts the editor on the last pane (vim's `:q`); `All`
577 /// ignores pane/tab count and shuts the editor outright (vim's
578 /// `:qa`). The dirty guard (unless forced) is identical for both
579 /// and lives once in `Editor::do_quit`.
580 ///
581 /// Peer-applied.
582 QuitEditor {
583 /// `!` -- skip the dirty-buffer guard.
584 force: bool,
585 /// Pane (`:q`) or whole editor (`:qa`).
586 scope: QuitScope,
587 },
588 /// `:e[!] [path]` -- swap the current document for the file at `path`.
589 /// With `path = None` reload from the document's existing path.
590 /// `force = true` discards unsaved changes.
591 ///
592 /// Peer-applied. A file already open in another buffer is activated,
593 /// not reloaded; naming the focused buffer's own file reloads it (and
594 /// needs `force` when dirty); a directory opens a directory view.
595 OpenBuffer {
596 /// User-typed path (`~` and `:cd`-relative resolved by the host);
597 /// `None` reloads the focused document from its own path.
598 path: Option<PathBuf>,
599 /// `!` -- allow a reload that discards unsaved changes.
600 force: bool,
601 },
602 /// M.10.3 bug fix (2026-06-03): atomic "open file + position
603 /// cursor." Used by mode-contributed jump handlers (search
604 /// `<CR>`, lsp-references `<CR>`, future project-diff `<CR>`)
605 /// so the cursor lands at the matched (row, byte) inside
606 /// the newly-opened buffer on the FIRST render.
607 ///
608 /// Necessary because `Effect::SelectionChange` runs
609 /// synchronously in the host's `handle_effect` (writes to
610 /// `editor.cursor` against whatever buffer is active at
611 /// that moment), while `Effect::OpenBuffer` is renderer-
612 /// coupled — applied LATER by the TUI/GPUI peers via
613 /// `do_edit`. The two can't be ordered to land cursor on
614 /// the new buffer without an atomic step. The peer
615 /// renderer's arm for this variant calls `do_edit` THEN
616 /// `set_selections_blocking` in a single atomic block.
617 ///
618 /// Pre-fix, `<CR>` on a search hit opened the file but
619 /// landed at (0,0) because the host's SelectionChange ran
620 /// first (against the still-active multibuffer) and the
621 /// later `do_edit` reset cursor for the freshly-loaded
622 /// document.
623 OpenBufferAt {
624 /// As [`Effect::OpenBuffer`]'s `path`.
625 path: Option<PathBuf>,
626 /// Where the cursor lands in the opened buffer, 0-based
627 /// `(line, byte)`. Closed folds around it are opened.
628 position: lattice_protocol::position::Position,
629 /// As [`Effect::OpenBuffer`]'s `force`.
630 force: bool,
631 /// CD.2: text for the buffer **only when the file is not on disk**.
632 /// Reopening a file that exists never has its text replaced — a
633 /// saved draft reopened must keep what was typed into it. The seed
634 /// is an ordinary edit, so the buffer is modified and `:q` guards it.
635 content: Option<String>,
636 /// CD.2: a minor mode to activate alongside the major the path
637 /// resolves, before the buffer is shown, so its keymap is live on
638 /// the first keystroke. OC.7a's reason: a plugin mode has no
639 /// `on_activate`, so this is how a guest gives its buffer chords.
640 activate_minor: Option<String>,
641 },
642 /// LM.0: open `path` at `position` in a target pane —
643 /// split / vsplit / new tab. The peer-applied sibling of
644 /// [`Effect::OpenBufferAt`] with a pane target: its peer arm runs
645 /// `Editor::prepare_open_target_pane(target)` (the picker-accept
646 /// split sequence) then `Editor::open_buffer_at(path, position, …)`.
647 ///
648 /// `<CR>` (current pane) still returns plain [`Effect::OpenBufferAt`];
649 /// this is what the listing majors' `<C-s>`/`<C-v>`/`<C-t>` chords
650 /// return so a file lands in a new split / vsplit / tab. `target =
651 /// Default` is equivalent to `OpenBufferAt` and kept for uniformity.
652 ///
653 /// Host/peer-only for now: no WIT mirror (a plugin opening in a split
654 /// is a deliberate future WIT addition, see `boundary_effect`).
655 OpenInTarget {
656 /// As [`Effect::OpenBuffer`]'s `path`.
657 path: Option<PathBuf>,
658 /// Where the cursor lands, 0-based `(line, byte)`.
659 position: lattice_protocol::position::Position,
660 /// Which pane: current, new split, new vsplit, or new tab.
661 target: lattice_core::ui::pane::OpenTarget,
662 },
663 /// LM.2: re-list an existing oil buffer `view` to `dir` in place —
664 /// the peer-applied sibling of the directory-navigation half of the
665 /// old `do_oil_follow` / `do_oil_navigate_up`. The applier reloads
666 /// the directory snapshot (fs I/O), rewrites the buffer's rope, and
667 /// resets cursor/scroll. `focus` is the entry name to land the cursor
668 /// on afterwards (the came-from directory for `-` parent-navigation);
669 /// `None` lands at the top (a `<CR>` descent into a child).
670 ///
671 /// Names `view` explicitly so many oil buffers stay independent: the
672 /// applier touches only that buffer's dir/snapshot/rope (design §3.2).
673 /// Host/peer-only — no WIT mirror.
674 OilNavigate {
675 /// The oil buffer to re-list.
676 view: lattice_core::BufferId,
677 /// The directory it now lists.
678 dir: PathBuf,
679 /// Entry name to put the cursor on after the re-list; `None` =
680 /// first row.
681 focus: Option<String>,
682 },
683 /// LM.2: toggle the expansion of file-tree `view`'s directory entry
684 /// at `entry_index` (the row under the cursor), re-rendering the
685 /// tree's rope — the peer-applied sibling of the directory half of
686 /// `do_file_tree_follow`. Names `view` so trees stay independent.
687 /// Host/peer-only — no WIT mirror.
688 FileTreeToggle {
689 /// The file-tree buffer.
690 view: lattice_core::BufferId,
691 /// 0-based index into the tree's current entry list (the row under
692 /// the cursor). Out of range is a silent no-op.
693 entry_index: u32,
694 },
695 /// BC.8c: open `uri` via the OS handler (`open` / `xdg-open` /
696 /// `explorer`). Emitted by the LSP `window/showDocument` handler for
697 /// `external: true` requests; generic enough to reuse for any "open
698 /// this URL externally" need (`gx`, markdown / help external links).
699 /// A plain URL string — no `lsp_types` leak into the grammar.
700 /// **Host-applied** in `Editor::handle_effect`: the spawn is a host
701 /// side-effect, and the showDocument bus drains off-keystroke through
702 /// the generic inbound tick-callback (where peer-applied effects are
703 /// not forwarded), so the work must run host-side.
704 OpenExternalUri {
705 /// Any URI the OS handler accepts (`https:`, `file:`, `mailto:`).
706 /// A spawn failure is logged, not echoed.
707 uri: String,
708 },
709 /// BC.8c: **host-applied** atomic open + optional UTF-16-column
710 /// cursor placement. Unlike [`Effect::OpenBufferAt`] (peer-applied,
711 /// carrying a pre-converted byte offset), this runs entirely in
712 /// `Editor::handle_effect` so it works on the off-keystroke async
713 /// path: server-initiated `window/showDocument` drains through the
714 /// generic inbound tick-callback, where peer-applied effects are
715 /// discarded — the open must happen host-side (as the retired
716 /// `drain_inbound_show_documents` did via `do_edit`).
717 ///
718 /// `column = None` opens only, leaving the cursor where `do_edit`
719 /// puts it (the no-selection showDocument case). `Some` positions the
720 /// cursor at the UTF-16 code-unit column, converted to a byte offset
721 /// against the opened line — the conversion needs the line text, which
722 /// only exists post-open, which is why the column travels unconverted.
723 OpenBufferAtColumn {
724 /// As [`Effect::OpenBuffer`]'s `path`.
725 path: Option<PathBuf>,
726 /// Cursor target as an unconverted LSP position; `None` = open only.
727 column: Option<Utf16Pos>,
728 /// As [`Effect::OpenBuffer`]'s `force`.
729 force: bool,
730 },
731 /// I5.1 (Claude Code IDE peer): spawn a child process in a new
732 /// `BufferKind::Terminal` buffer, optionally injecting extra environment
733 /// and activating a minor mode on the new buffer. Host-applied (the open is
734 /// irreducibly `&mut Editor`): the host calls `do_terminal_spawn` with
735 /// `cmd_line` + `env`, then activates `activate_minor` (a minor mode, by its
736 /// mode-id name) on the spawned buffer when set. `:terminal` reaches the
737 /// same host path via the host-side `AppEffect::TerminalSpawn`; this grammar
738 /// variant lets crate-owned ex-commands — the IDE peer's `:claude`, which
739 /// must inject `CLAUDE_CODE_SSE_PORT` + `ENABLE_IDE_INTEGRATION` — request
740 /// the spawn through the Effect vocabulary (the host boundary) instead of a
741 /// bespoke channel.
742 SpawnTerminal {
743 /// Command line (`program [args...]`); `None` spawns `$SHELL`.
744 cmd_line: Option<String>,
745 /// PC.2: working directory to spawn in, overriding the active buffer's
746 /// project root for this spawn only.
747 ///
748 /// `lattice_terminal::SpawnConfig` has carried a `cwd` since the
749 /// terminal shipped ("`None` = inherit parent's cwd"); this is the
750 /// boundary catching up, so a producer that knows WHICH project it
751 /// means can say so. `None` keeps PR.3's behaviour exactly: spawn at
752 /// the active buffer's project root.
753 ///
754 /// Binding at spawn is what lets several projects coexist —
755 /// `Command::cwd` applies once, so a shell already running is the OS's
756 /// business and nothing resolved later can move it.
757 cwd: Option<std::path::PathBuf>,
758 /// Extra environment injected on top of the inherited parent env.
759 env: Vec<(String, String)>,
760 /// Minor mode to activate on the spawned buffer, by mode-id name (e.g.
761 /// `"claude-code-mode"`); `None` leaves the buffer mode-bare.
762 activate_minor: Option<String>,
763 },
764 /// D-fix.4 (Claude Code IDE peer): write raw bytes to the focused
765 /// terminal's PTY. Host-applied (`do_terminal_input`, irreducibly
766 /// `&mut Editor` + the terminal registry). The IDE peer's
767 /// `:claude-interrupt` emits `TerminalInput(vec![0x1b])` to forward an
768 /// `<Esc>` to the running `claude` CLI — `<Esc>` can't be sent by typing
769 /// because the terminal's modal layer consumes it for Insert→Normal, so
770 /// an ex-command is the only interrupt path. Targets the active pane's
771 /// terminal (the focused claude session); a no-op (logged) when the
772 /// active buffer isn't a terminal.
773 TerminalInput(Vec<u8>),
774 /// `:set <option>` -- the host parses the option spec; the closure
775 /// just hands the raw text through.
776 SetOption {
777 /// Everything after `:set ` (`name`, `noname`, `name=value`,
778 /// `name?`, ...). Parsed and validated by the host's config
779 /// registry; a parse error is echoed and nothing is written.
780 spec: String,
781 },
782 /// `:setlocal <option>` -- like `SetOption` but writes to the
783 /// buffer-local override layer for the active buffer only, without
784 /// touching the global config registry.
785 SetLocalOption {
786 /// Same syntax as [`Effect::SetOption`]'s `spec`.
787 spec: String,
788 },
789 /// `:setglobal <option>` -- like `SetOption` but only writes the
790 /// global config registry without updating any buffer-local override
791 /// layers. Reads back the global value on `:setglobal name?`.
792 SetGlobalOption {
793 /// Same syntax as [`Effect::SetOption`]'s `spec`.
794 spec: String,
795 },
796 /// `:noh[lsearch]` -- clear the hlsearch overlay.
797 ClearSearchHighlight,
798 /// `:colorscheme <name>` (T.9.b) -- swap the active theme by name.
799 /// The host looks `name` up in `lattice_theme::builtin_themes()`
800 /// and calls `ThemeRegistry::set_theme` (palette + override swap),
801 /// then emits `RendererSignal::ThemeChanged` so both renderers
802 /// rebuild their caches. An unknown name echoes a host-side error.
803 /// The closure only packages the name (it has no registry access).
804 SetColorscheme(String),
805 /// Display a one-line message in the echo area. Also appended to
806 /// `*messages*` and published as a message event; `Trace` / `Debug`
807 /// are recorded but not shown.
808 Echo {
809 /// Severity; decides colour and whether it is shown.
810 level: EchoLevel,
811 /// The message. Keep it to one line.
812 text: String,
813 },
814 /// L4b (lsp-architecture.md §15): show a cursor-anchored popup with
815 /// the pre-formatted diagnostic lines for the cursor's line. The
816 /// owning mode (`lsp-diagnostics-mode`) formats `lines` in its
817 /// `gl` handler; the host renders them through the hover popup
818 /// pipeline (`HelpContent` → `DisplayBufferRequest`,
819 /// `PopupPlacement::CursorAnchored`). Empty `lines` → host echoes
820 /// "no diagnostics on line" instead of an empty popup. Each line is
821 /// `(text, severity_rank)` where rank is Error = 0 … Hint = 3
822 /// (matching `lattice_lsp`'s severity_rank); the host colours each
823 /// popup line by its severity via the matching `Style::Diagnostic*`
824 /// highlight.
825 ShowDiagnosticsPopup {
826 /// `(text, severity_rank)` per popup line; rank 0 = Error, 1 =
827 /// Warning, 2 = Information, 3 = Hint.
828 lines: Vec<(String, u8)>,
829 },
830 /// L7 (lsp-architecture.md §16): fire a mode-owned LSP **navigation**
831 /// request (`K` / `gd` / `gD` / `gy` / `gI` / `gr` / `gx`). The
832 /// owning `lsp-mode` `action_handlers()` closure decides *which*
833 /// request via [`LspRequest`]; the host's `editor.lsp_request`
834 /// dispatcher runs the (unchanged) async request substrate
835 /// host-side. Host-applied — both renderers treat it as a
836 /// host-handled no-op in their effect classifiers, and it neither
837 /// mutates nor yanks. `LspRequest::FollowLink` is the one variant
838 /// that yields `RendererSignal`s synchronously (open buffer / OS
839 /// handler); the apply arm extends `out.renderer_signals`.
840 Lsp(LspRequest),
841 /// `:reg[isters]` -- the host formats and displays its own register
842 /// state.
843 EchoRegisters,
844 /// `:marks` -- the host formats and displays its own mark state.
845 EchoMarks,
846 /// `:[%]s/pat/repl/[g]` -- run substitute over the given scope, one
847 /// edit per changed line. Echoes the count, or `E486` when nothing
848 /// matched.
849 Substitute {
850 /// Which lines.
851 scope: SubstituteScope,
852 /// A `fancy_regex` pattern (Rust regex syntax plus look-around and
853 /// backreferences) -- not vim's regex dialect. Empty is an error.
854 pattern: String,
855 /// Replacement template; `$1` / `${name}` expand capture groups.
856 replacement: String,
857 /// `g` flag -- every match on a line, not just the first.
858 global: bool,
859 },
860 /// `:g/pat/body` (and `:v/pat/body` with `inverted = true`).
861 /// `body` is a pre-parsed [`CommandInvocation`] -- the parser
862 /// front-end (`lattice-host::excommand`) compiles it once at
863 /// `:g` parse time so the host doesn't re-parse per matching
864 /// line, and so body parse errors surface before `:g` fires.
865 ///
866 /// Peer-applied. The host collects the matching lines from a snapshot
867 /// first, then walks them **bottom-up**, placing the cursor at column 0
868 /// of each and dispatching `body` there, so edits never shift a line
869 /// still to be visited. No WIT mirror (`CommandInvocation` has none).
870 Global {
871 /// Matched as a **literal substring** today, unlike
872 /// [`Effect::Substitute`]'s regex. Empty is an error.
873 pattern: String,
874 /// `:v` / `:g!` -- run on the lines that do *not* match.
875 inverted: bool,
876 /// The command to run on each selected line.
877 body: Box<CommandInvocation>,
878 },
879 /// `:d` -- delete the current line including its trailing newline.
880 /// Distinct from the standard `delete` operator with a `CurrentLine`
881 /// range, which preserves the newline (vim's `dd` semantics differ
882 /// from `:d` -- §5.2.1).
883 DeleteCurrentLine,
884 /// `:describe-command <name>` (DESIGN.md §5.11). The host queries
885 /// its `CommandRegistry` for the named entry and renders the
886 /// metadata into a help overlay. Carried as a sentinel because
887 /// the closure has no registry access.
888 ///
889 /// `anchor` (optional) tells the host to scroll the help to a
890 /// named anchor after rendering -- used by the cmdline's
891 /// arg-aware `<C-h>` to land on `arg:<name>` directly.
892 DescribeCommand {
893 /// Canonical name (`ex:write`, `motion:word-forward`) or an
894 /// ex-command alias.
895 name: String,
896 /// Help anchor to scroll to, e.g. `arg:<name>`; `None` = top.
897 anchor: Option<String>,
898 },
899 /// `:describe-buffer`. The host renders a snapshot of the current
900 /// buffer's view-relevant state (path, language, modal, cursor,
901 /// dirty, line count, ...).
902 DescribeBuffer,
903 /// `:apropos <pattern>`. The host runs a substring search over
904 /// every registered `CommandSpec` (name + doc) and renders the
905 /// matches.
906 Apropos {
907 /// Case-insensitive substring matched against names and docs.
908 pattern: String,
909 },
910 /// `:describe-key <chord>` (DESIGN.md §5.11). The host queries
911 /// its keymap registry for every binding of `chord` (a chord may
912 /// appear in multiple modes -- Normal / Visual / Help, etc.) and
913 /// renders them.
914 DescribeKey {
915 /// Canonical chord notation (`<C-w>v`, `gg`), as produced by the
916 /// cmdline's chord-capture slot.
917 chord: String,
918 },
919 /// `:keymap`. The host renders the full default keymap grouped by
920 /// mode.
921 ListKeymap,
922 /// `:bn[ext]` -- cycle to the next open document buffer.
923 BufferNext,
924 /// `:bp[rev]` -- cycle to the previous open document buffer.
925 BufferPrev,
926 /// CD.1: show the buffer with this id in the active pane.
927 ///
928 /// The peer of [`Effect::ApplyEdit`]'s `target`: a plugin can edit a
929 /// buffer it knows only by id, and this is how it shows one. Opening by
930 /// path or by name cannot serve a buffer known only as an id — which is
931 /// how a capture knows the buffer it was fired from.
932 ///
933 /// **Host-applied**, so it works on the off-keystroke paths too. An id
934 /// that no longer names a buffer is a no-op logged at `debug!`: a buffer
935 /// closing between an action and its effect is an ordinary race, not a
936 /// producer bug.
937 FocusBuffer(u32),
938 /// CD.3d: run a registered command after the effects before this one.
939 ///
940 /// An action is dispatched with `args` typed; anything else runs as an ex
941 /// line. The picker's `invoke-command` outcome, as an effect.
942 ///
943 /// **Why an effect and not a host call.** A plugin's host calls run while
944 /// its action runs — before the host applies any effect the action
945 /// returns. Work that must happen only *after* a returned effect landed
946 /// (capture deletes its draft only once the entry is filed) cannot be a
947 /// host call; it goes here, after the effect it depends on. A write that
948 /// does not land stops the batch, so this does not run.
949 InvokeCommand {
950 /// A registered command name. If it names a `CommandKind::Action`
951 /// it is dispatched with `args`; otherwise `id` plus `args` is run
952 /// as an ex line.
953 id: String,
954 /// Arguments for the command (rendered onto the ex line in the
955 /// ex-line case).
956 args: crate::args::Args,
957 },
958 /// `:ls` / `:buffers` -- render every open document buffer in a
959 /// help-style view.
960 ListBuffers,
961 /// `:cd [path]` -- change the editor's working directory (what relative
962 /// paths in `:e` / `:w` resolve against). The path is user-typed: `~`
963 /// expands, relative joins the current `:cd` directory. `None` changes
964 /// to the user's home directory.
965 ChangeDir(Option<String>),
966 /// `:pwd` -- print the current working directory.
967 PrintWorkingDir,
968 /// PR.2: `:project-root` -- print the active buffer's project root
969 /// and which marker decided it. The introspection affordance for
970 /// project resolution: "why is my terminal opening here" is
971 /// otherwise only answerable by reading the source.
972 PrintProjectRoot,
973 /// `:b` with no arg -- open the vertico-style buffer switcher
974 /// (DESIGN.md §5.9.7). The user types to filter, `<CR>` to
975 /// switch. Type-aware completion in the cmdline can pre-fill
976 /// the picker via `:b <prefix>` once that wiring lands; for now
977 /// the no-arg form is the entry point.
978 OpenBufferPicker,
979 /// `:picker <source> [args...]` -- canonical picker entry
980 /// point. Dispatches by `source` against the host's
981 /// `PickerRegistry`. Unknown source ids surface as a
982 /// host-side error echo; per-source arg shape is opaque to
983 /// the grammar (the host re-parses `args` against the
984 /// resolved source's `args_schema`). Short per-source
985 /// aliases like `:files`, `:recent` emit this same effect
986 /// with the appropriate `source` set so the trait-driven
987 /// dispatch + MRU pipeline runs uniformly.
988 OpenPicker {
989 /// Registered picker-source name (`files`, `recent`, `grep`, ...).
990 source: String,
991 /// Raw whitespace-split arguments; the source re-parses them.
992 args: Vec<String>,
993 /// PC.1: root this picker resolves against, overriding the active
994 /// buffer's project for this open only.
995 ///
996 /// **Why the CONTEXT and not an argument.** A `live` source
997 /// (`grep`) re-queries through `on_query_changed`, which receives the
998 /// query and the context and NOT the open's args — and the source is a
999 /// shared `&self` generator with no per-open state. A root passed as an
1000 /// argument would therefore apply to the first query and silently
1001 /// revert to the workspace root on the next keystroke, which is worse
1002 /// than not having it. In the context it survives every re-query, and
1003 /// every root-sensitive source gets it without a per-source convention.
1004 ///
1005 /// `None` is the ordinary case: resolve from the active buffer, exactly
1006 /// as before.
1007 root: Option<std::path::PathBuf>,
1008 /// PC.11: this picker is being opened **to answer a question**, and
1009 /// the answer goes to the named command as its first argument.
1010 ///
1011 /// The picker's `FillCaller` outcome already means "hand this value
1012 /// to whoever opened me"; what it lacked was a destination a *plugin*
1013 /// can own. A guest owns none of the surfaces
1014 /// `lattice_picker::FillTarget` could name — not the document, not
1015 /// the `:` line, not a prompt — but it does own an ex-command, so
1016 /// that becomes the destination. [`Effect::OpenPrompt`]'s
1017 /// `on_submit_action` is the same shape for the same reason, and the
1018 /// asymmetry between the two (a guest could be handed a prompt's
1019 /// answer but not a picker's) is what this closes.
1020 ///
1021 /// **Not a flag that overrides the source's accept.** The source
1022 /// decides what accepting one of ITS candidates means; `file-pick`
1023 /// and `dir-pick` exist as separate sources precisely so that "supply
1024 /// a value" is a source's own decision rather than a caller's
1025 /// override. This names where a `FillCaller` lands, which is a
1026 /// question `FillTarget` already owns.
1027 ///
1028 /// `None` leaves the existing behaviour untouched: the target is
1029 /// whatever surface was captured at open.
1030 fill_action: Option<String>,
1031 /// CD.6a: text the picker's query starts with. For a static source it
1032 /// narrows the rows from the first frame, as emacs's `completing-read`
1033 /// initial input does; org-roam's node insert seeds it from the active
1034 /// region. `None` is an empty prompt (or a live source's own seed).
1035 query: Option<String>,
1036 },
1037 /// `:bd[elete][!]` -- close the active document buffer.
1038 /// `force = true` discards unsaved changes.
1039 BufferDelete {
1040 /// `!` -- close even if modified.
1041 force: bool,
1042 },
1043 /// `:Tree [path]` -- open a file-tree buffer rooted at `path`.
1044 /// Absent = the document's parent directory.
1045 OpenFileTree {
1046 /// Directory to root the tree at.
1047 root: Option<PathBuf>,
1048 },
1049 /// `:TreeClose` -- dismiss the file-tree buffer.
1050 CloseFileTree,
1051 /// `:Oil [path]` -- open an oil buffer for `path` (flat editable listing).
1052 /// Absent = current document's parent directory / cwd.
1053 OpenOil {
1054 /// Directory to list.
1055 dir: Option<PathBuf>,
1056 },
1057 /// `:describe-option NAME` -- render the option's metadata in
1058 /// a help view.
1059 DescribeOption {
1060 /// Option name as used with `:set`.
1061 name: String,
1062 },
1063 /// `:describe-element NAME` / `:describe-face NAME` (T.9.d) --
1064 /// render a registered theme element's metadata in a help view:
1065 /// owner, doc, the authoring (reference-form) `StyleSpec` default
1066 /// (palette keys + inherit parent), and the concrete resolved
1067 /// `Style`. The introspection counterpart of `:describe-option` /
1068 /// `:describe-mode` for theme elements. The host reads the
1069 /// `ThemeRegistry::describe` snapshot; an unknown name echoes an
1070 /// error.
1071 DescribeElement {
1072 /// Theme element name (`modeline.mode`, `diagnostic.error`, ...).
1073 name: String,
1074 },
1075 /// `:options` -- list every registered option.
1076 ListOptions,
1077 /// `:describe-plugin-api [<seam>]` (PI.2) -- render the plugin-API
1078 /// catalog. With `seam` = an interface name (`host-services`,
1079 /// `picker-source`, ...), render that one interface's functions +
1080 /// direction + capability. Without, render the same as
1081 /// `:list-plugin-apis`. The catalog is derived from `wit/` at build
1082 /// time (`lattice-plugin-api`); the host holds no plugin runtime.
1083 DescribePluginApi {
1084 /// WIT interface name; `None` lists every interface.
1085 seam: Option<String>,
1086 },
1087 /// `:list-plugin-apis` (PI.2) -- list every plugin-API interface the
1088 /// `wit/` package exposes, one row per interface (name + direction +
1089 /// capability + function count).
1090 ListPluginApis,
1091 /// `:export-plugin-api [markdown|json]` (PI.2b) -- dump the whole
1092 /// plugin-API catalog into a savable synthetic buffer (`*plugin-api.md*`
1093 /// / `*plugin-api.json*`) the author saves with `:w <path>`. `format`
1094 /// defaults to markdown; `json` selects the machine-readable form. The
1095 /// host owns the dump + buffer open (the `OpenSyntheticBuffer` pattern).
1096 ExportPluginApi {
1097 /// `"markdown"` (the default when `None`) or `"json"`.
1098 format: Option<String>,
1099 },
1100 /// `:list-commands` (PI.3) -- enumerate every registered command grouped
1101 /// by source layer (built-in / user config / plugin / ...). The one
1102 /// introspection enumeration the help family was missing; a plugin group
1103 /// resolves the plugin id to its manifest name where the host knows it.
1104 ListCommands,
1105 /// `:describe-plugin <name>` (PI.4, Facet B) -- render one loaded plugin's
1106 /// own documentation + contributions. The doc comes from the plugin
1107 /// (embedded WIT world doc / manifest `doc`), resolved once at load. Unknown
1108 /// / no-plugin-loaded echoes an error. Loaded-plugin enumeration is
1109 /// Phase-8-gated; the surface + registry seam exist now.
1110 DescribePlugin {
1111 /// The plugin's manifest name.
1112 name: String,
1113 },
1114 /// `:list-plugins` (PI.4) -- list every loaded plugin (name + doc summary).
1115 /// Empty until the Phase-8 loader populates the registry.
1116 ListPlugins,
1117 /// `:hover [text]` -- open a hover popup at the cursor with
1118 /// `text` as the markdown body. A manual / testing entry point; LSP
1119 /// hover (`K`) goes through [`Effect::Lsp`] instead.
1120 OpenHover {
1121 /// Markdown body of the popup.
1122 markdown: String,
1123 },
1124 /// Dismiss the active popup, whatever its content. Content-agnostic
1125 /// (routes through `dismiss_popup`); produced today by `:HoverClose`.
1126 ///
1127 /// **This is the USER's verb — "close what I am looking at".** It is the
1128 /// right one for a key the user pressed (`:popup-dismiss`, magit's `q`,
1129 /// answering a permission prompt), because the popup they mean is the one
1130 /// on screen by definition. It is the WRONG one for a mode dismissing on
1131 /// its own schedule — see [`Effect::DismissPopupNamed`].
1132 DismissPopup,
1133 /// Dismiss the active popup **only if it is the named one** — a no-op
1134 /// otherwise.
1135 ///
1136 /// `name` is the popup buffer's synthetic name, the same string
1137 /// [`Effect::OpenPopup`] was given.
1138 ///
1139 /// **Why the pair exists.** There is one popup slot
1140 /// (`Editor::popup_buffer`), so [`Effect::DismissPopup`] means "close
1141 /// whatever is in it". For a keypress that is exactly right. For a
1142 /// BACKGROUND dismissal it is a bug waiting to be written: between the
1143 /// moment a mode decided to close its popup and the moment the effect is
1144 /// applied, the slot may hold someone else's — and the mode closes that
1145 /// instead, silently, with nothing in the type to warn it.
1146 ///
1147 /// Which-key wrote that bug. Its timer-driven dismissal fired on every
1148 /// resolved chord, so any two-key sequence typed faster than the popup
1149 /// delay (`zz`, `gg`, `dd`, `ci"`) tore down whatever hover or diagnostic
1150 /// popup happened to be showing. Naming the target makes the stale case a
1151 /// no-op instead of a wrong action.
1152 ///
1153 /// **Rule of thumb:** a dismissal the user asked for is
1154 /// [`Effect::DismissPopup`]; a dismissal a mode decided on is this.
1155 DismissPopupNamed {
1156 /// The popup buffer's synthetic name.
1157 name: String,
1158 },
1159 /// Show a popup overlay at `placement` with the given `focus`
1160 /// (popup-api.md §4.3). Content-agnostic and data-only: the host
1161 /// idempotently ensures a popup buffer named `name` under major mode
1162 /// `mode_id` (`Editor::open_popup_named`) and the owning mode's
1163 /// `on_activate` projects the content. Name-based, not id-based, because
1164 /// the emitters (the `:ai-permission` ex-command, the async tick callback)
1165 /// have no host access to register a buffer and supply a `BufferId` — a
1166 /// name keeps the effect vocabulary the host boundary, like
1167 /// `OpenSyntheticBuffer`.
1168 OpenPopup {
1169 /// Synthetic name of the popup buffer; reused if it exists. Also
1170 /// the key [`Effect::DismissPopupNamed`] matches on.
1171 name: String,
1172 /// Major mode for the buffer, by mode-id string; must be registered.
1173 mode_id: String,
1174 /// Where the popup floats (cursor-anchored, centred, ...).
1175 placement: lattice_core::ui::popup::PopupPlacement,
1176 /// Whether the popup takes keyboard focus.
1177 focus: lattice_core::ui::popup::PopupFocus,
1178 },
1179 /// `:help [topic]` -- open a free-form help topic. With no
1180 /// topic the host renders the index (`docs/user/README.md`
1181 /// equivalent); with a topic the host looks it up in its
1182 /// help-topic registry and surfaces the body in a help
1183 /// buffer. Unknown topics echo an error.
1184 OpenHelpTopic {
1185 /// Topic name; `None` opens the index.
1186 topic: Option<String>,
1187 },
1188
1189 /// `:diagnostics` -- render every workspace diagnostic in a
1190 /// help-style buffer with clickable per-entry source links
1191 /// (Phase 4.1.d.iv). The host queries its
1192 /// `LspSupervisor::diagnostics()` layer and formats.
1193 ListDiagnostics,
1194 /// CM.8: `:clist` / `:cl` — open the error list in a fuzzy
1195 /// picker (the flat browse-and-jump surface, parallel to
1196 /// `:diagnostics`). Complements `:cnext` (step) and `:copen` (the
1197 /// `*problems*` multibuffer). Host builds it from `Editor::error_list`.
1198 ListErrors,
1199 /// `]d` / `:diag-next` -- move the cursor to the
1200 /// next diagnostic in the active buffer. Wraps to top.
1201 NextDiagnostic,
1202 /// `[d` / `:diag-prev` / `:cprev` -- move the cursor to the
1203 /// previous diagnostic in the active buffer. Wraps to
1204 /// bottom.
1205 PrevDiagnostic,
1206
1207 /// `:lsp-log [server]` (Phase 4.1.g) -- open the subsystem
1208 /// log buffer (`*lsp*`) when `server_id` is None, or the
1209 /// per-server log (`*lsp:<server>*`) when set.
1210 OpenLspLog {
1211 /// Server id (as in `:lsp-status`); `None` = the subsystem log.
1212 server_id: Option<String>,
1213 },
1214 /// `:ai-log [provider]` (AI-1b) -- open the per-session AI log
1215 /// buffer (`*ai:<provider>:<index>*`). With no known session,
1216 /// echoes an info hint; with exactly one, opens it directly; with
1217 /// more (optionally narrowed by the `session` provider
1218 /// prefilter), raises a picker. Peer-applied via the host's
1219 /// `do_open_ai_log`, exactly like [`Effect::OpenLspLog`]. The
1220 /// `lattice-ai` crate owns the `:ai-log` binding + this
1221 /// emission; the host owns only the generic
1222 /// `ensure_named_synthetic_document` + `AiLogMode` open.
1223 OpenAiLog {
1224 /// Provider-prefix filter over known sessions; `None` = all.
1225 session: Option<String>,
1226 },
1227 /// Open (or focus) a named synthetic buffer under a given major mode --
1228 /// the generic primitive behind provider-owned buffer views (e.g. the
1229 /// `ai-conversation` `*ai:opencode*` buffer). The emitter (a mode's
1230 /// command handler) supplies the buffer name + the mode id; the host owns
1231 /// only the generic `ensure_named_synthetic_document` open, so no
1232 /// provider-specific host method is added. `mode_id` is the mode's string
1233 /// id (`ModeId::new(&mode_id)`); the mode must be registered at boot.
1234 ///
1235 /// OC.7a adds `content` / `cursor` / `activate_minor`. A native mode fills
1236 /// its own buffer from `on_activate`; the `modes` WIT seam is
1237 /// declaration-only, so a PLUGIN mode has no such hook and a guest emitting
1238 /// this got a buffer it could never put text in. `Effect::ApplyEdit` is not
1239 /// the way out either — it names a `target` buffer id this effect does not
1240 /// hand back. All three are `None` for every pre-OC.7a emitter.
1241 OpenSyntheticBuffer {
1242 /// Buffer name (`*ai:opencode*`); an existing buffer of that name is
1243 /// focused rather than recreated.
1244 name: String,
1245 /// Major mode id; must be registered at boot.
1246 mode_id: String,
1247 /// Seed text, applied BEFORE the buffer is shown so the first frame is
1248 /// the finished one. Ignored when the buffer already existed — a
1249 /// re-open must not overwrite what the user has typed, which is the
1250 /// difference between reopening a capture and losing one.
1251 content: Option<String>,
1252 /// Where to leave the caret in `content` (org capture's `%?`).
1253 /// Out-of-range is clamped, not refused: a template whose `%?` sits
1254 /// past its own text is a template bug that must not cost the capture.
1255 cursor: Option<lattice_protocol::position::Position>,
1256 /// A minor to activate alongside the major. Mirrors
1257 /// `Effect::SpawnTerminal`'s `activate_minor` and exists for the same
1258 /// reason — the interesting behaviour rides a general-purpose major.
1259 activate_minor: Option<String>,
1260 },
1261 /// MG.50: [`Effect::OpenSyntheticBuffer`] + cursor placement, in one
1262 /// step.
1263 ///
1264 /// The synthetic peer of [`Effect::OpenBufferAt`], and it exists for
1265 /// exactly the same reason: the open is peer-applied while a cursor
1266 /// effect runs host-side against whatever buffer is active at that
1267 /// moment, so `Many([open, move])` cannot land the caret on a buffer
1268 /// that does not exist yet. Emitted by magit's `<CR>`, which opens a
1269 /// staged blob at the line the cursor was reading in the diff.
1270 OpenSyntheticBufferAt {
1271 /// As [`Effect::OpenSyntheticBuffer`]'s `name`.
1272 name: String,
1273 /// As [`Effect::OpenSyntheticBuffer`]'s `mode_id`.
1274 mode_id: String,
1275 /// Cursor target in the opened buffer, 0-based `(line, byte)`.
1276 position: lattice_protocol::position::Position,
1277 },
1278 /// `:messages` -- open the `*messages*` buffer (the emacs
1279 /// `*Messages*` analogue). Renders a chronological view
1280 /// of every echo / minibuffer notification; live-tails as
1281 /// new entries arrive via the typed event bus.
1282 OpenMessages,
1283 /// `:dashboard` -- open (or re-compose + activate) the
1284 /// `*dashboard*` launch page. The applier reads config, composes
1285 /// the enabled sections via the crate-owned `DashboardRegistry`
1286 /// service, and seeds a read-only `BufferKind::Dashboard` buffer.
1287 /// See `docs/dev/architecture/dashboard.md` §9.
1288 OpenDashboard,
1289 /// `:lsp-trace <server>` -- pure toggle of JSON-RPC tracing
1290 /// for the server. The trace buffer is opened separately via
1291 /// `:lsp-trace-log <server>` so peeking mid-stream doesn't
1292 /// flip the toggle off.
1293 ToggleLspTrace {
1294 /// Server id (as in `:lsp-status`).
1295 server_id: String,
1296 },
1297 /// `:lsp-trace-log [server]` -- open the JSON-RPC trace ring
1298 /// (`*lsp:<server>:trace*`) in the active pane via the
1299 /// vertico picker (Phase 3). No arg = picker over every
1300 /// running instance; arg = pre-filter; single match short-
1301 /// circuits the picker. Independent of the trace toggle.
1302 OpenLspTraceLog {
1303 /// Server-id filter; `None` = pick among all running servers.
1304 server_id: Option<String>,
1305 },
1306 /// `:lsp-status` -- render every running server (id, root,
1307 /// pid, uptime, capability summary) in a help-style buffer.
1308 LspStatus,
1309 /// EP.4 (2026-08-10): `:lsp-diagnostics-to-error-list` -- pull the
1310 /// current published diagnostics into the error list's `Lsp` slice
1311 /// on demand.
1312 ///
1313 /// The manual peer of the live feed gated by
1314 /// `lsp.diagnostics-to-error-list`. Useful when that option is off,
1315 /// and as a forced refresh after a server restart when it is on.
1316 /// Echoes the entry count, because this surfaces what servers have
1317 /// *published* -- not a workspace scan -- and an empty result must
1318 /// not be misread as a clean tree.
1319 LspDiagnosticsToErrorList,
1320 /// `:lsp-server-log` -- picker-style listing of every running
1321 /// server actor with workspace root + buffer count +
1322 /// capability summary, each row carrying `exec:` links to
1323 /// the per-server log + trace buffers. Use vim search
1324 /// (`/query`) to filter rows; press `<CR>` on a link to
1325 /// open. A real fuzzy picker arrives with the bundled
1326 /// fuzzy-finder plugin (Phase 8b).
1327 LspServerLogListing,
1328 /// `:lsp-restart <server>` -- ask the LSP supervisor to restart the
1329 /// server. Runs asynchronously on the LSP runtime; the outcome is
1330 /// written to the LSP log.
1331 LspRestart {
1332 /// Server id (as in `:lsp-status`).
1333 server_id: String,
1334 },
1335 /// `:lsp-progress-cancel [server]` -- send
1336 /// `window/workDoneProgress/cancel` for every active,
1337 /// cancellable progress entry on the named server (or, with
1338 /// no arg, on every server currently attached to the active
1339 /// buffer). Non-cancellable entries are left alone — the
1340 /// host's cancel is best-effort regardless. 4.4.c.
1341 LspProgressCancel {
1342 /// Server id; `None` = every server attached to the active buffer.
1343 server_id: Option<String>,
1344 },
1345 /// 4.4.e: `:lsp-expand-region` -- structural smart-
1346 /// expansion. First invocation fires `textDocument/selectionRange`
1347 /// at the cursor; each subsequent invocation walks one
1348 /// `parent` step outward in the cached chain. Enters Visual
1349 /// mode with the resolved range as the selection.
1350 LspExpandRegion,
1351 /// 4.4.e: `:lsp-shrink-region` -- inverse walk through the
1352 /// cached selection-range chain. No-op when the chain is
1353 /// empty / at the innermost step.
1354 LspShrinkRegion,
1355 /// `:lsp-log-level [server] <level>` -- set the subsystem-
1356 /// wide default min level (when `server_id` is None) or a
1357 /// per-server override.
1358 SetLspLogLevel {
1359 /// Server id; `None` = the subsystem-wide default.
1360 server_id: Option<String>,
1361 /// `trace`, `debug`, `info`, `warn` / `warning` or `error`; anything
1362 /// else is echoed as an error.
1363 level: String,
1364 },
1365 /// `:lsp-log-clear [server]` -- drop the ring's records.
1366 /// `None` clears the subsystem-wide ring; a server id
1367 /// clears that ring.
1368 LspLogClear {
1369 /// Server id; `None` = the subsystem-wide ring.
1370 server_id: Option<String>,
1371 },
1372 /// `:lsp-symbols` -- open a picker over the active document's
1373 /// LSP symbol outline (`textDocument/documentSymbol`). Phase
1374 /// 4.2.e.
1375 LspDocumentSymbol,
1376 /// `:lsp-workspace-symbol [query]` -- open a picker over
1377 /// workspace-scoped symbols matching `query` (server-side
1378 /// substring filter). Phase 4.2.f.
1379 LspWorkspaceSymbol {
1380 /// Query sent to the server; empty asks for everything.
1381 query: String,
1382 },
1383 /// `:lsp-incoming-calls` -- 4.5.a. Prepares call-hierarchy
1384 /// items at the cursor, fans out `callHierarchy/incomingCalls`
1385 /// for the first item, opens the merged caller list as a
1386 /// vertico picker. "Who calls this function?"
1387 LspIncomingCalls,
1388 /// `:lsp-outgoing-calls` -- 4.5.a. Symmetric peer of
1389 /// `LspIncomingCalls`. "What does this function call?"
1390 LspOutgoingCalls,
1391 /// `:lsp-supertypes` -- 4.5.b. Same shape as
1392 /// `LspIncomingCalls` but for type relationships: prepares
1393 /// type-hierarchy items, fans out
1394 /// `typeHierarchy/supertypes`, opens the picker. "What
1395 /// does this type subtype?"
1396 LspSupertypes,
1397 /// `:lsp-subtypes` -- 4.5.b. Symmetric peer of
1398 /// `LspSupertypes`. "What subtypes this type?"
1399 LspSubtypes,
1400 /// `:lsp-moniker` -- 4.5.g. Fires `textDocument/moniker`
1401 /// at the cursor; echoes the resulting moniker list
1402 /// (scheme + identifier + unique level + optional kind).
1403 /// Useful for indexers (SCIP / LSIF) + cross-repo
1404 /// navigation; the result surfaces as a one-line
1405 /// summary, not a picker.
1406 LspMoniker,
1407 /// `:lsp-code-lens` -- 4.5.d. Open a picker over the
1408 /// cached `textDocument/codeLens` entries for the active
1409 /// buffer. Accept routes the chosen lens's `command`
1410 /// through `workspace/executeCommand` (after a lazy
1411 /// `codeLens/resolve` if the lens arrived without a
1412 /// command).
1413 LspCodeLens,
1414 /// `:lsp-color-presentation` -- 4.5.e. At the cursor,
1415 /// look up the color literal in the per-buffer
1416 /// `documentColor` cache and fire
1417 /// `textDocument/colorPresentation` to fetch alternative
1418 /// formats (named, rgb(), hex, etc.). Open a picker;
1419 /// accept splices the chosen alternative.
1420 LspColorPresentation,
1421 /// `:lsp-format` -- run `textDocument/formatting` on the highest-
1422 /// priority server with `documentFormattingProvider` and
1423 /// apply the returned edits as one undo unit. Phase 4.3.
1424 LspFormat,
1425 /// IN.8b: `:format` -- format the whole buffer through the
1426 /// availability cascade (LSP if a server advertises formatting,
1427 /// otherwise `formatprg` or the built-in per-language table).
1428 ///
1429 /// Distinct from `LspFormat`, which is LSP-only by name and stays
1430 /// that way. `:format` is the LSP-INDEPENDENT command, which is
1431 /// why its generic name satisfies the ex-command naming rule
1432 /// rather than violating it: that rule exists because a generic
1433 /// name implies "works regardless of LSP", and this one does.
1434 Format,
1435 /// `:lsp-format-range` -- run `textDocument/rangeFormatting`
1436 /// over the active Visual selection (when in Visual mode)
1437 /// or the supplied line range. Apply edits atomically.
1438 /// Phase 4.3.
1439 LspFormatRange,
1440 /// `:lsp-signature-help` (or trigger-character driven). Send
1441 /// `textDocument/signatureHelp` to attached servers; first
1442 /// non-empty response renders into the hover popup.
1443 LspSignatureHelp,
1444 /// `:lsp-complete` -- fire `textDocument/completion` at the
1445 /// cursor and open the merged item list as a vertico
1446 /// picker. Phase 4.2.g.
1447 LspComplete,
1448 /// `:lsp-rename <new-name>` -- run textDocument/prepareRename
1449 /// (when advertised) then textDocument/rename; apply the
1450 /// returned WorkspaceEdit as one undo unit across every
1451 /// affected buffer. Phase 4.3.
1452 LspRename {
1453 /// The new identifier.
1454 new_name: String,
1455 },
1456 /// `:lsp-code-action` -- run textDocument/codeAction at the
1457 /// cursor / selection; open the merged item list as a
1458 /// vertico picker. Accept routes through resolve (when
1459 /// needed) and applies the action's WorkspaceEdit /
1460 /// command. Phase 4.3.
1461 LspCodeAction,
1462 /// SN.3c.1: direct snippet expansion over a known trigger
1463 /// range. The mode-owned `<C-x><C-s>` handler
1464 /// (`snippet-mode`'s `action_handlers()`) does the word-prefix
1465 /// scan and emits this with `replace_range = token-start..cursor`;
1466 /// the **host** owns resolution + expansion (language detection,
1467 /// registry lookup, variable render, buffer splice + session
1468 /// install) via `Editor::expand_snippet_from_range`. A deliberate
1469 /// first-party effect: the typed `Effect` enum stays a host-owned
1470 /// vocabulary (`feedback_effect_vocabulary_is_host_boundary`) —
1471 /// the mode owns the *trigger*, the host owns the *expansion
1472 /// mechanics*. No-op (quiet info echo) when no snippet matches the
1473 /// prefix.
1474 ExpandSnippet {
1475 /// The trigger text to replace, half-open `(line, byte)` range on a
1476 /// single line of the focused buffer; its text is the prefix looked
1477 /// up (active language first, then `*`).
1478 replace_range: lattice_protocol::position::Range,
1479 },
1480 /// `:reload-snippets` -- re-read every snippet file from
1481 /// disk and rebuild the per-language registry (Phase
1482 /// 4.2.g.4). Useful after editing a `.code-snippets` /
1483 /// `.json` file in the project's snippet directory.
1484 ReloadSnippets,
1485
1486 /// `:describe-events` -- render a help buffer listing every
1487 /// registered event (M.5.3.c). Walks
1488 /// `lattice_protocol::event_registry::EVENT_DESCRIPTORS` and
1489 /// formats each as `name :: source-crate :: doc`.
1490 DescribeEvents,
1491 /// `:describe-diff` -- render a help buffer listing every
1492 /// active diff session (D.2.d). Walks the host's
1493 /// `DiffSubsystem::describe_sessions` and formats each row
1494 /// as `BufferId | Algorithm | Rev | Hunks | Watches`.
1495 DescribeDiff,
1496 /// `:diff` (no args) -- open an inline diff session for
1497 /// the active document against its on-disk content.
1498 /// D.3.a.1.
1499 DiffOpen,
1500 /// `:diffoff[!]` -- close the active pane's diff session
1501 /// (if any). v1 two-way semantics collapse `:diffoff` and
1502 /// `:diffoff!` to the same teardown (removing one side of
1503 /// a two-way diff leaves the other degenerate, so both
1504 /// drop the whole session). The bang is a forward-compat
1505 /// surface: D.6 (three-way merge) will distinguish per-
1506 /// participant removal (`:diffoff`) from full-session
1507 /// teardown (`:diffoff!`). The handler reads the session's
1508 /// watch list as the source of truth for participants —
1509 /// the tab is not a grouping unit. D.3.a.1 / D.4.d.3.a.
1510 DiffOff {
1511 /// `!`; currently identical to the plain form (see above).
1512 force: bool,
1513 },
1514 /// `:diffthis` -- stage the active pane for a two-pane
1515 /// diff; the second `:diffthis` invocation in a different
1516 /// pane completes the session (creates a `DiffSession`
1517 /// against the two live buffers, a `PaneGroup` with
1518 /// `HunkRowMapper`, and `FillerRowProvider`s on each
1519 /// side). Same pane twice unstages. Third-pane staging
1520 /// errors out — v1 is two-way only; multi-way arrives
1521 /// with D.6 three-way merge. D.4.d.3.a.
1522 Diffthis,
1523 /// `:diffsplit <file> [<remote>]` -- open `<file>` (and
1524 /// optionally `<remote>`) in new vertical splits and
1525 /// register a diff session between the current pane and
1526 /// the new pane(s).
1527 ///
1528 /// - **One arg** (`:diffsplit base`): two-way diff
1529 /// between the current pane (current side) and a new
1530 /// pane loading `base` (baseline side). D.4.d.3.b.
1531 /// - **Two args** (`:diffsplit base remote`): three-way
1532 /// merge with the current pane as "local", a new pane
1533 /// loading `base` as the common ancestor, and a third
1534 /// new pane loading `remote` as the other side. D.6.c.
1535 ///
1536 /// Composes vsplit + `:edit <path>` in each new pane +
1537 /// the appropriate registration helper
1538 /// (`register_two_pane_diff` for one arg,
1539 /// `register_three_pane_diff` for two). Empty first arg
1540 /// errors at parse time. Cursor lands in the *first*
1541 /// new pane (vim parity).
1542 Diffsplit {
1543 /// The baseline (two-way) or common-ancestor (three-way) file.
1544 path: std::path::PathBuf,
1545 /// The "other side" for a three-way merge; `None` = two-way.
1546 remote: Option<std::path::PathBuf>,
1547 },
1548 /// `:diffget [<bufnr>]` -- pull the hunk under the cursor
1549 /// from the named (or auto-resolved) buffer side. D.6.d.
1550 /// `target` is the optional buffer number passed by the
1551 /// user; `None` means "the peer side" (two-way: unique;
1552 /// three-way: ambiguous — dispatch emits "target required").
1553 /// The chord-driven `do` operator stays unit-variant
1554 /// `Action::DiffGet`; this ex-command variant is a parallel
1555 /// entry point for explicit-target invocations.
1556 DiffGetCmd {
1557 /// Buffer number to pull from; `None` = the unique peer side.
1558 target: Option<u32>,
1559 },
1560 /// `:diffput [<bufnr>]` -- push the hunk under the cursor
1561 /// into the named (or auto-resolved) buffer side. D.6.d.
1562 /// Mirror of [`Self::DiffGetCmd`] but for the put direction.
1563 DiffPutCmd {
1564 /// Buffer number to push into; `None` = the unique peer side.
1565 target: Option<u32>,
1566 },
1567 /// `:diff-accept` -- resolve the active pane's diff
1568 /// session with `lattice_diff::DiffOutcome::Accept`. v1 semantics:
1569 /// equivalent to `:diffoff` + signal Accept on the
1570 /// session's completion channel (if any). The buffer's
1571 /// current content (whatever the user applied via
1572 /// `do`/`dp` or left alone) becomes the accepted
1573 /// resolution; plugins consuming the outcome commit
1574 /// from there. D.6.e.
1575 DiffAccept,
1576 /// `:diff-reject` -- resolve the active pane's diff
1577 /// session with `lattice_diff::DiffOutcome::Reject`. v1 semantics:
1578 /// equivalent to `:diffoff!` + signal Reject. Plugins
1579 /// consuming the outcome should revert any
1580 /// pre-session state. D.6.e.
1581 DiffReject,
1582 /// `:diff-accept-all` — resolve EVERY pending review (each session with a
1583 /// bound completion) with `lattice_diff::DiffOutcome::Accept`. The bulk counterpart to
1584 /// `:diff-accept` for when several agent reviews are open at once.
1585 DiffAcceptAll,
1586 /// `:diff-reject-all` — resolve EVERY pending review with
1587 /// `lattice_diff::DiffOutcome::Reject`. Bulk counterpart to `:diff-reject`.
1588 DiffRejectAll,
1589 /// D-fix.6: an IDE-peer connection's `close_tab` — tear down (as a
1590 /// Reject) every *programmatic* diff session that THIS connection
1591 /// (`origin_session`) opened, regardless of how/where it is
1592 /// displayed. Host-applied: it fires each matching session's bound
1593 /// completion oneshot with `lattice_diff::DiffOutcome::Reject` and closes its
1594 /// panes (the `:diff-reject` teardown, but targeted by
1595 /// `origin_session` rather than the active pane). If the connection
1596 /// opened no diff, the host falls back to closing the active buffer
1597 /// when `tab_name` matches its path (the legacy I3 file-close — the
1598 /// only remaining `tab_name` use, orthogonal to the diff teardown).
1599 CloseSessionDiffs {
1600 /// The originating connection id; only diffs tagged with it are
1601 /// torn down (`0` = none — a non-IDE producer's diff is never
1602 /// matched). Cross-session isolation: connection A's close can
1603 /// never affect connection B's diffs.
1604 origin_session: u64,
1605 /// The agent's close-tab label, used ONLY for the legacy
1606 /// active-buffer file-close fallback when no diff matched.
1607 tab_name: String,
1608 },
1609 /// D-fix.6: an IDE-peer connection's `closeAllDiffTabs` — tear down
1610 /// (as a Reject) every programmatic diff session `origin_session`
1611 /// opened. Same scoping as [`Self::CloseSessionDiffs`] but with no
1612 /// file-close fallback (it is unambiguously a diff-only bulk close).
1613 CloseAllSessionDiffs {
1614 /// The originating connection id; scopes the bulk teardown.
1615 origin_session: u64,
1616 },
1617 /// `]c` / `:hunk-next` -- jump cursor to the start of the
1618 /// next diff hunk on the current side (`ranges[1]`).
1619 /// Wraps to top. D.3.c.
1620 NextHunk,
1621 /// `[c` / `:hunk-prev` -- jump cursor to the start of the
1622 /// previous diff hunk on the current side. Wraps to
1623 /// bottom. D.3.c.
1624 PrevHunk,
1625 /// `:describe-event <name>` -- render the descriptor for a
1626 /// single registered event (M.5.3.c). The introspection
1627 /// counterpart of `:describe-command` for events.
1628 DescribeEvent {
1629 /// Event name as listed by `:describe-events`.
1630 name: String,
1631 },
1632
1633 /// `:list-modes` -- render every registered mode in a help
1634 /// buffer (M.8). Groups by kind (Major / Minor); each row
1635 /// shows the mode's id and current activation state on the
1636 /// active buffer. The mode counterpart of `:options`.
1637 ListModes,
1638 /// `:describe-mode <name>` -- render one mode's metadata
1639 /// (M.8): id, kind, contributed option overrides,
1640 /// required capabilities, and current activation state on
1641 /// the active buffer. The introspection counterpart of
1642 /// `:describe-command` / `:describe-option` /
1643 /// `:describe-event` for modes.
1644 DescribeMode {
1645 /// Mode id (`rust-mode`, `magit-core-mode`).
1646 name: String,
1647 },
1648 /// `:describe-active-modes` (`<C-h>m`) -- render the mode
1649 /// stack live on the *active* buffer: the major plus every
1650 /// minor, each with the chords it contributes.
1651 ///
1652 /// Distinct from [`Effect::DescribeMode`], which describes
1653 /// one *named* mode whether or not it is active, and from
1654 /// [`Effect::ListModes`], which lists every *registered*
1655 /// mode. This one answers "what is this buffer, and what
1656 /// can I press in it".
1657 ///
1658 /// Major-only would under-report by construction: the
1659 /// minor-mode convention deliberately pushes chords shared
1660 /// across majors out into a minor (magit's `gr` / `q` /
1661 /// `]]` live on `magit-core-mode`, not on each magit
1662 /// major), so the view is major + minors.
1663 ///
1664 /// An additive variant rather than widening
1665 /// `DescribeMode`'s `name` to `Option<String>` — the WIT
1666 /// declaration is `describe-mode(string)` and widening it
1667 /// would break a published plugin API.
1668 DescribeActiveModes,
1669 /// `:describe-bindings` (`<C-h>K`) -- the chords that can
1670 /// actually fire on the *active* buffer: builtin entries live in
1671 /// the current binding-mode, plus every active mode's
1672 /// contributions.
1673 ///
1674 /// Distinct from [`Effect::ListKeymap`] (`:keymap`), which
1675 /// renders the whole static catalog regardless of what is
1676 /// active. `:keymap` stays the exhaustive reference; this one
1677 /// answers "what can I press *here*".
1678 DescribeActiveBindings,
1679 /// `:describe-option-resolution <name>` -- show which
1680 /// resolver layer (modal / buffer-local / mode
1681 /// contribution / typed-option / default) provides the
1682 /// resolved value for `<name>` on the active buffer
1683 /// (M.8). Helps debug surprising values when a mode's
1684 /// contribution shadows a `:set` write or vice versa.
1685 DescribeOptionResolution {
1686 /// Option name as used with `:set`.
1687 name: String,
1688 },
1689
1690 /// `:customize [name]` -- open the customize buffer
1691 /// (M.9). With no arg, opens the group + mode picker.
1692 /// With an arg ending in `-mode`, opens the focused
1693 /// view of that mode's contributed options. Otherwise,
1694 /// opens the cross-mode group view (every option in the
1695 /// named group, sectioned by owning mode).
1696 ///
1697 /// M.9.0 ships the read-only listing form. M.9.1 wires
1698 /// per-row navigation + Enter-to-edit; for now edits run
1699 /// via the existing `:set` machinery on the cmdline.
1700 Customize {
1701 /// Group or `*-mode` name; `None` = the picker.
1702 name: Option<String>,
1703 },
1704 /// `:tutor [N]` -- open the interactive tutor lesson `N`
1705 /// (default: 1) in a fresh editable buffer. The lesson
1706 /// content is embedded in the binary and copied to a
1707 /// temp file each time so the user starts fresh and can
1708 /// practice motions / operators on the file itself
1709 /// (vim-tutor pattern). Lessons are the
1710 /// `docs/user/tutor/lesson-N.md` files embedded by the host.
1711 Tutor {
1712 /// 1-based lesson number; `None` = lesson 1.
1713 lesson: Option<u32>,
1714 },
1715 /// `:<mode-name>` -- toggle a registered mode on the active
1716 /// buffer (M.5.1; mode-architecture §9.6.1). For minors:
1717 /// activate if inactive, deactivate if active. For majors:
1718 /// activate if not currently the major; reload (deactivate
1719 /// then re-activate) if it's already the active major. Mode
1720 /// resolution is by name, not id object, because the grammar
1721 /// crate stays renderer-agnostic and doesn't depend on
1722 /// `lattice-mode`.
1723 ToggleMode {
1724 /// Mode id string, e.g. `"auto-pair-mode"`.
1725 mode_name: String,
1726 },
1727
1728 /// Free-form App-side effect produced by a `CommandKind::Action`
1729 /// dispatch. Carries an [`AppEffect`] -- the typed App-side
1730 /// counterpart to the dispatcher-native variants above. The
1731 /// host's `apply_effect` matches the inner `AppEffect` to drive
1732 /// chord-bound work that has no grammar concept attached
1733 /// (`<Esc>` exits Visual, `<C-w>v` splits a pane, `o` opens a
1734 /// line below). Slice 8.i wires this surface; see
1735 /// `docs/dev/notes/8i-approach.md`.
1736 AppAction(AppEffect),
1737
1738 /// M.10.3 (2026-06-03): record the editor's CURRENT cursor +
1739 /// active buffer onto the position-history ring as an
1740 /// `AutoJump` entry — vim's jump-list semantics for "big
1741 /// motions" (gg, G, /, *, mark jumps, ...). Used by
1742 /// mode-contributed jump actions (search `<CR>`,
1743 /// lsp-references `<CR>`, future project-diff `<CR>`) so
1744 /// `<C-o>` walks the user back to where they were before
1745 /// the jump. Must be the FIRST sub-effect inside an
1746 /// `Effect::Many` that also opens a new buffer — the host
1747 /// reads the cursor/active-buffer state at apply time, so
1748 /// after `OpenBuffer` lands the recorded entry would be the
1749 /// new doc's start, not the pre-jump location.
1750 RecordJump,
1751
1752 /// Open a yes/no confirmation dialog. The host shows a transient
1753 /// picker with `prompt` as the title; `y` dispatches `yes_action`
1754 /// with `args`, `n` / `q` / Esc dismisses.
1755 ///
1756 /// **IX.1: `args` is what makes the confirmed thing and the executed
1757 /// thing the same thing.** Without it a yes-half has to re-derive
1758 /// its target when it fires, and the context it derives from is not
1759 /// stable across the wait — a background refresh can rebuild the
1760 /// buffer and move the cursor while the dialog is up, so the action
1761 /// lands on a different target than the prompt named. Carrying the
1762 /// target closes that window by construction.
1763 ///
1764 /// Carry the **payload, not a pointer to it**: a path, a SHA, a
1765 /// synthesized patch — not a cursor row or a row span, which a
1766 /// rebuild invalidates. For patch-shaped payloads this also makes
1767 /// `git apply`'s context check refuse a stale one loudly instead of
1768 /// applying it somewhere plausible.
1769 ///
1770 /// `Args::None` keeps the pre-IX.1 behaviour (the yes-half
1771 /// re-derives), so existing confirms are unaffected until migrated.
1772 ///
1773 /// **Why a name + `Args` rather than a `CommandInvocation`,** which
1774 /// is otherwise the canonical "thing to execute" (design §5.2.1):
1775 /// this effect has to cross the plugin seam, and `CommandInvocation`
1776 /// has no WIT mirror — it is exactly why [`Effect::Global`] fails at
1777 /// the boundary. `Args` is mirrored and a name is a string, so this
1778 /// payload crosses. A name is also the plugin-native form: plugins
1779 /// register actions by name and cannot hold a host `CommandId`.
1780 Confirm {
1781 /// The question shown as the dialog title.
1782 prompt: String,
1783 /// Registered action name dispatched on `y`.
1784 yes_action: String,
1785 /// Arguments passed to `yes_action` (see above).
1786 args: crate::Args,
1787 },
1788
1789 /// Return the active pane to the buffer it was displaying before a
1790 /// full-pane synthetic buffer took it over, and drop that buffer's
1791 /// hold on the pane ("bury", in vim's sense — the buffer stays in
1792 /// the registry, it just stops being shown).
1793 ///
1794 /// Distinct from [`Effect::DismissPopup`] on purpose. A popup
1795 /// *floats over* the pane: the underlying document is never
1796 /// swapped out, so dismissing one only has to drop the overlay.
1797 /// A synthetic buffer opened full-pane (magit's views, oil, the
1798 /// plugin manager) genuinely replaced the pane's buffer AND the
1799 /// editor's active-document handle, so returning has to swap both
1800 /// back. magit's `q` used `DismissPopup` for this and left the
1801 /// active document pointing at magit while the pane pointed at the
1802 /// file — the pane said one thing and the screen painted another.
1803 ///
1804 /// A no-op when nothing was buried (no origin to return to), so a
1805 /// mode can bind it unconditionally.
1806 BuryBuffer,
1807
1808 /// [`Effect::BuryBuffer`], then delete the buffer that was buried:
1809 /// the pane returns to where it came from and the buffer is gone.
1810 ///
1811 /// For a buffer whose life ends at a verb — a compose buffer on
1812 /// finish or cancel (magit's commit, note and rebase-todo buffers;
1813 /// with-editor's `C-c C-c` / `C-c C-k`). Bury alone keeps the buffer,
1814 /// and a synthetic buffer reopened by name is reused WITHOUT being
1815 /// re-seeded, so the next commit came back holding the previous
1816 /// message.
1817 ///
1818 /// No dirty check: the emitting mode has decided the buffer is done.
1819 /// With no origin to return to it behaves as `:bd!`.
1820 KillBuffer,
1821
1822 /// Open a named transient picker menu. `source` is a name
1823 /// registered into a `TransientSourceRegistry` (`lattice-picker`)
1824 /// by the owning mode crate at boot — mirrors `OpenPicker`'s
1825 /// named-source shape. The registry, not this enum, holds the
1826 /// actual `TransientSpec` builder, since `TransientSpec` lives in
1827 /// a crate downstream of `lattice-grammar`.
1828 /// TR.3a: `args` are the arguments this open was requested with;
1829 /// they reach the builder as `TransientContext::args`.
1830 ///
1831 /// Without them a menu cannot drill down — a row that opens a second
1832 /// menu has no way to say what it opened it FOR. Org's capture menu
1833 /// has a row per template and the fields menu it opens must know
1834 /// which template it is collecting for; the only alternative is guest
1835 /// memory, which `<Esc>` never clears, so the next open would inherit
1836 /// the last one's subject.
1837 ///
1838 /// `Args::None` is a plain open, which is every native menu today.
1839 OpenTransient {
1840 /// Registered transient-source name.
1841 source: String,
1842 /// Context for the menu builder (see above).
1843 args: crate::args::Args,
1844 },
1845
1846 /// Open a one-line minibuffer text prompt. `prompt` is shown as
1847 /// an info-level echo label; `initial` pre-seeds the input
1848 /// buffer's content; `on_submit_action` names a registered
1849 /// `action:*` handler fired on `<CR>` with the typed text
1850 /// available as `ActionContext::prompt_value` (never a closure —
1851 /// same name-based-lookup convention as `Confirm`'s `yes_action`
1852 /// and `OpenTransient`'s `source`, so the variant stays a plain,
1853 /// serializable value with no crate carrying a callback type
1854 /// downstream of `lattice-grammar`). `buffer_name`, when set,
1855 /// becomes the synthetic prompt buffer's name — callers use this
1856 /// to stash context for the submit handler to read back (mirrors
1857 /// how magit's blame/rebase/revision modes encode their target in
1858 /// the buffer name); `None` uses a default unnamed prompt buffer.
1859 /// `<Esc>` cancels without firing anything.
1860 OpenPrompt {
1861 /// Label shown for the prompt.
1862 prompt: String,
1863 /// Initial input text; may be empty.
1864 initial: String,
1865 /// Registered `action:*` name run on `<CR>`.
1866 on_submit_action: String,
1867 /// Name for the prompt buffer (context for the submit handler);
1868 /// `None` = default.
1869 buffer_name: Option<String>,
1870 },
1871
1872 /// Several effects, applied **in order** (the host flattens nested
1873 /// `Many`). Host-applied children all run before any peer-applied
1874 /// child. A [`Effect::WriteToFile`] that fails stops the remainder;
1875 /// nothing else does. Order matters: [`Effect::RecordJump`] must precede
1876 /// the open it records, and a cursor effect must follow the edit it
1877 /// positions after.
1878 Many(Vec<Effect>),
1879}
1880
1881impl Effect {
1882 /// `true` only for [`Effect::None`]. An empty [`Effect::Many`] is not
1883 /// `None`, nor is [`Effect::Declined`].
1884 pub fn is_none(&self) -> bool {
1885 matches!(self, Effect::None)
1886 }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 #![allow(clippy::unwrap_used, clippy::panic)]
1892 use super::*;
1893
1894 // ── XF.1: the anchor resolver ──────────────────────────────────────
1895 //
1896 // The whole of `FileAnchor`'s logic, and the reason it is a position
1897 // rather than a range: a producer addressing a file it has never read can
1898 // name these three and nothing else (`cross-file-writes.md` §4).
1899
1900 #[test]
1901 fn end_resolves_one_past_the_last_line() {
1902 // "Append" means insert *before* line `line_count` — one past the
1903 // last — which is what a caller building a `Position` needs.
1904 assert_eq!(FileAnchor::End.resolve_line(3), 3);
1905 }
1906
1907 /// An empty target is the capture case: the file is created by this very
1908 /// write, so every anchor has to agree on line 0 rather than one of them
1909 /// producing an out-of-range insert into a document with no lines.
1910 #[test]
1911 fn every_anchor_agrees_on_an_empty_file() {
1912 assert_eq!(FileAnchor::End.resolve_line(0), 0);
1913 assert_eq!(FileAnchor::Start.resolve_line(0), 0);
1914 assert_eq!(FileAnchor::Line(0).resolve_line(0), 0);
1915 assert_eq!(FileAnchor::Line(7).resolve_line(0), 0);
1916 }
1917
1918 #[test]
1919 fn start_is_always_the_first_line() {
1920 assert_eq!(FileAnchor::Start.resolve_line(0), 0);
1921 assert_eq!(FileAnchor::Start.resolve_line(100), 0);
1922 }
1923
1924 #[test]
1925 fn a_line_in_range_resolves_to_itself() {
1926 assert_eq!(FileAnchor::Line(0).resolve_line(5), 0);
1927 assert_eq!(FileAnchor::Line(3).resolve_line(5), 3);
1928 }
1929
1930 /// Past the end CLAMPS rather than erroring. A producer computing a line
1931 /// from a file it has not read can legitimately be off, and refusing to
1932 /// file the text at all is worse than filing it at the end — this is the
1933 /// difference between "your archive entry went somewhere" and "your
1934 /// archive entry is gone".
1935 #[test]
1936 fn a_line_past_the_end_clamps_to_append() {
1937 assert_eq!(FileAnchor::Line(9).resolve_line(5), 5);
1938 assert_eq!(FileAnchor::Line(u32::MAX).resolve_line(5), 5);
1939 assert_eq!(
1940 FileAnchor::Line(9).resolve_line(5),
1941 FileAnchor::End.resolve_line(5),
1942 "clamping means exactly End, not almost-End"
1943 );
1944 }
1945
1946 /// `Line(n)` where n == the count is already "past the last line", so it
1947 /// is End rather than an off-by-one that inserts inside the last line.
1948 #[test]
1949 fn a_line_equal_to_the_count_is_append_not_the_last_line() {
1950 assert_eq!(
1951 FileAnchor::Line(5).resolve_line(5),
1952 FileAnchor::End.resolve_line(5)
1953 );
1954 }
1955
1956 /// `WriteToFile` is a mutation for dot-repeat and for Visual auto-exit.
1957 /// Pinned here because both classifiers live in other crates and a new
1958 /// effect silently defaulting to "not a mutation" is the kind of gap
1959 /// nothing surfaces until `.` mysteriously replays the wrong thing.
1960 #[test]
1961 fn write_to_file_is_constructible_with_and_without_a_cut() {
1962 let base = Effect::WriteToFile {
1963 path: std::path::PathBuf::from("/tmp/archive.org"),
1964 anchor: FileAnchor::End,
1965 text: "* Done\n".to_string(),
1966 cut: None,
1967 create_parents: false,
1968 save: false,
1969 };
1970 assert!(matches!(base, Effect::WriteToFile { cut: None, .. }));
1971
1972 let moving = Effect::WriteToFile {
1973 path: std::path::PathBuf::from("/tmp/archive.org"),
1974 anchor: FileAnchor::End,
1975 text: "* Done\n".to_string(),
1976 cut: Some(lattice_protocol::position::Range::new(
1977 lattice_protocol::position::Position::new(2, 0),
1978 lattice_protocol::position::Position::new(5, 0),
1979 )),
1980 create_parents: false,
1981 save: false,
1982 };
1983 assert!(matches!(moving, Effect::WriteToFile { cut: Some(_), .. }));
1984 }
1985
1986 /// OC.9: `save` is a per-call decision, not a property of the effect —
1987 /// capture asks for it and refile / archive do not, from the same
1988 /// constructor. Pinned because the whole point of the flag over a blanket
1989 /// host-side save is that two producers can disagree.
1990 #[test]
1991 fn write_to_file_carries_the_callers_save_choice() {
1992 let committing = Effect::WriteToFile {
1993 path: std::path::PathBuf::from("/tmp/inbox.org"),
1994 anchor: FileAnchor::End,
1995 text: "* TODO Captured\n".to_string(),
1996 cut: None,
1997 create_parents: false,
1998 save: true,
1999 };
2000 assert!(matches!(committing, Effect::WriteToFile { save: true, .. }));
2001 }
2002
2003 #[test]
2004 fn none_is_none() {
2005 assert!(Effect::None.is_none());
2006 }
2007
2008 #[test]
2009 fn yank_carries_register_and_content() {
2010 let e = Effect::Yank {
2011 register: Register::Unnamed,
2012 content: "hello".into(),
2013 kind: YankKind::Charwise,
2014 explicit_yank: true,
2015 };
2016 match e {
2017 Effect::Yank {
2018 register,
2019 content,
2020 kind,
2021 explicit_yank,
2022 } => {
2023 assert_eq!(register, Register::Unnamed);
2024 assert_eq!(content, "hello");
2025 assert_eq!(kind, YankKind::Charwise);
2026 assert!(explicit_yank);
2027 }
2028 _ => panic!("expected Yank"),
2029 }
2030 }
2031
2032 #[test]
2033 fn yank_kind_serializes() {
2034 let charwise = serde_json::to_string(&YankKind::Charwise).unwrap();
2035 let linewise = serde_json::to_string(&YankKind::Linewise).unwrap();
2036 assert!(charwise.contains("Charwise"));
2037 assert!(linewise.contains("Linewise"));
2038 }
2039}