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 #[error("alias cycle resolving `{0}`")]
36 AliasCycle(String),
37 }
42
43pub type Result<T> = std::result::Result<T, CommandError>;
44
45pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
54
55#[derive(Debug, Clone)]
70pub enum Handler {
71 Native(CommandFn),
73 Action(String),
75}
76
77#[derive(Debug, Clone)]
78pub struct Command {
79 pub name: String,
80 pub description: String,
81 pub handler: Handler,
82}
83
84impl Command {
85 pub fn native(
87 name: impl Into<String>,
88 description: impl Into<String>,
89 handler: CommandFn,
90 ) -> Self {
91 Self {
92 name: name.into(),
93 description: description.into(),
94 handler: Handler::Native(handler),
95 }
96 }
97
98 pub fn action(
102 name: impl Into<String>,
103 description: impl Into<String>,
104 action: impl Into<String>,
105 ) -> Self {
106 Self {
107 name: name.into(),
108 description: description.into(),
109 handler: Handler::Action(action.into()),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
115pub struct CommandSpec {
116 pub name: String,
117 pub description: String,
118 #[serde(default)]
119 pub args: Vec<CommandArgSpec>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct CommandArgSpec {
124 pub name: String,
125 pub description: String,
126 #[serde(default)]
127 pub required: bool,
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub variants: Vec<String>,
130}
131
132#[derive(Debug, Default, Clone)]
133pub struct CommandRegistry {
134 commands: HashMap<String, Command>,
135}
136
137impl CommandRegistry {
138 #[must_use]
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 #[must_use]
144 pub fn default_set() -> Self {
145 let mut r = Self::new();
146 r.register(Command::native(
147 "save",
148 "Write the active buffer to disk",
149 erase::<Save>(),
150 ));
151 r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
152 r.register(Command::native(
157 "buffer.next",
158 "Go to the next buffer",
159 erase::<BufferNext>(),
160 ));
161 r.register(Command::native(
162 "buffer.prev",
163 "Go to the previous buffer",
164 erase::<BufferPrev>(),
165 ));
166 r.register(Command::native(
167 "buffer.delete",
168 "Close the active buffer",
169 erase::<BufferDelete>(),
170 ));
171 r.register(Command::native(
172 "picker.buffers",
173 "Pick an open buffer",
174 erase::<OpenPicker<false>>(),
175 ));
176 r.register(Command::native(
177 "picker.commands",
178 "Pick a command",
179 erase::<OpenPicker<true>>(),
180 ));
181 r.register(Command::native(
182 "picker.help",
183 "Search every keybinding",
184 erase::<HelpPicker>(),
185 ));
186 r.register(Command::native(
187 "picker.grep",
188 "Search the project for a pattern",
189 erase::<GrepPicker>(),
190 ));
191 r.register(Command::native(
192 "picker.files",
193 "Pick a file under the working directory",
194 erase::<WalkPicker<false>>(),
195 ));
196 r.register(Command::native(
197 "picker.project",
198 "Pick a project root",
199 erase::<WalkPicker<true>>(),
200 ));
201 r.register(Command::native(
202 "window.split",
203 "Split the window horizontally (:sp)",
204 erase::<SplitWindow<true>>(),
205 ));
206 r.register(Command::native(
207 "window.vsplit",
208 "Split the window vertically (:vsp)",
209 erase::<SplitWindow<false>>(),
210 ));
211 r.register(Command::native(
212 "window.close",
213 "Close the active window (:close)",
214 erase::<CloseWindow>(),
215 ));
216 r.register(Command::native(
217 "pane.left",
218 "Focus the window to the left",
219 erase::<FocusDir<-1, 0>>(),
220 ));
221 r.register(Command::native(
222 "pane.right",
223 "Focus the window to the right",
224 erase::<FocusDir<1, 0>>(),
225 ));
226 r.register(Command::native(
227 "pane.up",
228 "Focus the window above",
229 erase::<FocusDir<0, -1>>(),
230 ));
231 r.register(Command::native(
232 "pane.down",
233 "Focus the window below",
234 erase::<FocusDir<0, 1>>(),
235 ));
236 r.register(Command::native(
237 "todo.next",
238 "Go to the next TODO/FIXME marker",
239 erase::<TodoWalk<true>>(),
240 ));
241 r.register(Command::native(
242 "todo.prev",
243 "Go to the previous TODO/FIXME marker",
244 erase::<TodoWalk<false>>(),
245 ));
246 for name in ["comment.toggle-line", "comment.toggle-block"] {
247 r.register(Command::native(
248 name,
249 "Toggle the comment on the current line",
250 erase::<CommentToggle>(),
251 ));
252 }
253 for alias in ["noh", "nohl", "nohlsearch"] {
254 r.register(Command::action(
255 alias,
256 "Stop highlighting matches, keep the pattern",
257 "search.clear-highlight",
258 ));
259 }
260 r.register(Command::native(
261 "undo",
262 "Undo the last change",
263 erase::<Undo>(),
264 ));
265 r.register(Command::native(
266 "redo",
267 "Redo the last undone change",
268 erase::<Redo>(),
269 ));
270 r.register(Command::native(
271 "buffer-info",
272 "Print the active buffer summary",
273 erase::<Info>(),
274 ));
275 r
276 }
277
278 pub fn register(&mut self, command: Command) {
279 self.commands.insert(command.name.clone(), command);
280 }
281
282 #[must_use]
285 pub fn contains(&self, name: &str) -> bool {
286 self.commands.contains_key(name)
287 }
288
289 #[must_use]
291 pub fn len(&self) -> usize {
292 self.commands.len()
293 }
294
295 #[must_use]
297 pub fn is_empty(&self) -> bool {
298 self.commands.is_empty()
299 }
300
301 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
307 self.run_bounded(name, snap, args, ALIAS_FUEL)
308 }
309
310 fn run_bounded(
323 &self,
324 name: &str,
325 snap: &dyn Snapshot,
326 args: &[String],
327 fuel: u8,
328 ) -> Result<Outcome> {
329 let Some(fuel) = fuel.checked_sub(1) else {
330 return Err(CommandError::AliasCycle(name.to_string()));
331 };
332 let cmd = self
333 .commands
334 .get(name)
335 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
336 match &cmd.handler {
337 Handler::Native(f) => Ok(f(snap, args)),
338 Handler::Action(sym) => match builtin_action(sym) {
339 Some(f) => Ok(f(snap, args)),
340 None if sym != name && self.commands.contains_key(sym.as_str()) => {
344 self.run_bounded(sym, snap, args, fuel)
345 }
346 None => Err(CommandError::Unhandled(sym.to_string())),
347 },
348 }
349 }
350
351 #[must_use]
352 pub fn names(&self) -> Vec<&str> {
353 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
354 v.sort_unstable();
355 v
356 }
357
358 #[must_use]
359 pub fn specs(&self) -> Vec<CommandSpec> {
360 let mut out: Vec<CommandSpec> = self
361 .commands
362 .values()
363 .map(|c| CommandSpec {
364 name: c.name.to_string(),
365 description: c.description.to_string(),
366 args: Vec::new(),
367 })
368 .collect();
369 out.sort_by(|a, b| a.name.cmp(&b.name));
370 out
371 }
372}
373
374const ALIAS_FUEL: u8 = 8;
379
380fn builtin_action(sym: &str) -> Option<CommandFn> {
386 Some(match sym {
387 "buffer.save" | "buffer.write" => erase::<Save>(),
388 "buffer.write-all" => erase::<WriteAll>(),
389 "buffer.undo" => erase::<Undo>(),
390 "buffer.redo" => erase::<Redo>(),
391 "buffer.info" => erase::<Info>(),
392 "editor.quit" => erase::<Quit>(),
393 "search.clear-highlight" => erase::<Noh>(),
394 _ => return None,
401 })
402}
403
404fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
409 b.active()
410 .map(BufferView::id)
411 .ok_or_else(|| Outcome::declined("no active buffer"))
412}
413
414type Result2<T> = std::result::Result<T, Outcome>;
415
416struct WriteAll;
422impl Native for WriteAll {
423 type Reads = caps!(Buffers);
424 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
425 let b = v.buffers();
426 let slips: Vec<Negai> = b
427 .ids()
428 .into_iter()
429 .filter(|id| {
430 b.get(*id)
431 .is_some_and(|x| x.is_modified() && x.path().is_some())
432 })
433 .map(|buffer| Negai::Save { buffer })
434 .collect();
435 if slips.is_empty() {
436 return Outcome::declined("no modified files");
437 }
438 Outcome::did(slips)
439 }
440}
441
442struct Save;
443impl Native for Save {
444 type Reads = caps!(Buffers);
445 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
446 match active_or_decline(&v.buffers()) {
447 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
448 Err(o) => o,
449 }
450 }
451}
452
453struct Undo;
454impl Native for Undo {
455 type Reads = caps!(Buffers);
456 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
457 match active_or_decline(&v.buffers()) {
458 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
459 Err(o) => o,
460 }
461 }
462}
463
464struct Redo;
465impl Native for Redo {
466 type Reads = caps!(Buffers);
467 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
468 match active_or_decline(&v.buffers()) {
469 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
470 Err(o) => o,
471 }
472 }
473}
474
475struct Info;
482impl Native for Info {
483 type Reads = caps!(Buffers);
484 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
485 let b = v.buffers();
486 let Some(buf) = b.active() else {
487 return Outcome::declined("no active buffer");
488 };
489 let mut m = String::with_capacity(48);
490 m.push_str("buffer ");
491 m.push_str(&buf.id().0.to_string());
492 m.push_str(" — ");
493 m.push_str(&buf.line_count().to_string());
494 m.push_str(" line(s)");
495 if buf.is_modified() {
496 m.push_str(" [modified]");
497 }
498 Outcome::did(vec![Negai::Message(m)])
499 }
500}
501
502struct OpenPicker<const COMMANDS: bool>;
518impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
519 type Reads = caps!();
520 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
521 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
522 escriba_madoguchi::PickerSource::Commands
523 } else {
524 escriba_madoguchi::PickerSource::Buffers
525 })])
526 }
527}
528
529struct HelpPicker;
531impl Native for HelpPicker {
532 type Reads = caps!();
533 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
534 Outcome::did(vec![Negai::OpenPicker(
535 escriba_madoguchi::PickerSource::Help,
536 )])
537 }
538}
539
540struct GrepPicker;
546impl Native for GrepPicker {
547 type Reads = caps!();
548 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
549 let pattern = args.join(" ");
550 if pattern.is_empty() {
551 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
552 }
553 Outcome::did(vec![Negai::GrepProject { pattern }])
554 }
555}
556
557struct WalkPicker<const PROJECT: bool>;
559impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
560 type Reads = caps!();
561 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
562 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
563 escriba_madoguchi::PickerSource::Project
564 } else {
565 escriba_madoguchi::PickerSource::Files
566 })])
567 }
568}
569
570struct SplitWindow<const STACKED: bool>;
577impl<const STACKED: bool> Native for SplitWindow<STACKED> {
578 type Reads = caps!();
579 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
580 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
581 }
582}
583
584struct CloseWindow;
586impl Native for CloseWindow {
587 type Reads = caps!();
588 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
589 Outcome::did(vec![Negai::CloseWindow])
590 }
591}
592
593struct FocusDir<const DX: i8, const DY: i8>;
599impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
600 type Reads = caps!();
601 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
602 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
603 }
604}
605
606struct BufferNext;
607impl Native for BufferNext {
608 type Reads = caps!();
609 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
610 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
611 }
612}
613
614struct BufferPrev;
615impl Native for BufferPrev {
616 type Reads = caps!();
617 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
618 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
619 }
620}
621
622struct BufferDelete;
627impl Native for BufferDelete {
628 type Reads = caps!(Buffers);
629 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
630 match active_or_decline(&v.buffers()) {
631 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
632 Err(o) => o,
633 }
634 }
635}
636
637struct CommentToggle;
644impl Native for CommentToggle {
645 type Reads = caps!(Buffers, Cursor, Syntax);
646 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
647 let Some(ft) = v.syntax().filetype() else {
648 return Outcome::declined("no filetype for this buffer");
649 };
650 let Some(comment) = ft.comment.as_ref() else {
651 let mut m = String::from("no comment syntax for ");
652 m.push_str(&ft.name);
653 return Outcome::declined(m);
654 };
655 let b = v.buffers();
656 let Some(buf) = b.active() else {
657 return Outcome::declined("no active buffer");
658 };
659 let line_no = v.cursor().position().line;
660 let Some(line) = buf.line(line_no) else {
661 return Outcome::declined("cursor past the end of the buffer");
662 };
663 if line.trim().is_empty() {
666 return Outcome::declined("nothing on this line");
667 }
668
669 let indent_len = line.len() - line.trim_start().len();
672 let (indent, body) = line.split_at(indent_len);
673 let toggled = match comment.strip(body) {
674 Some(uncommented) => uncommented.to_string(),
675 None => comment.wrap(body),
676 };
677 let mut text = String::with_capacity(indent.len() + toggled.len());
678 text.push_str(indent);
679 text.push_str(&toggled);
680
681 Outcome::did(vec![Negai::Edit {
682 buffer: buf.id(),
683 edit: escriba_core::Edit {
684 range: escriba_core::Range::new(
685 escriba_core::Position::new(line_no, 0),
686 escriba_core::Position::new(
687 line_no,
688 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
689 ),
690 ),
691 kind: escriba_core::EditKind::Replace { text },
692 },
693 }])
694 }
695}
696
697struct TodoWalk<const FORWARD: bool>;
709impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
710 type Reads = caps!(Buffers);
711 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
712 let b = v.buffers();
713 let Some(buf) = b.active() else {
714 return Outcome::declined("no active buffer");
715 };
716 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
717 if findings.is_empty() {
718 return Outcome::declined("no TODO markers in this buffer");
719 }
720 Outcome::did(vec![
721 Negai::PublishFindings {
722 list: "todo".to_string(),
723 findings,
724 },
725 Negai::WalkList {
726 list: "todo".to_string(),
727 forward: FORWARD,
728 },
729 ])
730 }
731}
732
733struct Quit;
734impl Native for Quit {
735 type Reads = caps!();
736 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
737 Outcome::did(vec![Negai::Quit])
738 }
739}
740
741struct Noh;
749impl Native for Noh {
750 type Reads = caps!();
751 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
752 Outcome::did(vec![Negai::ClearSearchHighlight])
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use escriba_core::BufferId;
760 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
761
762 fn dirty_file() -> FakeSnapshot {
764 let mut s = FakeSnapshot::default();
765 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
766 s.active = Some(BufferId(1));
767 s
768 }
769
770 #[test]
771 fn default_set_is_populated() {
772 let r = CommandRegistry::default_set();
773 let names = r.names();
774 assert!(names.contains(&"save"));
775 assert!(names.contains(&"quit"));
776 }
777
778 #[test]
779 fn specs_are_sorted() {
780 let r = CommandRegistry::default_set();
781 let specs = r.specs();
782 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
783 }
784
785 #[test]
786 fn not_found_errors() {
787 let r = CommandRegistry::new();
790 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
791 assert!(matches!(err, CommandError::NotFound(_)));
792 }
793
794 #[test]
795 fn a_command_asks_rather_than_acts() {
796 let mut r = CommandRegistry::new();
803 r.register(Command::action(
804 "w-all",
805 "Write every modified buffer",
806 "buffer.write-all",
807 ));
808 let out = r
809 .run("w-all", &dirty_file(), &[])
810 .expect("registered command dispatches");
811 assert_eq!(
812 out.slips,
813 vec![Negai::Save {
814 buffer: BufferId(1)
815 }]
816 );
817 assert_eq!(out.verdict, Verdict::Did);
818 }
819
820 #[test]
821 fn nothing_to_save_declines_rather_than_claiming_success() {
822 let mut r = CommandRegistry::new();
826 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
827 let out = r
828 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
829 .expect("dispatches");
830 assert!(out.slips.is_empty());
831 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
832 }
833
834 #[test]
835 fn no_active_buffer_declines_rather_than_failing() {
836 let mut r = CommandRegistry::new();
839 r.register(Command::action("w", "Save", "buffer.save"));
840 let out = r
841 .run("w", &FakeSnapshot::default(), &[])
842 .expect("dispatches");
843 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
844 assert!(out.slips.is_empty(), "a decline asks for nothing");
845 }
846
847 #[test]
848 fn unknown_action_symbol_is_reported_not_silent() {
849 let mut r = CommandRegistry::new();
858 r.register(Command::action("pick", "Pick a file", "picker.files"));
859 let err = r
860 .run("pick", &FakeSnapshot::default(), &[])
861 .expect_err("an unimplemented action must report, not report success");
862 assert!(
863 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
864 "expected Unhandled(picker.files), got {err:?}",
865 );
866 assert!(r.contains("pick"), "the command survives its own failure");
867 }
868
869 #[test]
870 fn action_naming_a_command_is_inert_not_recursive() {
871 let mut r = CommandRegistry::new();
879 r.register(Command::action("alias", "aliases save by name", "save"));
880 let err = r
881 .run("alias", &dirty_file(), &[])
882 .expect_err("a command-name alias resolves nothing, and says so");
883 assert!(
884 matches!(&err, CommandError::Unhandled(s) if s == "save"),
885 "expected Unhandled(save), got {err:?}",
886 );
887 }
888
889 #[test]
890 fn quit_is_a_request_not_a_flag_poke() {
891 let mut r = CommandRegistry::new();
896 r.register(Command::action("bye", "Quit", "editor.quit"));
897 let out = r
898 .run("bye", &FakeSnapshot::default(), &[])
899 .expect("dispatches");
900 assert_eq!(out.slips, vec![Negai::Quit]);
901 }
902
903 #[test]
904 fn buffer_info_speaks_through_a_slip_not_stderr() {
905 let mut r = CommandRegistry::new();
909 r.register(Command::action("info", "Buffer info", "buffer.info"));
910 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
911 let Some(Negai::Message(m)) = out.slips.first() else {
912 panic!("expected a Message slip, got {:?}", out.slips);
913 };
914 assert!(m.contains("buffer 1"), "{m}");
915 assert!(m.contains("[modified]"), "{m}");
916 }
917}