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 "todo.next",
173 "Go to the next TODO/FIXME marker",
174 erase::<TodoWalk<true>>(),
175 ));
176 r.register(Command::native(
177 "todo.prev",
178 "Go to the previous TODO/FIXME marker",
179 erase::<TodoWalk<false>>(),
180 ));
181 for name in ["comment.toggle-line", "comment.toggle-block"] {
182 r.register(Command::native(
183 name,
184 "Toggle the comment on the current line",
185 erase::<CommentToggle>(),
186 ));
187 }
188 for alias in ["noh", "nohl", "nohlsearch"] {
189 r.register(Command::action(
190 alias,
191 "Stop highlighting matches, keep the pattern",
192 "search.clear-highlight",
193 ));
194 }
195 r.register(Command::native(
196 "undo",
197 "Undo the last change",
198 erase::<Undo>(),
199 ));
200 r.register(Command::native(
201 "redo",
202 "Redo the last undone change",
203 erase::<Redo>(),
204 ));
205 r.register(Command::native(
206 "buffer-info",
207 "Print the active buffer summary",
208 erase::<Info>(),
209 ));
210 r
211 }
212
213 pub fn register(&mut self, command: Command) {
214 self.commands.insert(command.name.clone(), command);
215 }
216
217 #[must_use]
220 pub fn contains(&self, name: &str) -> bool {
221 self.commands.contains_key(name)
222 }
223
224 #[must_use]
226 pub fn len(&self) -> usize {
227 self.commands.len()
228 }
229
230 #[must_use]
232 pub fn is_empty(&self) -> bool {
233 self.commands.is_empty()
234 }
235
236 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
242 self.run_bounded(name, snap, args, ALIAS_FUEL)
243 }
244
245 fn run_bounded(
258 &self,
259 name: &str,
260 snap: &dyn Snapshot,
261 args: &[String],
262 fuel: u8,
263 ) -> Result<Outcome> {
264 let Some(fuel) = fuel.checked_sub(1) else {
265 return Err(CommandError::AliasCycle(name.to_string()));
266 };
267 let cmd = self
268 .commands
269 .get(name)
270 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
271 match &cmd.handler {
272 Handler::Native(f) => Ok(f(snap, args)),
273 Handler::Action(sym) => match builtin_action(sym) {
274 Some(f) => Ok(f(snap, args)),
275 None if sym != name && self.commands.contains_key(sym.as_str()) => {
279 self.run_bounded(sym, snap, args, fuel)
280 }
281 None => Err(CommandError::Unhandled(sym.to_string())),
282 },
283 }
284 }
285
286 #[must_use]
287 pub fn names(&self) -> Vec<&str> {
288 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
289 v.sort_unstable();
290 v
291 }
292
293 #[must_use]
294 pub fn specs(&self) -> Vec<CommandSpec> {
295 let mut out: Vec<CommandSpec> = self
296 .commands
297 .values()
298 .map(|c| CommandSpec {
299 name: c.name.to_string(),
300 description: c.description.to_string(),
301 args: Vec::new(),
302 })
303 .collect();
304 out.sort_by(|a, b| a.name.cmp(&b.name));
305 out
306 }
307}
308
309const ALIAS_FUEL: u8 = 8;
314
315fn builtin_action(sym: &str) -> Option<CommandFn> {
321 Some(match sym {
322 "buffer.save" | "buffer.write" => erase::<Save>(),
323 "buffer.write-all" => erase::<WriteAll>(),
324 "buffer.undo" => erase::<Undo>(),
325 "buffer.redo" => erase::<Redo>(),
326 "buffer.info" => erase::<Info>(),
327 "editor.quit" => erase::<Quit>(),
328 "search.clear-highlight" => erase::<Noh>(),
329 _ => return None,
336 })
337}
338
339fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
344 b.active()
345 .map(BufferView::id)
346 .ok_or_else(|| Outcome::declined("no active buffer"))
347}
348
349type Result2<T> = std::result::Result<T, Outcome>;
350
351struct WriteAll;
357impl Native for WriteAll {
358 type Reads = caps!(Buffers);
359 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
360 let b = v.buffers();
361 let slips: Vec<Negai> = b
362 .ids()
363 .into_iter()
364 .filter(|id| {
365 b.get(*id)
366 .is_some_and(|x| x.is_modified() && x.path().is_some())
367 })
368 .map(|buffer| Negai::Save { buffer })
369 .collect();
370 if slips.is_empty() {
371 return Outcome::declined("no modified files");
372 }
373 Outcome::did(slips)
374 }
375}
376
377struct Save;
378impl Native for Save {
379 type Reads = caps!(Buffers);
380 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
381 match active_or_decline(&v.buffers()) {
382 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
383 Err(o) => o,
384 }
385 }
386}
387
388struct Undo;
389impl Native for Undo {
390 type Reads = caps!(Buffers);
391 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
392 match active_or_decline(&v.buffers()) {
393 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
394 Err(o) => o,
395 }
396 }
397}
398
399struct Redo;
400impl Native for Redo {
401 type Reads = caps!(Buffers);
402 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
403 match active_or_decline(&v.buffers()) {
404 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
405 Err(o) => o,
406 }
407 }
408}
409
410struct Info;
417impl Native for Info {
418 type Reads = caps!(Buffers);
419 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
420 let b = v.buffers();
421 let Some(buf) = b.active() else {
422 return Outcome::declined("no active buffer");
423 };
424 let mut m = String::with_capacity(48);
425 m.push_str("buffer ");
426 m.push_str(&buf.id().0.to_string());
427 m.push_str(" — ");
428 m.push_str(&buf.line_count().to_string());
429 m.push_str(" line(s)");
430 if buf.is_modified() {
431 m.push_str(" [modified]");
432 }
433 Outcome::did(vec![Negai::Message(m)])
434 }
435}
436
437struct BufferNext;
446impl Native for BufferNext {
447 type Reads = caps!();
448 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
449 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
450 }
451}
452
453struct BufferPrev;
454impl Native for BufferPrev {
455 type Reads = caps!();
456 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
457 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
458 }
459}
460
461struct BufferDelete;
466impl Native for BufferDelete {
467 type Reads = caps!(Buffers);
468 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
469 match active_or_decline(&v.buffers()) {
470 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
471 Err(o) => o,
472 }
473 }
474}
475
476struct CommentToggle;
483impl Native for CommentToggle {
484 type Reads = caps!(Buffers, Cursor, Syntax);
485 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
486 let Some(ft) = v.syntax().filetype() else {
487 return Outcome::declined("no filetype for this buffer");
488 };
489 let Some(comment) = ft.comment.as_ref() else {
490 let mut m = String::from("no comment syntax for ");
491 m.push_str(&ft.name);
492 return Outcome::declined(m);
493 };
494 let b = v.buffers();
495 let Some(buf) = b.active() else {
496 return Outcome::declined("no active buffer");
497 };
498 let line_no = v.cursor().position().line;
499 let Some(line) = buf.line(line_no) else {
500 return Outcome::declined("cursor past the end of the buffer");
501 };
502 if line.trim().is_empty() {
505 return Outcome::declined("nothing on this line");
506 }
507
508 let indent_len = line.len() - line.trim_start().len();
511 let (indent, body) = line.split_at(indent_len);
512 let toggled = match comment.strip(body) {
513 Some(uncommented) => uncommented.to_string(),
514 None => comment.wrap(body),
515 };
516 let mut text = String::with_capacity(indent.len() + toggled.len());
517 text.push_str(indent);
518 text.push_str(&toggled);
519
520 Outcome::did(vec![Negai::Edit {
521 buffer: buf.id(),
522 edit: escriba_core::Edit {
523 range: escriba_core::Range::new(
524 escriba_core::Position::new(line_no, 0),
525 escriba_core::Position::new(
526 line_no,
527 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
528 ),
529 ),
530 kind: escriba_core::EditKind::Replace { text },
531 },
532 }])
533 }
534}
535
536struct TodoWalk<const FORWARD: bool>;
548impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
549 type Reads = caps!(Buffers);
550 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
551 let b = v.buffers();
552 let Some(buf) = b.active() else {
553 return Outcome::declined("no active buffer");
554 };
555 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
556 if findings.is_empty() {
557 return Outcome::declined("no TODO markers in this buffer");
558 }
559 Outcome::did(vec![
560 Negai::PublishFindings {
561 list: "todo".to_string(),
562 findings,
563 },
564 Negai::WalkList {
565 list: "todo".to_string(),
566 forward: FORWARD,
567 },
568 ])
569 }
570}
571
572struct Quit;
573impl Native for Quit {
574 type Reads = caps!();
575 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
576 Outcome::did(vec![Negai::Quit])
577 }
578}
579
580struct Noh;
588impl Native for Noh {
589 type Reads = caps!();
590 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
591 Outcome::did(vec![Negai::ClearSearchHighlight])
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598 use escriba_core::BufferId;
599 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
600
601 fn dirty_file() -> FakeSnapshot {
603 let mut s = FakeSnapshot::default();
604 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
605 s.active = Some(BufferId(1));
606 s
607 }
608
609 #[test]
610 fn default_set_is_populated() {
611 let r = CommandRegistry::default_set();
612 let names = r.names();
613 assert!(names.contains(&"save"));
614 assert!(names.contains(&"quit"));
615 }
616
617 #[test]
618 fn specs_are_sorted() {
619 let r = CommandRegistry::default_set();
620 let specs = r.specs();
621 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
622 }
623
624 #[test]
625 fn not_found_errors() {
626 let r = CommandRegistry::new();
629 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
630 assert!(matches!(err, CommandError::NotFound(_)));
631 }
632
633 #[test]
634 fn a_command_asks_rather_than_acts() {
635 let mut r = CommandRegistry::new();
642 r.register(Command::action(
643 "w-all",
644 "Write every modified buffer",
645 "buffer.write-all",
646 ));
647 let out = r
648 .run("w-all", &dirty_file(), &[])
649 .expect("registered command dispatches");
650 assert_eq!(
651 out.slips,
652 vec![Negai::Save {
653 buffer: BufferId(1)
654 }]
655 );
656 assert_eq!(out.verdict, Verdict::Did);
657 }
658
659 #[test]
660 fn nothing_to_save_declines_rather_than_claiming_success() {
661 let mut r = CommandRegistry::new();
665 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
666 let out = r
667 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
668 .expect("dispatches");
669 assert!(out.slips.is_empty());
670 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
671 }
672
673 #[test]
674 fn no_active_buffer_declines_rather_than_failing() {
675 let mut r = CommandRegistry::new();
678 r.register(Command::action("w", "Save", "buffer.save"));
679 let out = r
680 .run("w", &FakeSnapshot::default(), &[])
681 .expect("dispatches");
682 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
683 assert!(out.slips.is_empty(), "a decline asks for nothing");
684 }
685
686 #[test]
687 fn unknown_action_symbol_is_reported_not_silent() {
688 let mut r = CommandRegistry::new();
697 r.register(Command::action("pick", "Pick a file", "picker.files"));
698 let err = r
699 .run("pick", &FakeSnapshot::default(), &[])
700 .expect_err("an unimplemented action must report, not report success");
701 assert!(
702 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
703 "expected Unhandled(picker.files), got {err:?}",
704 );
705 assert!(r.contains("pick"), "the command survives its own failure");
706 }
707
708 #[test]
709 fn action_naming_a_command_is_inert_not_recursive() {
710 let mut r = CommandRegistry::new();
718 r.register(Command::action("alias", "aliases save by name", "save"));
719 let err = r
720 .run("alias", &dirty_file(), &[])
721 .expect_err("a command-name alias resolves nothing, and says so");
722 assert!(
723 matches!(&err, CommandError::Unhandled(s) if s == "save"),
724 "expected Unhandled(save), got {err:?}",
725 );
726 }
727
728 #[test]
729 fn quit_is_a_request_not_a_flag_poke() {
730 let mut r = CommandRegistry::new();
735 r.register(Command::action("bye", "Quit", "editor.quit"));
736 let out = r
737 .run("bye", &FakeSnapshot::default(), &[])
738 .expect("dispatches");
739 assert_eq!(out.slips, vec![Negai::Quit]);
740 }
741
742 #[test]
743 fn buffer_info_speaks_through_a_slip_not_stderr() {
744 let mut r = CommandRegistry::new();
748 r.register(Command::action("info", "Buffer info", "buffer.info"));
749 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
750 let Some(Negai::Message(m)) = out.slips.first() else {
751 panic!("expected a Message slip, got {:?}", out.slips);
752 };
753 assert!(m.contains("buffer 1"), "{m}");
754 assert!(m.contains("[modified]"), "{m}");
755 }
756}