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 "todo.next",
193 "Go to the next TODO/FIXME marker",
194 erase::<TodoWalk<true>>(),
195 ));
196 r.register(Command::native(
197 "todo.prev",
198 "Go to the previous TODO/FIXME marker",
199 erase::<TodoWalk<false>>(),
200 ));
201 for name in ["comment.toggle-line", "comment.toggle-block"] {
202 r.register(Command::native(
203 name,
204 "Toggle the comment on the current line",
205 erase::<CommentToggle>(),
206 ));
207 }
208 for alias in ["noh", "nohl", "nohlsearch"] {
209 r.register(Command::action(
210 alias,
211 "Stop highlighting matches, keep the pattern",
212 "search.clear-highlight",
213 ));
214 }
215 r.register(Command::native(
216 "undo",
217 "Undo the last change",
218 erase::<Undo>(),
219 ));
220 r.register(Command::native(
221 "redo",
222 "Redo the last undone change",
223 erase::<Redo>(),
224 ));
225 r.register(Command::native(
226 "buffer-info",
227 "Print the active buffer summary",
228 erase::<Info>(),
229 ));
230 r
231 }
232
233 pub fn register(&mut self, command: Command) {
234 self.commands.insert(command.name.clone(), command);
235 }
236
237 #[must_use]
240 pub fn contains(&self, name: &str) -> bool {
241 self.commands.contains_key(name)
242 }
243
244 #[must_use]
246 pub fn len(&self) -> usize {
247 self.commands.len()
248 }
249
250 #[must_use]
252 pub fn is_empty(&self) -> bool {
253 self.commands.is_empty()
254 }
255
256 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
262 self.run_bounded(name, snap, args, ALIAS_FUEL)
263 }
264
265 fn run_bounded(
278 &self,
279 name: &str,
280 snap: &dyn Snapshot,
281 args: &[String],
282 fuel: u8,
283 ) -> Result<Outcome> {
284 let Some(fuel) = fuel.checked_sub(1) else {
285 return Err(CommandError::AliasCycle(name.to_string()));
286 };
287 let cmd = self
288 .commands
289 .get(name)
290 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
291 match &cmd.handler {
292 Handler::Native(f) => Ok(f(snap, args)),
293 Handler::Action(sym) => match builtin_action(sym) {
294 Some(f) => Ok(f(snap, args)),
295 None if sym != name && self.commands.contains_key(sym.as_str()) => {
299 self.run_bounded(sym, snap, args, fuel)
300 }
301 None => Err(CommandError::Unhandled(sym.to_string())),
302 },
303 }
304 }
305
306 #[must_use]
307 pub fn names(&self) -> Vec<&str> {
308 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
309 v.sort_unstable();
310 v
311 }
312
313 #[must_use]
314 pub fn specs(&self) -> Vec<CommandSpec> {
315 let mut out: Vec<CommandSpec> = self
316 .commands
317 .values()
318 .map(|c| CommandSpec {
319 name: c.name.to_string(),
320 description: c.description.to_string(),
321 args: Vec::new(),
322 })
323 .collect();
324 out.sort_by(|a, b| a.name.cmp(&b.name));
325 out
326 }
327}
328
329const ALIAS_FUEL: u8 = 8;
334
335fn builtin_action(sym: &str) -> Option<CommandFn> {
341 Some(match sym {
342 "buffer.save" | "buffer.write" => erase::<Save>(),
343 "buffer.write-all" => erase::<WriteAll>(),
344 "buffer.undo" => erase::<Undo>(),
345 "buffer.redo" => erase::<Redo>(),
346 "buffer.info" => erase::<Info>(),
347 "editor.quit" => erase::<Quit>(),
348 "search.clear-highlight" => erase::<Noh>(),
349 _ => return None,
356 })
357}
358
359fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
364 b.active()
365 .map(BufferView::id)
366 .ok_or_else(|| Outcome::declined("no active buffer"))
367}
368
369type Result2<T> = std::result::Result<T, Outcome>;
370
371struct WriteAll;
377impl Native for WriteAll {
378 type Reads = caps!(Buffers);
379 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
380 let b = v.buffers();
381 let slips: Vec<Negai> = b
382 .ids()
383 .into_iter()
384 .filter(|id| {
385 b.get(*id)
386 .is_some_and(|x| x.is_modified() && x.path().is_some())
387 })
388 .map(|buffer| Negai::Save { buffer })
389 .collect();
390 if slips.is_empty() {
391 return Outcome::declined("no modified files");
392 }
393 Outcome::did(slips)
394 }
395}
396
397struct Save;
398impl Native for Save {
399 type Reads = caps!(Buffers);
400 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
401 match active_or_decline(&v.buffers()) {
402 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
403 Err(o) => o,
404 }
405 }
406}
407
408struct Undo;
409impl Native for Undo {
410 type Reads = caps!(Buffers);
411 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
412 match active_or_decline(&v.buffers()) {
413 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
414 Err(o) => o,
415 }
416 }
417}
418
419struct Redo;
420impl Native for Redo {
421 type Reads = caps!(Buffers);
422 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
423 match active_or_decline(&v.buffers()) {
424 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
425 Err(o) => o,
426 }
427 }
428}
429
430struct Info;
437impl Native for Info {
438 type Reads = caps!(Buffers);
439 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
440 let b = v.buffers();
441 let Some(buf) = b.active() else {
442 return Outcome::declined("no active buffer");
443 };
444 let mut m = String::with_capacity(48);
445 m.push_str("buffer ");
446 m.push_str(&buf.id().0.to_string());
447 m.push_str(" — ");
448 m.push_str(&buf.line_count().to_string());
449 m.push_str(" line(s)");
450 if buf.is_modified() {
451 m.push_str(" [modified]");
452 }
453 Outcome::did(vec![Negai::Message(m)])
454 }
455}
456
457struct OpenPicker<const COMMANDS: bool>;
473impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
474 type Reads = caps!();
475 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
476 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
477 escriba_madoguchi::PickerSource::Commands
478 } else {
479 escriba_madoguchi::PickerSource::Buffers
480 })])
481 }
482}
483
484struct HelpPicker;
486impl Native for HelpPicker {
487 type Reads = caps!();
488 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
489 Outcome::did(vec![Negai::OpenPicker(
490 escriba_madoguchi::PickerSource::Help,
491 )])
492 }
493}
494
495struct GrepPicker;
501impl Native for GrepPicker {
502 type Reads = caps!();
503 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
504 let pattern = args.join(" ");
505 if pattern.is_empty() {
506 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
507 }
508 Outcome::did(vec![Negai::GrepProject { pattern }])
509 }
510}
511
512struct BufferNext;
513impl Native for BufferNext {
514 type Reads = caps!();
515 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
516 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
517 }
518}
519
520struct BufferPrev;
521impl Native for BufferPrev {
522 type Reads = caps!();
523 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
524 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
525 }
526}
527
528struct BufferDelete;
533impl Native for BufferDelete {
534 type Reads = caps!(Buffers);
535 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
536 match active_or_decline(&v.buffers()) {
537 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
538 Err(o) => o,
539 }
540 }
541}
542
543struct CommentToggle;
550impl Native for CommentToggle {
551 type Reads = caps!(Buffers, Cursor, Syntax);
552 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
553 let Some(ft) = v.syntax().filetype() else {
554 return Outcome::declined("no filetype for this buffer");
555 };
556 let Some(comment) = ft.comment.as_ref() else {
557 let mut m = String::from("no comment syntax for ");
558 m.push_str(&ft.name);
559 return Outcome::declined(m);
560 };
561 let b = v.buffers();
562 let Some(buf) = b.active() else {
563 return Outcome::declined("no active buffer");
564 };
565 let line_no = v.cursor().position().line;
566 let Some(line) = buf.line(line_no) else {
567 return Outcome::declined("cursor past the end of the buffer");
568 };
569 if line.trim().is_empty() {
572 return Outcome::declined("nothing on this line");
573 }
574
575 let indent_len = line.len() - line.trim_start().len();
578 let (indent, body) = line.split_at(indent_len);
579 let toggled = match comment.strip(body) {
580 Some(uncommented) => uncommented.to_string(),
581 None => comment.wrap(body),
582 };
583 let mut text = String::with_capacity(indent.len() + toggled.len());
584 text.push_str(indent);
585 text.push_str(&toggled);
586
587 Outcome::did(vec![Negai::Edit {
588 buffer: buf.id(),
589 edit: escriba_core::Edit {
590 range: escriba_core::Range::new(
591 escriba_core::Position::new(line_no, 0),
592 escriba_core::Position::new(
593 line_no,
594 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
595 ),
596 ),
597 kind: escriba_core::EditKind::Replace { text },
598 },
599 }])
600 }
601}
602
603struct TodoWalk<const FORWARD: bool>;
615impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
616 type Reads = caps!(Buffers);
617 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
618 let b = v.buffers();
619 let Some(buf) = b.active() else {
620 return Outcome::declined("no active buffer");
621 };
622 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
623 if findings.is_empty() {
624 return Outcome::declined("no TODO markers in this buffer");
625 }
626 Outcome::did(vec![
627 Negai::PublishFindings {
628 list: "todo".to_string(),
629 findings,
630 },
631 Negai::WalkList {
632 list: "todo".to_string(),
633 forward: FORWARD,
634 },
635 ])
636 }
637}
638
639struct Quit;
640impl Native for Quit {
641 type Reads = caps!();
642 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
643 Outcome::did(vec![Negai::Quit])
644 }
645}
646
647struct Noh;
655impl Native for Noh {
656 type Reads = caps!();
657 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
658 Outcome::did(vec![Negai::ClearSearchHighlight])
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use escriba_core::BufferId;
666 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
667
668 fn dirty_file() -> FakeSnapshot {
670 let mut s = FakeSnapshot::default();
671 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
672 s.active = Some(BufferId(1));
673 s
674 }
675
676 #[test]
677 fn default_set_is_populated() {
678 let r = CommandRegistry::default_set();
679 let names = r.names();
680 assert!(names.contains(&"save"));
681 assert!(names.contains(&"quit"));
682 }
683
684 #[test]
685 fn specs_are_sorted() {
686 let r = CommandRegistry::default_set();
687 let specs = r.specs();
688 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
689 }
690
691 #[test]
692 fn not_found_errors() {
693 let r = CommandRegistry::new();
696 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
697 assert!(matches!(err, CommandError::NotFound(_)));
698 }
699
700 #[test]
701 fn a_command_asks_rather_than_acts() {
702 let mut r = CommandRegistry::new();
709 r.register(Command::action(
710 "w-all",
711 "Write every modified buffer",
712 "buffer.write-all",
713 ));
714 let out = r
715 .run("w-all", &dirty_file(), &[])
716 .expect("registered command dispatches");
717 assert_eq!(
718 out.slips,
719 vec![Negai::Save {
720 buffer: BufferId(1)
721 }]
722 );
723 assert_eq!(out.verdict, Verdict::Did);
724 }
725
726 #[test]
727 fn nothing_to_save_declines_rather_than_claiming_success() {
728 let mut r = CommandRegistry::new();
732 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
733 let out = r
734 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
735 .expect("dispatches");
736 assert!(out.slips.is_empty());
737 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
738 }
739
740 #[test]
741 fn no_active_buffer_declines_rather_than_failing() {
742 let mut r = CommandRegistry::new();
745 r.register(Command::action("w", "Save", "buffer.save"));
746 let out = r
747 .run("w", &FakeSnapshot::default(), &[])
748 .expect("dispatches");
749 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
750 assert!(out.slips.is_empty(), "a decline asks for nothing");
751 }
752
753 #[test]
754 fn unknown_action_symbol_is_reported_not_silent() {
755 let mut r = CommandRegistry::new();
764 r.register(Command::action("pick", "Pick a file", "picker.files"));
765 let err = r
766 .run("pick", &FakeSnapshot::default(), &[])
767 .expect_err("an unimplemented action must report, not report success");
768 assert!(
769 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
770 "expected Unhandled(picker.files), got {err:?}",
771 );
772 assert!(r.contains("pick"), "the command survives its own failure");
773 }
774
775 #[test]
776 fn action_naming_a_command_is_inert_not_recursive() {
777 let mut r = CommandRegistry::new();
785 r.register(Command::action("alias", "aliases save by name", "save"));
786 let err = r
787 .run("alias", &dirty_file(), &[])
788 .expect_err("a command-name alias resolves nothing, and says so");
789 assert!(
790 matches!(&err, CommandError::Unhandled(s) if s == "save"),
791 "expected Unhandled(save), got {err:?}",
792 );
793 }
794
795 #[test]
796 fn quit_is_a_request_not_a_flag_poke() {
797 let mut r = CommandRegistry::new();
802 r.register(Command::action("bye", "Quit", "editor.quit"));
803 let out = r
804 .run("bye", &FakeSnapshot::default(), &[])
805 .expect("dispatches");
806 assert_eq!(out.slips, vec![Negai::Quit]);
807 }
808
809 #[test]
810 fn buffer_info_speaks_through_a_slip_not_stderr() {
811 let mut r = CommandRegistry::new();
815 r.register(Command::action("info", "Buffer info", "buffer.info"));
816 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
817 let Some(Negai::Message(m)) = out.slips.first() else {
818 panic!("expected a Message slip, got {:?}", out.slips);
819 };
820 assert!(m.contains("buffer 1"), "{m}");
821 assert!(m.contains("[modified]"), "{m}");
822 }
823}