1extern crate self as escriba_command;
4
5use std::collections::HashMap;
6
7use escriba_core::BufferId;
8use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
9use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum CommandError {
16 #[error("command not found: {0}")]
17 NotFound(String),
18 #[error("action `{0}` is declared but not implemented yet")]
27 Unhandled(String),
28 #[error("command failed: {0}")]
29 Failed(String),
30 }
35
36pub type Result<T> = std::result::Result<T, CommandError>;
37
38pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
47
48#[derive(Debug, Clone)]
63pub enum Handler {
64 Native(CommandFn),
66 Action(String),
68}
69
70#[derive(Debug, Clone)]
71pub struct Command {
72 pub name: String,
73 pub description: String,
74 pub handler: Handler,
75}
76
77impl Command {
78 pub fn native(
80 name: impl Into<String>,
81 description: impl Into<String>,
82 handler: CommandFn,
83 ) -> Self {
84 Self {
85 name: name.into(),
86 description: description.into(),
87 handler: Handler::Native(handler),
88 }
89 }
90
91 pub fn action(
95 name: impl Into<String>,
96 description: impl Into<String>,
97 action: impl Into<String>,
98 ) -> Self {
99 Self {
100 name: name.into(),
101 description: description.into(),
102 handler: Handler::Action(action.into()),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108pub struct CommandSpec {
109 pub name: String,
110 pub description: String,
111 #[serde(default)]
112 pub args: Vec<CommandArgSpec>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
116pub struct CommandArgSpec {
117 pub name: String,
118 pub description: String,
119 #[serde(default)]
120 pub required: bool,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub variants: Vec<String>,
123}
124
125#[derive(Debug, Default, Clone)]
126pub struct CommandRegistry {
127 commands: HashMap<String, Command>,
128}
129
130impl CommandRegistry {
131 #[must_use]
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 #[must_use]
137 pub fn default_set() -> Self {
138 let mut r = Self::new();
139 r.register(Command::native(
140 "save",
141 "Write the active buffer to disk",
142 erase::<Save>(),
143 ));
144 r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
145 r.register(Command::native(
150 "buffer.next",
151 "Go to the next buffer",
152 erase::<BufferNext>(),
153 ));
154 r.register(Command::native(
155 "buffer.prev",
156 "Go to the previous buffer",
157 erase::<BufferPrev>(),
158 ));
159 r.register(Command::native(
160 "buffer.delete",
161 "Close the active buffer",
162 erase::<BufferDelete>(),
163 ));
164 r.register(Command::native(
165 "todo.next",
166 "Go to the next TODO/FIXME marker",
167 erase::<TodoWalk<true>>(),
168 ));
169 r.register(Command::native(
170 "todo.prev",
171 "Go to the previous TODO/FIXME marker",
172 erase::<TodoWalk<false>>(),
173 ));
174 for name in ["comment.toggle-line", "comment.toggle-block"] {
175 r.register(Command::native(
176 name,
177 "Toggle the comment on the current line",
178 erase::<CommentToggle>(),
179 ));
180 }
181 for alias in ["noh", "nohl", "nohlsearch"] {
182 r.register(Command::action(
183 alias,
184 "Stop highlighting matches, keep the pattern",
185 "search.clear-highlight",
186 ));
187 }
188 r.register(Command::native(
189 "undo",
190 "Undo the last change",
191 erase::<Undo>(),
192 ));
193 r.register(Command::native(
194 "redo",
195 "Redo the last undone change",
196 erase::<Redo>(),
197 ));
198 r.register(Command::native(
199 "buffer-info",
200 "Print the active buffer summary",
201 erase::<Info>(),
202 ));
203 r
204 }
205
206 pub fn register(&mut self, command: Command) {
207 self.commands.insert(command.name.clone(), command);
208 }
209
210 #[must_use]
213 pub fn contains(&self, name: &str) -> bool {
214 self.commands.contains_key(name)
215 }
216
217 #[must_use]
219 pub fn len(&self) -> usize {
220 self.commands.len()
221 }
222
223 #[must_use]
225 pub fn is_empty(&self) -> bool {
226 self.commands.is_empty()
227 }
228
229 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
235 let cmd = self
236 .commands
237 .get(name)
238 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
239 match &cmd.handler {
240 Handler::Native(f) => Ok(f(snap, args)),
241 Handler::Action(sym) => run_action(sym, snap, args),
242 }
243 }
244
245 #[must_use]
246 pub fn names(&self) -> Vec<&str> {
247 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
248 v.sort_unstable();
249 v
250 }
251
252 #[must_use]
253 pub fn specs(&self) -> Vec<CommandSpec> {
254 let mut out: Vec<CommandSpec> = self
255 .commands
256 .values()
257 .map(|c| CommandSpec {
258 name: c.name.to_string(),
259 description: c.description.to_string(),
260 args: Vec::new(),
261 })
262 .collect();
263 out.sort_by(|a, b| a.name.cmp(&b.name));
264 out
265 }
266}
267
268fn run_action(sym: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
270 match sym {
271 "buffer.save" | "buffer.write" => Ok(erase::<Save>()(snap, args)),
272 "buffer.write-all" => Ok(erase::<WriteAll>()(snap, args)),
273 "buffer.undo" => Ok(erase::<Undo>()(snap, args)),
274 "buffer.redo" => Ok(erase::<Redo>()(snap, args)),
275 "buffer.info" => Ok(erase::<Info>()(snap, args)),
276 "editor.quit" => Ok(erase::<Quit>()(snap, args)),
277 "search.clear-highlight" => Ok(erase::<Noh>()(snap, args)),
278 _ => Err(CommandError::Unhandled(sym.to_string())),
285 }
286}
287
288fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
293 b.active()
294 .map(BufferView::id)
295 .ok_or_else(|| Outcome::declined("no active buffer"))
296}
297
298type Result2<T> = std::result::Result<T, Outcome>;
299
300struct WriteAll;
306impl Native for WriteAll {
307 type Reads = caps!(Buffers);
308 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
309 let b = v.buffers();
310 let slips: Vec<Negai> = b
311 .ids()
312 .into_iter()
313 .filter(|id| {
314 b.get(*id)
315 .is_some_and(|x| x.is_modified() && x.path().is_some())
316 })
317 .map(|buffer| Negai::Save { buffer })
318 .collect();
319 if slips.is_empty() {
320 return Outcome::declined("no modified files");
321 }
322 Outcome::did(slips)
323 }
324}
325
326struct Save;
327impl Native for Save {
328 type Reads = caps!(Buffers);
329 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
330 match active_or_decline(&v.buffers()) {
331 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
332 Err(o) => o,
333 }
334 }
335}
336
337struct Undo;
338impl Native for Undo {
339 type Reads = caps!(Buffers);
340 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
341 match active_or_decline(&v.buffers()) {
342 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
343 Err(o) => o,
344 }
345 }
346}
347
348struct Redo;
349impl Native for Redo {
350 type Reads = caps!(Buffers);
351 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
352 match active_or_decline(&v.buffers()) {
353 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
354 Err(o) => o,
355 }
356 }
357}
358
359struct Info;
366impl Native for Info {
367 type Reads = caps!(Buffers);
368 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
369 let b = v.buffers();
370 let Some(buf) = b.active() else {
371 return Outcome::declined("no active buffer");
372 };
373 let mut m = String::with_capacity(48);
374 m.push_str("buffer ");
375 m.push_str(&buf.id().0.to_string());
376 m.push_str(" — ");
377 m.push_str(&buf.line_count().to_string());
378 m.push_str(" line(s)");
379 if buf.is_modified() {
380 m.push_str(" [modified]");
381 }
382 Outcome::did(vec![Negai::Message(m)])
383 }
384}
385
386struct BufferNext;
395impl Native for BufferNext {
396 type Reads = caps!();
397 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
398 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
399 }
400}
401
402struct BufferPrev;
403impl Native for BufferPrev {
404 type Reads = caps!();
405 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
406 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
407 }
408}
409
410struct BufferDelete;
415impl Native for BufferDelete {
416 type Reads = caps!(Buffers);
417 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
418 match active_or_decline(&v.buffers()) {
419 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
420 Err(o) => o,
421 }
422 }
423}
424
425struct CommentToggle;
432impl Native for CommentToggle {
433 type Reads = caps!(Buffers, Cursor, Syntax);
434 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
435 let Some(ft) = v.syntax().filetype() else {
436 return Outcome::declined("no filetype for this buffer");
437 };
438 let Some(comment) = ft.comment.as_ref() else {
439 let mut m = String::from("no comment syntax for ");
440 m.push_str(&ft.name);
441 return Outcome::declined(m);
442 };
443 let b = v.buffers();
444 let Some(buf) = b.active() else {
445 return Outcome::declined("no active buffer");
446 };
447 let line_no = v.cursor().position().line;
448 let Some(line) = buf.line(line_no) else {
449 return Outcome::declined("cursor past the end of the buffer");
450 };
451 if line.trim().is_empty() {
454 return Outcome::declined("nothing on this line");
455 }
456
457 let indent_len = line.len() - line.trim_start().len();
460 let (indent, body) = line.split_at(indent_len);
461 let toggled = match comment.strip(body) {
462 Some(uncommented) => uncommented.to_string(),
463 None => comment.wrap(body),
464 };
465 let mut text = String::with_capacity(indent.len() + toggled.len());
466 text.push_str(indent);
467 text.push_str(&toggled);
468
469 Outcome::did(vec![Negai::Edit {
470 buffer: buf.id(),
471 edit: escriba_core::Edit {
472 range: escriba_core::Range::new(
473 escriba_core::Position::new(line_no, 0),
474 escriba_core::Position::new(
475 line_no,
476 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
477 ),
478 ),
479 kind: escriba_core::EditKind::Replace { text },
480 },
481 }])
482 }
483}
484
485struct TodoWalk<const FORWARD: bool>;
497impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
498 type Reads = caps!(Buffers);
499 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
500 let b = v.buffers();
501 let Some(buf) = b.active() else {
502 return Outcome::declined("no active buffer");
503 };
504 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
505 if findings.is_empty() {
506 return Outcome::declined("no TODO markers in this buffer");
507 }
508 Outcome::did(vec![
509 Negai::PublishFindings {
510 list: "todo".to_string(),
511 findings,
512 },
513 Negai::WalkList {
514 list: "todo".to_string(),
515 forward: FORWARD,
516 },
517 ])
518 }
519}
520
521struct Quit;
522impl Native for Quit {
523 type Reads = caps!();
524 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
525 Outcome::did(vec![Negai::Quit])
526 }
527}
528
529struct Noh;
537impl Native for Noh {
538 type Reads = caps!();
539 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
540 Outcome::did(vec![Negai::ClearSearchHighlight])
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use escriba_core::BufferId;
548 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
549
550 fn dirty_file() -> FakeSnapshot {
552 let mut s = FakeSnapshot::default();
553 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
554 s.active = Some(BufferId(1));
555 s
556 }
557
558 #[test]
559 fn default_set_is_populated() {
560 let r = CommandRegistry::default_set();
561 let names = r.names();
562 assert!(names.contains(&"save"));
563 assert!(names.contains(&"quit"));
564 }
565
566 #[test]
567 fn specs_are_sorted() {
568 let r = CommandRegistry::default_set();
569 let specs = r.specs();
570 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
571 }
572
573 #[test]
574 fn not_found_errors() {
575 let r = CommandRegistry::new();
578 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
579 assert!(matches!(err, CommandError::NotFound(_)));
580 }
581
582 #[test]
583 fn a_command_asks_rather_than_acts() {
584 let mut r = CommandRegistry::new();
591 r.register(Command::action(
592 "w-all",
593 "Write every modified buffer",
594 "buffer.write-all",
595 ));
596 let out = r
597 .run("w-all", &dirty_file(), &[])
598 .expect("registered command dispatches");
599 assert_eq!(
600 out.slips,
601 vec![Negai::Save {
602 buffer: BufferId(1)
603 }]
604 );
605 assert_eq!(out.verdict, Verdict::Did);
606 }
607
608 #[test]
609 fn nothing_to_save_declines_rather_than_claiming_success() {
610 let mut r = CommandRegistry::new();
614 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
615 let out = r
616 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
617 .expect("dispatches");
618 assert!(out.slips.is_empty());
619 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
620 }
621
622 #[test]
623 fn no_active_buffer_declines_rather_than_failing() {
624 let mut r = CommandRegistry::new();
627 r.register(Command::action("w", "Save", "buffer.save"));
628 let out = r
629 .run("w", &FakeSnapshot::default(), &[])
630 .expect("dispatches");
631 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
632 assert!(out.slips.is_empty(), "a decline asks for nothing");
633 }
634
635 #[test]
636 fn unknown_action_symbol_is_reported_not_silent() {
637 let mut r = CommandRegistry::new();
646 r.register(Command::action("pick", "Pick a file", "picker.files"));
647 let err = r
648 .run("pick", &FakeSnapshot::default(), &[])
649 .expect_err("an unimplemented action must report, not report success");
650 assert!(
651 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
652 "expected Unhandled(picker.files), got {err:?}",
653 );
654 assert!(r.contains("pick"), "the command survives its own failure");
655 }
656
657 #[test]
658 fn action_naming_a_command_is_inert_not_recursive() {
659 let mut r = CommandRegistry::new();
667 r.register(Command::action("alias", "aliases save by name", "save"));
668 let err = r
669 .run("alias", &dirty_file(), &[])
670 .expect_err("a command-name alias resolves nothing, and says so");
671 assert!(
672 matches!(&err, CommandError::Unhandled(s) if s == "save"),
673 "expected Unhandled(save), got {err:?}",
674 );
675 }
676
677 #[test]
678 fn quit_is_a_request_not_a_flag_poke() {
679 let mut r = CommandRegistry::new();
684 r.register(Command::action("bye", "Quit", "editor.quit"));
685 let out = r
686 .run("bye", &FakeSnapshot::default(), &[])
687 .expect("dispatches");
688 assert_eq!(out.slips, vec![Negai::Quit]);
689 }
690
691 #[test]
692 fn buffer_info_speaks_through_a_slip_not_stderr() {
693 let mut r = CommandRegistry::new();
697 r.register(Command::action("info", "Buffer info", "buffer.info"));
698 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
699 let Some(Negai::Message(m)) = out.slips.first() else {
700 panic!("expected a Message slip, got {:?}", out.slips);
701 };
702 assert!(m.contains("buffer 1"), "{m}");
703 assert!(m.contains("[modified]"), "{m}");
704 }
705}