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 "conflict.next",
238 "Go to the next merge conflict",
239 erase::<ConflictWalk<true>>(),
240 ));
241 r.register(Command::native(
242 "conflict.prev",
243 "Go to the previous merge conflict",
244 erase::<ConflictWalk<false>>(),
245 ));
246 r.register(Command::native(
247 "conflict.choose-ours",
248 "Resolve the conflict keeping ours",
249 erase::<ChooseSide<0>>(),
250 ));
251 r.register(Command::native(
252 "conflict.choose-theirs",
253 "Resolve the conflict keeping theirs",
254 erase::<ChooseSide<1>>(),
255 ));
256 r.register(Command::native(
257 "conflict.choose-both",
258 "Resolve the conflict keeping both",
259 erase::<ChooseSide<2>>(),
260 ));
261 r.register(Command::native(
262 "todo.next",
263 "Go to the next TODO/FIXME marker",
264 erase::<TodoWalk<true>>(),
265 ));
266 r.register(Command::native(
267 "todo.prev",
268 "Go to the previous TODO/FIXME marker",
269 erase::<TodoWalk<false>>(),
270 ));
271 for name in ["comment.toggle-line", "comment.toggle-block"] {
272 r.register(Command::native(
273 name,
274 "Toggle the comment on the current line",
275 erase::<CommentToggle>(),
276 ));
277 }
278 for alias in ["noh", "nohl", "nohlsearch"] {
279 r.register(Command::action(
280 alias,
281 "Stop highlighting matches, keep the pattern",
282 "search.clear-highlight",
283 ));
284 }
285 r.register(Command::native(
286 "undo",
287 "Undo the last change",
288 erase::<Undo>(),
289 ));
290 r.register(Command::native(
291 "redo",
292 "Redo the last undone change",
293 erase::<Redo>(),
294 ));
295 r.register(Command::native(
296 "buffer-info",
297 "Print the active buffer summary",
298 erase::<Info>(),
299 ));
300 r
301 }
302
303 pub fn register(&mut self, command: Command) {
304 self.commands.insert(command.name.clone(), command);
305 }
306
307 #[must_use]
310 pub fn contains(&self, name: &str) -> bool {
311 self.commands.contains_key(name)
312 }
313
314 #[must_use]
316 pub fn len(&self) -> usize {
317 self.commands.len()
318 }
319
320 #[must_use]
322 pub fn is_empty(&self) -> bool {
323 self.commands.is_empty()
324 }
325
326 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
332 self.run_bounded(name, snap, args, ALIAS_FUEL)
333 }
334
335 fn run_bounded(
348 &self,
349 name: &str,
350 snap: &dyn Snapshot,
351 args: &[String],
352 fuel: u8,
353 ) -> Result<Outcome> {
354 let Some(fuel) = fuel.checked_sub(1) else {
355 return Err(CommandError::AliasCycle(name.to_string()));
356 };
357 let cmd = self
358 .commands
359 .get(name)
360 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
361 match &cmd.handler {
362 Handler::Native(f) => Ok(f(snap, args)),
363 Handler::Action(sym) => match builtin_action(sym) {
364 Some(f) => Ok(f(snap, args)),
365 None if sym != name && self.commands.contains_key(sym.as_str()) => {
369 self.run_bounded(sym, snap, args, fuel)
370 }
371 None => Err(CommandError::Unhandled(sym.to_string())),
372 },
373 }
374 }
375
376 #[must_use]
377 pub fn names(&self) -> Vec<&str> {
378 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
379 v.sort_unstable();
380 v
381 }
382
383 #[must_use]
384 pub fn specs(&self) -> Vec<CommandSpec> {
385 let mut out: Vec<CommandSpec> = self
386 .commands
387 .values()
388 .map(|c| CommandSpec {
389 name: c.name.to_string(),
390 description: c.description.to_string(),
391 args: Vec::new(),
392 })
393 .collect();
394 out.sort_by(|a, b| a.name.cmp(&b.name));
395 out
396 }
397}
398
399const ALIAS_FUEL: u8 = 8;
404
405fn builtin_action(sym: &str) -> Option<CommandFn> {
411 Some(match sym {
412 "buffer.save" | "buffer.write" => erase::<Save>(),
413 "buffer.write-all" => erase::<WriteAll>(),
414 "buffer.undo" => erase::<Undo>(),
415 "buffer.redo" => erase::<Redo>(),
416 "buffer.info" => erase::<Info>(),
417 "editor.quit" => erase::<Quit>(),
418 "search.clear-highlight" => erase::<Noh>(),
419 _ => return None,
426 })
427}
428
429fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
434 b.active()
435 .map(BufferView::id)
436 .ok_or_else(|| Outcome::declined("no active buffer"))
437}
438
439type Result2<T> = std::result::Result<T, Outcome>;
440
441struct WriteAll;
447impl Native for WriteAll {
448 type Reads = caps!(Buffers);
449 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
450 let b = v.buffers();
451 let slips: Vec<Negai> = b
452 .ids()
453 .into_iter()
454 .filter(|id| {
455 b.get(*id)
456 .is_some_and(|x| x.is_modified() && x.path().is_some())
457 })
458 .map(|buffer| Negai::Save { buffer })
459 .collect();
460 if slips.is_empty() {
461 return Outcome::declined("no modified files");
462 }
463 Outcome::did(slips)
464 }
465}
466
467struct Save;
468impl Native for Save {
469 type Reads = caps!(Buffers);
470 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
471 match active_or_decline(&v.buffers()) {
472 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
473 Err(o) => o,
474 }
475 }
476}
477
478struct Undo;
479impl Native for Undo {
480 type Reads = caps!(Buffers);
481 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
482 match active_or_decline(&v.buffers()) {
483 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
484 Err(o) => o,
485 }
486 }
487}
488
489struct Redo;
490impl Native for Redo {
491 type Reads = caps!(Buffers);
492 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
493 match active_or_decline(&v.buffers()) {
494 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
495 Err(o) => o,
496 }
497 }
498}
499
500struct Info;
507impl Native for Info {
508 type Reads = caps!(Buffers);
509 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
510 let b = v.buffers();
511 let Some(buf) = b.active() else {
512 return Outcome::declined("no active buffer");
513 };
514 let mut m = String::with_capacity(48);
515 m.push_str("buffer ");
516 m.push_str(&buf.id().0.to_string());
517 m.push_str(" — ");
518 m.push_str(&buf.line_count().to_string());
519 m.push_str(" line(s)");
520 if buf.is_modified() {
521 m.push_str(" [modified]");
522 }
523 Outcome::did(vec![Negai::Message(m)])
524 }
525}
526
527struct OpenPicker<const COMMANDS: bool>;
543impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
544 type Reads = caps!();
545 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
546 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
547 escriba_madoguchi::PickerSource::Commands
548 } else {
549 escriba_madoguchi::PickerSource::Buffers
550 })])
551 }
552}
553
554struct HelpPicker;
556impl Native for HelpPicker {
557 type Reads = caps!();
558 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
559 Outcome::did(vec![Negai::OpenPicker(
560 escriba_madoguchi::PickerSource::Help,
561 )])
562 }
563}
564
565struct GrepPicker;
571impl Native for GrepPicker {
572 type Reads = caps!();
573 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
574 let pattern = args.join(" ");
575 if pattern.is_empty() {
576 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
577 }
578 Outcome::did(vec![Negai::GrepProject { pattern }])
579 }
580}
581
582struct WalkPicker<const PROJECT: bool>;
584impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
585 type Reads = caps!();
586 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
587 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
588 escriba_madoguchi::PickerSource::Project
589 } else {
590 escriba_madoguchi::PickerSource::Files
591 })])
592 }
593}
594
595struct SplitWindow<const STACKED: bool>;
602impl<const STACKED: bool> Native for SplitWindow<STACKED> {
603 type Reads = caps!();
604 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
605 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
606 }
607}
608
609struct CloseWindow;
611impl Native for CloseWindow {
612 type Reads = caps!();
613 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
614 Outcome::did(vec![Negai::CloseWindow])
615 }
616}
617
618struct FocusDir<const DX: i8, const DY: i8>;
624impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
625 type Reads = caps!();
626 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
627 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
628 }
629}
630
631struct ConflictWalk<const FORWARD: bool>;
637impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
638 type Reads = caps!(Buffers);
639 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
640 let b = v.buffers();
641 let Some(buf) = b.active() else {
642 return Outcome::declined("no active buffer");
643 };
644 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
645 if findings.is_empty() {
646 return Outcome::declined("no merge conflicts in this buffer");
647 }
648 Outcome::did(vec![
649 Negai::PublishFindings {
650 list: "conflict".to_string(),
651 findings,
652 },
653 Negai::WalkList {
654 list: "conflict".to_string(),
655 forward: FORWARD,
656 },
657 ])
658 }
659}
660
661struct ChooseSide<const SIDE: u8>;
667impl<const SIDE: u8> Native for ChooseSide<SIDE> {
668 type Reads = caps!(Buffers, Cursor);
669 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
670 use escriba_shirube::conflict::{Side, at, resolution};
671 let b = v.buffers();
672 let Some(buf) = b.active() else {
673 return Outcome::declined("no active buffer");
674 };
675 let text = buf.text();
676 let line = v.cursor().position().line;
677 let Some(c) = at(&text, line) else {
678 return Outcome::declined("not inside a merge conflict");
681 };
682 let side = match SIDE {
683 0 => Side::Ours,
684 1 => Side::Theirs,
685 _ => Side::Both,
686 };
687 let (from, to) = c.lines();
688 Outcome::did(vec![
689 Negai::Edit {
690 buffer: buf.id(),
691 edit: escriba_core::Edit {
692 range: escriba_core::Range::new(
693 escriba_core::Position::new(from, 0),
694 escriba_core::Position::new(to, 0),
695 ),
696 kind: escriba_core::EditKind::Replace {
697 text: resolution(&text, c, side),
698 },
699 },
700 },
701 Negai::SetCursor {
704 buffer: buf.id(),
705 to: escriba_core::Position::new(from, 0),
706 },
707 ])
708 }
709}
710
711struct BufferNext;
712impl Native for BufferNext {
713 type Reads = caps!();
714 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
715 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
716 }
717}
718
719struct BufferPrev;
720impl Native for BufferPrev {
721 type Reads = caps!();
722 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
723 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
724 }
725}
726
727struct BufferDelete;
732impl Native for BufferDelete {
733 type Reads = caps!(Buffers);
734 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
735 match active_or_decline(&v.buffers()) {
736 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
737 Err(o) => o,
738 }
739 }
740}
741
742struct CommentToggle;
749impl Native for CommentToggle {
750 type Reads = caps!(Buffers, Cursor, Syntax);
751 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
752 let Some(ft) = v.syntax().filetype() else {
753 return Outcome::declined("no filetype for this buffer");
754 };
755 let Some(comment) = ft.comment.as_ref() else {
756 let mut m = String::from("no comment syntax for ");
757 m.push_str(&ft.name);
758 return Outcome::declined(m);
759 };
760 let b = v.buffers();
761 let Some(buf) = b.active() else {
762 return Outcome::declined("no active buffer");
763 };
764 let line_no = v.cursor().position().line;
765 let Some(line) = buf.line(line_no) else {
766 return Outcome::declined("cursor past the end of the buffer");
767 };
768 if line.trim().is_empty() {
771 return Outcome::declined("nothing on this line");
772 }
773
774 let indent_len = line.len() - line.trim_start().len();
777 let (indent, body) = line.split_at(indent_len);
778 let toggled = match comment.strip(body) {
779 Some(uncommented) => uncommented.to_string(),
780 None => comment.wrap(body),
781 };
782 let mut text = String::with_capacity(indent.len() + toggled.len());
783 text.push_str(indent);
784 text.push_str(&toggled);
785
786 Outcome::did(vec![Negai::Edit {
787 buffer: buf.id(),
788 edit: escriba_core::Edit {
789 range: escriba_core::Range::new(
790 escriba_core::Position::new(line_no, 0),
791 escriba_core::Position::new(
792 line_no,
793 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
794 ),
795 ),
796 kind: escriba_core::EditKind::Replace { text },
797 },
798 }])
799 }
800}
801
802struct TodoWalk<const FORWARD: bool>;
814impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
815 type Reads = caps!(Buffers);
816 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
817 let b = v.buffers();
818 let Some(buf) = b.active() else {
819 return Outcome::declined("no active buffer");
820 };
821 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
822 if findings.is_empty() {
823 return Outcome::declined("no TODO markers in this buffer");
824 }
825 Outcome::did(vec![
826 Negai::PublishFindings {
827 list: "todo".to_string(),
828 findings,
829 },
830 Negai::WalkList {
831 list: "todo".to_string(),
832 forward: FORWARD,
833 },
834 ])
835 }
836}
837
838struct Quit;
839impl Native for Quit {
840 type Reads = caps!();
841 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
842 Outcome::did(vec![Negai::Quit])
843 }
844}
845
846struct Noh;
854impl Native for Noh {
855 type Reads = caps!();
856 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
857 Outcome::did(vec![Negai::ClearSearchHighlight])
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use escriba_core::BufferId;
865 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
866
867 fn dirty_file() -> FakeSnapshot {
869 let mut s = FakeSnapshot::default();
870 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
871 s.active = Some(BufferId(1));
872 s
873 }
874
875 #[test]
876 fn default_set_is_populated() {
877 let r = CommandRegistry::default_set();
878 let names = r.names();
879 assert!(names.contains(&"save"));
880 assert!(names.contains(&"quit"));
881 }
882
883 #[test]
884 fn specs_are_sorted() {
885 let r = CommandRegistry::default_set();
886 let specs = r.specs();
887 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
888 }
889
890 #[test]
891 fn not_found_errors() {
892 let r = CommandRegistry::new();
895 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
896 assert!(matches!(err, CommandError::NotFound(_)));
897 }
898
899 #[test]
900 fn a_command_asks_rather_than_acts() {
901 let mut r = CommandRegistry::new();
908 r.register(Command::action(
909 "w-all",
910 "Write every modified buffer",
911 "buffer.write-all",
912 ));
913 let out = r
914 .run("w-all", &dirty_file(), &[])
915 .expect("registered command dispatches");
916 assert_eq!(
917 out.slips,
918 vec![Negai::Save {
919 buffer: BufferId(1)
920 }]
921 );
922 assert_eq!(out.verdict, Verdict::Did);
923 }
924
925 #[test]
926 fn nothing_to_save_declines_rather_than_claiming_success() {
927 let mut r = CommandRegistry::new();
931 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
932 let out = r
933 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
934 .expect("dispatches");
935 assert!(out.slips.is_empty());
936 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
937 }
938
939 #[test]
940 fn no_active_buffer_declines_rather_than_failing() {
941 let mut r = CommandRegistry::new();
944 r.register(Command::action("w", "Save", "buffer.save"));
945 let out = r
946 .run("w", &FakeSnapshot::default(), &[])
947 .expect("dispatches");
948 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
949 assert!(out.slips.is_empty(), "a decline asks for nothing");
950 }
951
952 #[test]
953 fn unknown_action_symbol_is_reported_not_silent() {
954 let mut r = CommandRegistry::new();
963 r.register(Command::action("pick", "Pick a file", "picker.files"));
964 let err = r
965 .run("pick", &FakeSnapshot::default(), &[])
966 .expect_err("an unimplemented action must report, not report success");
967 assert!(
968 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
969 "expected Unhandled(picker.files), got {err:?}",
970 );
971 assert!(r.contains("pick"), "the command survives its own failure");
972 }
973
974 #[test]
975 fn action_naming_a_command_is_inert_not_recursive() {
976 let mut r = CommandRegistry::new();
984 r.register(Command::action("alias", "aliases save by name", "save"));
985 let err = r
986 .run("alias", &dirty_file(), &[])
987 .expect_err("a command-name alias resolves nothing, and says so");
988 assert!(
989 matches!(&err, CommandError::Unhandled(s) if s == "save"),
990 "expected Unhandled(save), got {err:?}",
991 );
992 }
993
994 #[test]
995 fn quit_is_a_request_not_a_flag_poke() {
996 let mut r = CommandRegistry::new();
1001 r.register(Command::action("bye", "Quit", "editor.quit"));
1002 let out = r
1003 .run("bye", &FakeSnapshot::default(), &[])
1004 .expect("dispatches");
1005 assert_eq!(out.slips, vec![Negai::Quit]);
1006 }
1007
1008 #[test]
1009 fn buffer_info_speaks_through_a_slip_not_stderr() {
1010 let mut r = CommandRegistry::new();
1014 r.register(Command::action("info", "Buffer info", "buffer.info"));
1015 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1016 let Some(Negai::Message(m)) = out.slips.first() else {
1017 panic!("expected a Message slip, got {:?}", out.slips);
1018 };
1019 assert!(m.contains("buffer 1"), "{m}");
1020 assert!(m.contains("[modified]"), "{m}");
1021 }
1022}