1use 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
60pub fn install(boot: &mut impl SubsystemBoot) {
62 register_diff_modes(boot.modes_mut());
67 register_diff_commands(boot.commands_mut());
71}
72
73fn register_diff_commands(registry: &mut CommandRegistry) {
76 register_diff_actions(registry);
77 register_diff_ex_commands(registry);
78}
79
80fn 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
143fn 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
366fn 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
379fn 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
404fn 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
417fn apply_diffget(ctx: &ExCommandContext) -> GrammarResult<Effect> {
419 Ok(Effect::DiffGetCmd {
420 target: parse_bufnr_arg(ctx, "diffget")?,
421 })
422}
423
424fn apply_diffput(ctx: &ExCommandContext) -> GrammarResult<Effect> {
426 Ok(Effect::DiffPutCmd {
427 target: parse_bufnr_arg(ctx, "diffput")?,
428 })
429}
430
431fn 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
446fn 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}