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 "todo.next",
203 "Go to the next TODO/FIXME marker",
204 erase::<TodoWalk<true>>(),
205 ));
206 r.register(Command::native(
207 "todo.prev",
208 "Go to the previous TODO/FIXME marker",
209 erase::<TodoWalk<false>>(),
210 ));
211 for name in ["comment.toggle-line", "comment.toggle-block"] {
212 r.register(Command::native(
213 name,
214 "Toggle the comment on the current line",
215 erase::<CommentToggle>(),
216 ));
217 }
218 for alias in ["noh", "nohl", "nohlsearch"] {
219 r.register(Command::action(
220 alias,
221 "Stop highlighting matches, keep the pattern",
222 "search.clear-highlight",
223 ));
224 }
225 r.register(Command::native(
226 "undo",
227 "Undo the last change",
228 erase::<Undo>(),
229 ));
230 r.register(Command::native(
231 "redo",
232 "Redo the last undone change",
233 erase::<Redo>(),
234 ));
235 r.register(Command::native(
236 "buffer-info",
237 "Print the active buffer summary",
238 erase::<Info>(),
239 ));
240 r
241 }
242
243 pub fn register(&mut self, command: Command) {
244 self.commands.insert(command.name.clone(), command);
245 }
246
247 #[must_use]
250 pub fn contains(&self, name: &str) -> bool {
251 self.commands.contains_key(name)
252 }
253
254 #[must_use]
256 pub fn len(&self) -> usize {
257 self.commands.len()
258 }
259
260 #[must_use]
262 pub fn is_empty(&self) -> bool {
263 self.commands.is_empty()
264 }
265
266 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
272 self.run_bounded(name, snap, args, ALIAS_FUEL)
273 }
274
275 fn run_bounded(
288 &self,
289 name: &str,
290 snap: &dyn Snapshot,
291 args: &[String],
292 fuel: u8,
293 ) -> Result<Outcome> {
294 let Some(fuel) = fuel.checked_sub(1) else {
295 return Err(CommandError::AliasCycle(name.to_string()));
296 };
297 let cmd = self
298 .commands
299 .get(name)
300 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
301 match &cmd.handler {
302 Handler::Native(f) => Ok(f(snap, args)),
303 Handler::Action(sym) => match builtin_action(sym) {
304 Some(f) => Ok(f(snap, args)),
305 None if sym != name && self.commands.contains_key(sym.as_str()) => {
309 self.run_bounded(sym, snap, args, fuel)
310 }
311 None => Err(CommandError::Unhandled(sym.to_string())),
312 },
313 }
314 }
315
316 #[must_use]
317 pub fn names(&self) -> Vec<&str> {
318 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
319 v.sort_unstable();
320 v
321 }
322
323 #[must_use]
324 pub fn specs(&self) -> Vec<CommandSpec> {
325 let mut out: Vec<CommandSpec> = self
326 .commands
327 .values()
328 .map(|c| CommandSpec {
329 name: c.name.to_string(),
330 description: c.description.to_string(),
331 args: Vec::new(),
332 })
333 .collect();
334 out.sort_by(|a, b| a.name.cmp(&b.name));
335 out
336 }
337}
338
339const ALIAS_FUEL: u8 = 8;
344
345fn builtin_action(sym: &str) -> Option<CommandFn> {
351 Some(match sym {
352 "buffer.save" | "buffer.write" => erase::<Save>(),
353 "buffer.write-all" => erase::<WriteAll>(),
354 "buffer.undo" => erase::<Undo>(),
355 "buffer.redo" => erase::<Redo>(),
356 "buffer.info" => erase::<Info>(),
357 "editor.quit" => erase::<Quit>(),
358 "search.clear-highlight" => erase::<Noh>(),
359 _ => return None,
366 })
367}
368
369fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
374 b.active()
375 .map(BufferView::id)
376 .ok_or_else(|| Outcome::declined("no active buffer"))
377}
378
379type Result2<T> = std::result::Result<T, Outcome>;
380
381struct WriteAll;
387impl Native for WriteAll {
388 type Reads = caps!(Buffers);
389 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
390 let b = v.buffers();
391 let slips: Vec<Negai> = b
392 .ids()
393 .into_iter()
394 .filter(|id| {
395 b.get(*id)
396 .is_some_and(|x| x.is_modified() && x.path().is_some())
397 })
398 .map(|buffer| Negai::Save { buffer })
399 .collect();
400 if slips.is_empty() {
401 return Outcome::declined("no modified files");
402 }
403 Outcome::did(slips)
404 }
405}
406
407struct Save;
408impl Native for Save {
409 type Reads = caps!(Buffers);
410 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
411 match active_or_decline(&v.buffers()) {
412 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
413 Err(o) => o,
414 }
415 }
416}
417
418struct Undo;
419impl Native for Undo {
420 type Reads = caps!(Buffers);
421 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
422 match active_or_decline(&v.buffers()) {
423 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
424 Err(o) => o,
425 }
426 }
427}
428
429struct Redo;
430impl Native for Redo {
431 type Reads = caps!(Buffers);
432 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
433 match active_or_decline(&v.buffers()) {
434 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
435 Err(o) => o,
436 }
437 }
438}
439
440struct Info;
447impl Native for Info {
448 type Reads = caps!(Buffers);
449 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
450 let b = v.buffers();
451 let Some(buf) = b.active() else {
452 return Outcome::declined("no active buffer");
453 };
454 let mut m = String::with_capacity(48);
455 m.push_str("buffer ");
456 m.push_str(&buf.id().0.to_string());
457 m.push_str(" — ");
458 m.push_str(&buf.line_count().to_string());
459 m.push_str(" line(s)");
460 if buf.is_modified() {
461 m.push_str(" [modified]");
462 }
463 Outcome::did(vec![Negai::Message(m)])
464 }
465}
466
467struct OpenPicker<const COMMANDS: bool>;
483impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
484 type Reads = caps!();
485 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
486 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
487 escriba_madoguchi::PickerSource::Commands
488 } else {
489 escriba_madoguchi::PickerSource::Buffers
490 })])
491 }
492}
493
494struct HelpPicker;
496impl Native for HelpPicker {
497 type Reads = caps!();
498 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
499 Outcome::did(vec![Negai::OpenPicker(
500 escriba_madoguchi::PickerSource::Help,
501 )])
502 }
503}
504
505struct GrepPicker;
511impl Native for GrepPicker {
512 type Reads = caps!();
513 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
514 let pattern = args.join(" ");
515 if pattern.is_empty() {
516 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
517 }
518 Outcome::did(vec![Negai::GrepProject { pattern }])
519 }
520}
521
522struct WalkPicker<const PROJECT: bool>;
524impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
525 type Reads = caps!();
526 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
527 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
528 escriba_madoguchi::PickerSource::Project
529 } else {
530 escriba_madoguchi::PickerSource::Files
531 })])
532 }
533}
534
535struct BufferNext;
536impl Native for BufferNext {
537 type Reads = caps!();
538 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
539 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
540 }
541}
542
543struct BufferPrev;
544impl Native for BufferPrev {
545 type Reads = caps!();
546 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
547 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
548 }
549}
550
551struct BufferDelete;
556impl Native for BufferDelete {
557 type Reads = caps!(Buffers);
558 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
559 match active_or_decline(&v.buffers()) {
560 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
561 Err(o) => o,
562 }
563 }
564}
565
566struct CommentToggle;
573impl Native for CommentToggle {
574 type Reads = caps!(Buffers, Cursor, Syntax);
575 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
576 let Some(ft) = v.syntax().filetype() else {
577 return Outcome::declined("no filetype for this buffer");
578 };
579 let Some(comment) = ft.comment.as_ref() else {
580 let mut m = String::from("no comment syntax for ");
581 m.push_str(&ft.name);
582 return Outcome::declined(m);
583 };
584 let b = v.buffers();
585 let Some(buf) = b.active() else {
586 return Outcome::declined("no active buffer");
587 };
588 let line_no = v.cursor().position().line;
589 let Some(line) = buf.line(line_no) else {
590 return Outcome::declined("cursor past the end of the buffer");
591 };
592 if line.trim().is_empty() {
595 return Outcome::declined("nothing on this line");
596 }
597
598 let indent_len = line.len() - line.trim_start().len();
601 let (indent, body) = line.split_at(indent_len);
602 let toggled = match comment.strip(body) {
603 Some(uncommented) => uncommented.to_string(),
604 None => comment.wrap(body),
605 };
606 let mut text = String::with_capacity(indent.len() + toggled.len());
607 text.push_str(indent);
608 text.push_str(&toggled);
609
610 Outcome::did(vec![Negai::Edit {
611 buffer: buf.id(),
612 edit: escriba_core::Edit {
613 range: escriba_core::Range::new(
614 escriba_core::Position::new(line_no, 0),
615 escriba_core::Position::new(
616 line_no,
617 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
618 ),
619 ),
620 kind: escriba_core::EditKind::Replace { text },
621 },
622 }])
623 }
624}
625
626struct TodoWalk<const FORWARD: bool>;
638impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
639 type Reads = caps!(Buffers);
640 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
641 let b = v.buffers();
642 let Some(buf) = b.active() else {
643 return Outcome::declined("no active buffer");
644 };
645 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
646 if findings.is_empty() {
647 return Outcome::declined("no TODO markers in this buffer");
648 }
649 Outcome::did(vec![
650 Negai::PublishFindings {
651 list: "todo".to_string(),
652 findings,
653 },
654 Negai::WalkList {
655 list: "todo".to_string(),
656 forward: FORWARD,
657 },
658 ])
659 }
660}
661
662struct Quit;
663impl Native for Quit {
664 type Reads = caps!();
665 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
666 Outcome::did(vec![Negai::Quit])
667 }
668}
669
670struct Noh;
678impl Native for Noh {
679 type Reads = caps!();
680 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
681 Outcome::did(vec![Negai::ClearSearchHighlight])
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use escriba_core::BufferId;
689 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
690
691 fn dirty_file() -> FakeSnapshot {
693 let mut s = FakeSnapshot::default();
694 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
695 s.active = Some(BufferId(1));
696 s
697 }
698
699 #[test]
700 fn default_set_is_populated() {
701 let r = CommandRegistry::default_set();
702 let names = r.names();
703 assert!(names.contains(&"save"));
704 assert!(names.contains(&"quit"));
705 }
706
707 #[test]
708 fn specs_are_sorted() {
709 let r = CommandRegistry::default_set();
710 let specs = r.specs();
711 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
712 }
713
714 #[test]
715 fn not_found_errors() {
716 let r = CommandRegistry::new();
719 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
720 assert!(matches!(err, CommandError::NotFound(_)));
721 }
722
723 #[test]
724 fn a_command_asks_rather_than_acts() {
725 let mut r = CommandRegistry::new();
732 r.register(Command::action(
733 "w-all",
734 "Write every modified buffer",
735 "buffer.write-all",
736 ));
737 let out = r
738 .run("w-all", &dirty_file(), &[])
739 .expect("registered command dispatches");
740 assert_eq!(
741 out.slips,
742 vec![Negai::Save {
743 buffer: BufferId(1)
744 }]
745 );
746 assert_eq!(out.verdict, Verdict::Did);
747 }
748
749 #[test]
750 fn nothing_to_save_declines_rather_than_claiming_success() {
751 let mut r = CommandRegistry::new();
755 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
756 let out = r
757 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
758 .expect("dispatches");
759 assert!(out.slips.is_empty());
760 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
761 }
762
763 #[test]
764 fn no_active_buffer_declines_rather_than_failing() {
765 let mut r = CommandRegistry::new();
768 r.register(Command::action("w", "Save", "buffer.save"));
769 let out = r
770 .run("w", &FakeSnapshot::default(), &[])
771 .expect("dispatches");
772 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
773 assert!(out.slips.is_empty(), "a decline asks for nothing");
774 }
775
776 #[test]
777 fn unknown_action_symbol_is_reported_not_silent() {
778 let mut r = CommandRegistry::new();
787 r.register(Command::action("pick", "Pick a file", "picker.files"));
788 let err = r
789 .run("pick", &FakeSnapshot::default(), &[])
790 .expect_err("an unimplemented action must report, not report success");
791 assert!(
792 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
793 "expected Unhandled(picker.files), got {err:?}",
794 );
795 assert!(r.contains("pick"), "the command survives its own failure");
796 }
797
798 #[test]
799 fn action_naming_a_command_is_inert_not_recursive() {
800 let mut r = CommandRegistry::new();
808 r.register(Command::action("alias", "aliases save by name", "save"));
809 let err = r
810 .run("alias", &dirty_file(), &[])
811 .expect_err("a command-name alias resolves nothing, and says so");
812 assert!(
813 matches!(&err, CommandError::Unhandled(s) if s == "save"),
814 "expected Unhandled(save), got {err:?}",
815 );
816 }
817
818 #[test]
819 fn quit_is_a_request_not_a_flag_poke() {
820 let mut r = CommandRegistry::new();
825 r.register(Command::action("bye", "Quit", "editor.quit"));
826 let out = r
827 .run("bye", &FakeSnapshot::default(), &[])
828 .expect("dispatches");
829 assert_eq!(out.slips, vec![Negai::Quit]);
830 }
831
832 #[test]
833 fn buffer_info_speaks_through_a_slip_not_stderr() {
834 let mut r = CommandRegistry::new();
838 r.register(Command::action("info", "Buffer info", "buffer.info"));
839 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
840 let Some(Negai::Message(m)) = out.slips.first() else {
841 panic!("expected a Message slip, got {:?}", out.slips);
842 };
843 assert!(m.contains("buffer 1"), "{m}");
844 assert!(m.contains("[modified]"), "{m}");
845 }
846}