Skip to main content

lattice_diff/
install.rs

1//! BC.6 / DX.7 / CR.6: the crate-owned `install(boot)` entry point.
2//!
3//! The diff subsystem registers its own **modes + commands** through the
4//! generic [`SubsystemBoot`] surface, collapsing the host wiring into one
5//! Phase-B line (`lattice_diff::install(&mut boot)`) — the terminal /
6//! claude-code / multibuffer shape.
7//!
8//! **CR.6 (2026-06-24): the diff subsystem registers its own commands.**
9//! Every diff action (`action:diff-*`, `action:hunk-*`) and ex-command
10//! (`ex:diff*` / `ex:hunk-*` / `ex:describe-diff`) is declared here via
11//! `boot.commands_mut()` — the "modes register commands" pattern
12//! (multibuffer precedent). The command *declarations* are mode-owned; the
13//! command *bodies* split two ways by what they need:
14//!
15//! - **mode-owned bodies** — the `do`/`dp`, conflict (`d2o`…`dB`), and
16//!   hunk-nav (`]c`/`[c`) chords resolve through the modes'
17//!   `action_handlers()` (the `ActionHandlerRegistry` is consulted before
18//!   the CommandSpec), which read the `DiffSubsystemHandle` service and
19//!   return an `Effect`. The action specs here are pure shells.
20//! - **host `Effect` appliers** — the ex-command apply closures parse args
21//!   and return a host-boundary `Effect` (`DiffOpen`, `Diffsplit`,
22//!   `DiffGetCmd`, …) that the host's `handle_effect` applies. The lifecycle
23//!   appliers (`do_diff_open`/`diffsplit`/`off`/`accept`/`reject`/`diffthis`)
24//!   stay host-side: they mutate `&mut Editor` (pane tree, document actor,
25//!   task spawning), which `lattice-diff` cannot reach without a dependency
26//!   cycle. This is the Effect-vocabulary-is-the-host-boundary rule.
27//!
28//! The commands register under their plain user-facing names (`diff`,
29//! `diffsplit`, `hunk-next`, …) — no `ex:` prefix and no host alias shim
30//! (the multibuffer pattern); `:diff` resolves to them directly.
31//!
32//! Two diff touch-points still stay host-side, and are *not* mode-ownership
33//! violations (see `docs/dev/architecture/diff-extraction.md`, couplings
34//! C6/C10):
35//!
36//! - **The `DiffSubsystem` lifecycle** — `bind` with the host's
37//!   `BufferRegistryDocumentResolver`, the `diff_subsystem` /
38//!   `diff_subscription_guard` / `diff_forwarders` Editor fields, and the
39//!   `apply_pending_diff_mode_changes` dispatch-tail drain — is host
40//!   actor-loop state (terminal-invocation-runner category). The subsystem
41//!   handle is published as a service (`DiffSubsystemHandle`) so the modes
42//!   reach it generically.
43//! - **The `+N ~M` modeline element** is registered against the host's
44//!   `ModelineService`, created *after* the Phase-B install list (boot
45//!   ordering). The descriptor + `diff_content` formatter are mode-owned (in
46//!   [`crate::mode`]); only the registration *call* is host-sequenced.
47
48use lattice_grammar::AppEffect;
49use lattice_grammar::CommandRegistry;
50use lattice_grammar::args::{ArgDefault, ArgKind, ArgSpec, Args};
51use lattice_grammar::command::LatencyClass;
52use lattice_grammar::effect::Effect;
53use lattice_grammar::error::{CommandError, GrammarResult};
54use lattice_grammar::registry::{ActionSpec, ExCommandContext, ExCommandSpec, SurfaceForm};
55use lattice_mode::SubsystemBoot;
56use std::sync::Arc;
57
58use crate::mode::register_diff_modes;
59
60/// Wire the diff subsystem's modes + commands into the editor at boot.
61pub fn install(boot: &mut impl SubsystemBoot) {
62    // D.5.a / DX.8: register `diff-mode` + `diff-conflict-mode`. Each mode's
63    // `on_activate` registers per-buffer render providers (DX.3-C7); their
64    // `keymap()` + `action_handlers()` contribute the chord surface, picked
65    // up by the host's generic K.2.4 + `register_mode_action_handlers` walks.
66    register_diff_modes(boot.modes_mut());
67    // CR.6: the diff subsystem registers its own commands (multibuffer
68    // pattern). Declarations are mode-owned; bodies are mode `action_handlers`
69    // (chords) or host `Effect` appliers (lifecycle ex-commands).
70    register_diff_commands(boot.commands_mut());
71}
72
73/// CR.6: register every diff action + ex-command against the boot
74/// `CommandRegistry`.
75fn register_diff_commands(registry: &mut CommandRegistry) {
76    register_diff_actions(registry);
77    register_diff_ex_commands(registry);
78}
79
80/// The `action:*` commands the diff chords resolve. All are mode-owned: the
81/// real bodies live in `DiffMode`/`DiffConflictMode::action_handlers()`,
82/// consulted before these CommandSpecs in dispatch. `diff-get`/`diff-put`
83/// keep their `AppEffect` fallback (the host arm is emptied to a no-op since
84/// CR.1); the conflict + hunk-nav actions fall back to `Effect::None`.
85fn register_diff_actions(registry: &mut CommandRegistry) {
86    registry.register_action(
87        "action:diff-get",
88        "diff-mode `do`: rewrite the current side's hunk to match the baseline.",
89        ActionSpec {
90            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::DiffGet))),
91            args_schema: vec![],
92        },
93    );
94    registry.register_action(
95        "action:diff-put",
96        "diff-mode `dp`: push the current side's hunk into the peer buffer.",
97        ActionSpec {
98            apply: Arc::new(|_| Ok(Effect::AppAction(AppEffect::DiffPut))),
99            args_schema: vec![],
100        },
101    );
102    for (name, doc) in [
103        (
104            "action:diff-keep-ours",
105            "diff-conflict `d2o`: keep the local (ours) side.",
106        ),
107        (
108            "action:diff-keep-theirs",
109            "diff-conflict `d3o`: take the remote (theirs) side into local.",
110        ),
111        (
112            "action:diff-put-ours",
113            "diff-conflict `d2p`: put local into the ours side.",
114        ),
115        (
116            "action:diff-put-theirs",
117            "diff-conflict `d3p`: put local into the remote (theirs) side.",
118        ),
119        (
120            "action:diff-keep-both",
121            "diff-conflict `dB`: keep both — splice ours then theirs.",
122        ),
123        (
124            "action:hunk-next",
125            "diff `]c`: jump the cursor to the next hunk start (wraps).",
126        ),
127        (
128            "action:hunk-prev",
129            "diff `[c`: jump the cursor to the previous hunk start (wraps).",
130        ),
131    ] {
132        registry.register_action(
133            name,
134            doc,
135            ActionSpec {
136                apply: Arc::new(|_| Ok(Effect::None)),
137                args_schema: vec![],
138            },
139        );
140    }
141}
142
143/// The `:diff*` / `:hunk-*` ex-commands, registered under their plain
144/// user-facing names (no `ex:` prefix / host alias — multibuffer pattern).
145/// The apply closures parse args + return a host-boundary `Effect` the host
146/// applies.
147fn register_diff_ex_commands(registry: &mut CommandRegistry) {
148    registry.register_ex_command(
149        "diff",
150        "Open an inline diff session for the active document against its on-disk content (`:diff`).",
151        ExCommandSpec {
152            latency_class: LatencyClass::Display,
153            accepts_bang: false,
154            accepts_range: false,
155            parse_args: Arc::new(parse_no_args),
156            apply: Arc::new(|_| Ok(Effect::DiffOpen)),
157            args_schema: vec![],
158            surface_form: SurfaceForm::Keyword,
159        },
160    );
161    registry.register_ex_command(
162        "diffoff",
163        "Close the active pane's diff session, if any (`:diffoff[!]`).",
164        ExCommandSpec {
165            latency_class: LatencyClass::Display,
166            accepts_bang: true,
167            accepts_range: false,
168            parse_args: Arc::new(parse_no_args),
169            apply: Arc::new(|ctx| Ok(Effect::DiffOff { force: ctx.bang })),
170            args_schema: vec![],
171            surface_form: SurfaceForm::Keyword,
172        },
173    );
174    registry.register_ex_command(
175        "diffthis",
176        "Stage the active pane for a two-pane diff; the second `:diffthis` in a different pane \
177         completes the session. Same pane twice unstages.",
178        ExCommandSpec {
179            latency_class: LatencyClass::Display,
180            accepts_bang: false,
181            accepts_range: false,
182            parse_args: Arc::new(parse_no_args),
183            apply: Arc::new(|_| Ok(Effect::Diffthis)),
184            args_schema: vec![],
185            surface_form: SurfaceForm::Keyword,
186        },
187    );
188    registry.register_ex_command(
189        "diffsplit",
190        "Open `<base>` (and optionally `<remote>`) in new vertical splits and register a diff \
191         session. One arg ⇒ two-way; two args ⇒ three-way merge (`:diffsplit <base> [<remote>]`).",
192        ExCommandSpec {
193            latency_class: LatencyClass::Display,
194            accepts_bang: false,
195            accepts_range: false,
196            parse_args: Arc::new(parse_required_path),
197            apply: Arc::new(apply_diffsplit),
198            args_schema: vec![
199                ArgSpec {
200                    name: "base".into(),
201                    kind: ArgKind::String,
202                    doc: "File path. Two-way: the baseline; three-way: the common ancestor.".into(),
203                    prompt: "base path:".into(),
204                    default: ArgDefault::Required,
205                    completion: Some("gen:files".into()),
206                    picker: None,
207                },
208                ArgSpec {
209                    name: "remote".into(),
210                    kind: ArgKind::String,
211                    doc: "Optional second file path → three-way merge (current pane = local)."
212                        .into(),
213                    prompt: "remote path:".into(),
214                    default: ArgDefault::None,
215                    completion: Some("gen:files".into()),
216                    picker: None,
217                },
218            ],
219            surface_form: SurfaceForm::Keyword,
220        },
221    );
222    registry.register_ex_command(
223        "diffget",
224        "Pull the hunk under the cursor from another buffer's side into the active buffer \
225         (`:diffget [<bufnr>]`). `<bufnr>` is required for three-way.",
226        ExCommandSpec {
227            latency_class: LatencyClass::Display,
228            accepts_bang: false,
229            accepts_range: false,
230            parse_args: Arc::new(parse_optional_bufnr),
231            apply: Arc::new(apply_diffget),
232            args_schema: vec![ArgSpec {
233                name: "bufnr".into(),
234                kind: ArgKind::String,
235                doc: "Optional buffer number to pull from (required in three-way merge).".into(),
236                prompt: "bufnr:".into(),
237                default: ArgDefault::None,
238                completion: None,
239                picker: None,
240            }],
241            surface_form: SurfaceForm::Keyword,
242        },
243    );
244    registry.register_ex_command(
245        "diffput",
246        "Push the hunk under the cursor from the active buffer into another buffer's side \
247         (`:diffput [<bufnr>]`). `<bufnr>` is required for three-way.",
248        ExCommandSpec {
249            latency_class: LatencyClass::Display,
250            accepts_bang: false,
251            accepts_range: false,
252            parse_args: Arc::new(parse_optional_bufnr),
253            apply: Arc::new(apply_diffput),
254            args_schema: vec![ArgSpec {
255                name: "bufnr".into(),
256                kind: ArgKind::String,
257                doc: "Optional buffer number to push to (required in three-way merge).".into(),
258                prompt: "bufnr:".into(),
259                default: ArgDefault::None,
260                completion: None,
261                picker: None,
262            }],
263            surface_form: SurfaceForm::Keyword,
264        },
265    );
266    registry.register_ex_command(
267        "diff-accept",
268        "Resolve the active pane's diff session with Accept, tear it down, and fire the Accept \
269         signal on any bound completion channel (`:diff-accept`).",
270        ExCommandSpec {
271            latency_class: LatencyClass::Display,
272            accepts_bang: false,
273            accepts_range: false,
274            parse_args: Arc::new(parse_no_args),
275            apply: Arc::new(|_| Ok(Effect::DiffAccept)),
276            args_schema: vec![],
277            surface_form: SurfaceForm::Keyword,
278        },
279    );
280    registry.register_ex_command(
281        "diff-reject",
282        "Resolve the active pane's diff session with Reject, tear it down, and fire the Reject \
283         signal on any bound completion channel (`:diff-reject`).",
284        ExCommandSpec {
285            latency_class: LatencyClass::Display,
286            accepts_bang: false,
287            accepts_range: false,
288            parse_args: Arc::new(parse_no_args),
289            apply: Arc::new(|_| Ok(Effect::DiffReject)),
290            args_schema: vec![],
291            surface_form: SurfaceForm::Keyword,
292        },
293    );
294    registry.register_ex_command(
295        "diff-accept-all",
296        "Resolve EVERY pending diff review with Accept — the bulk counterpart \
297         to `:diff-accept` for when several agent reviews are open at once.",
298        ExCommandSpec {
299            latency_class: LatencyClass::Display,
300            accepts_bang: false,
301            accepts_range: false,
302            parse_args: Arc::new(parse_no_args),
303            apply: Arc::new(|_| Ok(Effect::DiffAcceptAll)),
304            args_schema: vec![],
305            surface_form: SurfaceForm::Keyword,
306        },
307    );
308    registry.register_ex_command(
309        "diff-reject-all",
310        "Resolve EVERY pending diff review with Reject — the bulk counterpart \
311         to `:diff-reject` (`:diff-reject-all`).",
312        ExCommandSpec {
313            latency_class: LatencyClass::Display,
314            accepts_bang: false,
315            accepts_range: false,
316            parse_args: Arc::new(parse_no_args),
317            apply: Arc::new(|_| Ok(Effect::DiffRejectAll)),
318            args_schema: vec![],
319            surface_form: SurfaceForm::Keyword,
320        },
321    );
322    registry.register_ex_command(
323        "hunk-next",
324        "Jump cursor to the start of the next diff hunk on the current side. Wraps to top. \
325         `]c` / `:hunk-next`.",
326        ExCommandSpec {
327            latency_class: LatencyClass::Display,
328            accepts_bang: false,
329            accepts_range: false,
330            parse_args: Arc::new(parse_no_args),
331            apply: Arc::new(|_| Ok(Effect::NextHunk)),
332            args_schema: vec![],
333            surface_form: SurfaceForm::Keyword,
334        },
335    );
336    registry.register_ex_command(
337        "hunk-prev",
338        "Jump cursor to the start of the previous diff hunk on the current side. Wraps to \
339         bottom. `[c` / `:hunk-prev`.",
340        ExCommandSpec {
341            latency_class: LatencyClass::Display,
342            accepts_bang: false,
343            accepts_range: false,
344            parse_args: Arc::new(parse_no_args),
345            apply: Arc::new(|_| Ok(Effect::PrevHunk)),
346            args_schema: vec![],
347            surface_form: SurfaceForm::Keyword,
348        },
349    );
350    registry.register_ex_command(
351        "describe-diff",
352        "List every active diff session (`:describe-diff`): BufferId, algorithm, revision, hunk \
353         count, watched buffers.",
354        ExCommandSpec {
355            latency_class: LatencyClass::Display,
356            accepts_bang: false,
357            accepts_range: false,
358            parse_args: Arc::new(parse_no_args),
359            apply: Arc::new(|_| Ok(Effect::DescribeDiff)),
360            args_schema: vec![],
361            surface_form: SurfaceForm::Keyword,
362        },
363    );
364}
365
366// ── parse / apply helpers (relocated from lattice-grammar's ex_commands.rs) ──
367
368/// Reject any trailing characters; the command takes no args.
369fn parse_no_args(rest: &str, _bang: bool) -> GrammarResult<Args> {
370    if rest.trim().is_empty() {
371        Ok(Args::None)
372    } else {
373        Err(CommandError::BadArgs(
374            "trailing characters after command".into(),
375        ))
376    }
377}
378
379/// 1-or-2-path parser for `:diffsplit <base> [<remote>]`. Joins the two
380/// paths with `\x1f` inside `Args::String` (no `Vec<String>` variant in v1);
381/// `apply_diffsplit` splits them back. Empty first arg errors.
382fn parse_required_path(rest: &str, _bang: bool) -> GrammarResult<Args> {
383    let trimmed = rest.trim();
384    if trimmed.is_empty() {
385        return Err(CommandError::BadArgs(
386            "expected file path (`:diffsplit <base> [<remote>]`)".into(),
387        ));
388    }
389    let mut parts = trimmed.split_whitespace();
390    let base = parts.next().expect("non-empty after trim");
391    let remote = parts.next();
392    if parts.next().is_some() {
393        return Err(CommandError::BadArgs(
394            ":diffsplit takes at most two paths (`:diffsplit <base> [<remote>]`)".into(),
395        ));
396    }
397    let encoded = match remote {
398        Some(r) => format!("{base}\x1f{r}"),
399        None => base.to_string(),
400    };
401    Ok(Args::String(encoded))
402}
403
404/// Optional `<bufnr>` parser for `:diffget`/`:diffput`. Empty ⇒ `Args::None`;
405/// non-empty ⇒ `Args::String(bufnr)` after validating it's a non-negative u32.
406fn parse_optional_bufnr(rest: &str, _bang: bool) -> GrammarResult<Args> {
407    let trimmed = rest.trim();
408    if trimmed.is_empty() {
409        return Ok(Args::None);
410    }
411    trimmed
412        .parse::<u32>()
413        .map_err(|e| CommandError::BadArgs(format!("bufnr must be a non-negative integer: {e}")))?;
414    Ok(Args::String(trimmed.to_string()))
415}
416
417/// `:diffget [<bufnr>]` → `Effect::DiffGetCmd { target }`.
418fn apply_diffget(ctx: &ExCommandContext) -> GrammarResult<Effect> {
419    Ok(Effect::DiffGetCmd {
420        target: parse_bufnr_arg(ctx, "diffget")?,
421    })
422}
423
424/// `:diffput [<bufnr>]` → `Effect::DiffPutCmd { target }`.
425fn apply_diffput(ctx: &ExCommandContext) -> GrammarResult<Effect> {
426    Ok(Effect::DiffPutCmd {
427        target: parse_bufnr_arg(ctx, "diffput")?,
428    })
429}
430
431/// Shared `Args → Option<bufnr>` decode for `:diffget`/`:diffput`.
432fn parse_bufnr_arg(ctx: &ExCommandContext, cmd: &str) -> GrammarResult<Option<u32>> {
433    match &ctx.args {
434        Args::None => Ok(None),
435        Args::String(s) => {
436            Ok(Some(s.parse::<u32>().map_err(|e| {
437                CommandError::BadArgs(format!("bufnr: {e}"))
438            })?))
439        }
440        _ => Err(CommandError::BadArgs(format!(
441            "expected optional bufnr (`:{cmd} [<bufnr>]`)"
442        ))),
443    }
444}
445
446/// `:diffsplit <base> [<remote>]` → `Effect::Diffsplit { path, remote }`,
447/// decoding the parser's `\x1f`-joined paths.
448fn apply_diffsplit(ctx: &ExCommandContext) -> GrammarResult<Effect> {
449    let encoded = match &ctx.args {
450        Args::String(s) => s,
451        _ => {
452            return Err(CommandError::BadArgs(
453                "expected file path (`:diffsplit <base> [<remote>]`)".into(),
454            ));
455        }
456    };
457    let (path, remote) = match encoded.split_once('\x1f') {
458        Some((base, rem)) => (
459            std::path::PathBuf::from(base),
460            Some(std::path::PathBuf::from(rem)),
461        ),
462        None => (std::path::PathBuf::from(encoded), None),
463    };
464    Ok(Effect::Diffsplit { path, remote })
465}