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(
206 "files.open",
207 "Browse files under the working directory",
208 erase::<WalkPicker<false>>(),
209 ));
210 r.register(Command::native(
211 "files.open-parent",
212 "Browse files from the parent directory",
213 erase::<ParentPicker>(),
214 ));
215 r.register(Command::native(
217 "trouble.toggle",
218 "Show located findings",
219 erase::<FindingsPicker<true>>(),
220 ));
221 r.register(Command::native(
222 "trouble.workspace",
223 "Show findings across the workspace",
224 erase::<FindingsPicker<true>>(),
225 ));
226 r.register(Command::native(
227 "trouble.document",
228 "Show findings in this buffer",
229 erase::<FindingsPicker<false>>(),
230 ));
231 r.register(Command::native(
232 "window.split",
233 "Split the window horizontally (:sp)",
234 erase::<SplitWindow<true>>(),
235 ));
236 r.register(Command::native(
237 "window.vsplit",
238 "Split the window vertically (:vsp)",
239 erase::<SplitWindow<false>>(),
240 ));
241 r.register(Command::native(
242 "window.close",
243 "Close the active window (:close)",
244 erase::<CloseWindow>(),
245 ));
246 r.register(Command::native(
247 "pane.left",
248 "Focus the window to the left",
249 erase::<FocusDir<-1, 0>>(),
250 ));
251 r.register(Command::native(
252 "pane.right",
253 "Focus the window to the right",
254 erase::<FocusDir<1, 0>>(),
255 ));
256 r.register(Command::native(
257 "pane.up",
258 "Focus the window above",
259 erase::<FocusDir<0, -1>>(),
260 ));
261 r.register(Command::native(
262 "pane.down",
263 "Focus the window below",
264 erase::<FocusDir<0, 1>>(),
265 ));
266 r.register(Command::native(
267 "conflict.next",
268 "Go to the next merge conflict",
269 erase::<ConflictWalk<true>>(),
270 ));
271 r.register(Command::native(
272 "conflict.prev",
273 "Go to the previous merge conflict",
274 erase::<ConflictWalk<false>>(),
275 ));
276 r.register(Command::native(
277 "conflict.choose-ours",
278 "Resolve the conflict keeping ours",
279 erase::<ChooseSide<0>>(),
280 ));
281 r.register(Command::native(
282 "conflict.choose-theirs",
283 "Resolve the conflict keeping theirs",
284 erase::<ChooseSide<1>>(),
285 ));
286 r.register(Command::native(
287 "conflict.choose-both",
288 "Resolve the conflict keeping both",
289 erase::<ChooseSide<2>>(),
290 ));
291 r.register(Command::native(
292 "todo.next",
293 "Go to the next TODO/FIXME marker",
294 erase::<TodoWalk<true>>(),
295 ));
296 r.register(Command::native(
297 "todo.prev",
298 "Go to the previous TODO/FIXME marker",
299 erase::<TodoWalk<false>>(),
300 ));
301 for name in ["comment.toggle-line", "comment.toggle-block"] {
302 r.register(Command::native(
303 name,
304 "Toggle the comment on the current line",
305 erase::<CommentToggle>(),
306 ));
307 }
308 for alias in ["noh", "nohl", "nohlsearch"] {
309 r.register(Command::action(
310 alias,
311 "Stop highlighting matches, keep the pattern",
312 "search.clear-highlight",
313 ));
314 }
315 r.register(Command::native(
316 "undo",
317 "Undo the last change",
318 erase::<Undo>(),
319 ));
320 r.register(Command::native(
321 "redo",
322 "Redo the last undone change",
323 erase::<Redo>(),
324 ));
325 r.register(Command::native(
326 "buffer-info",
327 "Print the active buffer summary",
328 erase::<Info>(),
329 ));
330 r
331 }
332
333 pub fn register(&mut self, command: Command) {
334 self.commands.insert(command.name.clone(), command);
335 }
336
337 #[must_use]
340 pub fn contains(&self, name: &str) -> bool {
341 self.commands.contains_key(name)
342 }
343
344 #[must_use]
346 pub fn len(&self) -> usize {
347 self.commands.len()
348 }
349
350 #[must_use]
352 pub fn is_empty(&self) -> bool {
353 self.commands.is_empty()
354 }
355
356 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
362 self.run_bounded(name, snap, args, ALIAS_FUEL)
363 }
364
365 fn run_bounded(
378 &self,
379 name: &str,
380 snap: &dyn Snapshot,
381 args: &[String],
382 fuel: u8,
383 ) -> Result<Outcome> {
384 let Some(fuel) = fuel.checked_sub(1) else {
385 return Err(CommandError::AliasCycle(name.to_string()));
386 };
387 let cmd = self
388 .commands
389 .get(name)
390 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
391 match &cmd.handler {
392 Handler::Native(f) => Ok(f(snap, args)),
393 Handler::Action(sym) => match builtin_action(sym) {
394 Some(f) => Ok(f(snap, args)),
395 None if sym != name && self.commands.contains_key(sym.as_str()) => {
399 self.run_bounded(sym, snap, args, fuel)
400 }
401 None => Err(CommandError::Unhandled(sym.to_string())),
402 },
403 }
404 }
405
406 #[must_use]
407 pub fn names(&self) -> Vec<&str> {
408 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
409 v.sort_unstable();
410 v
411 }
412
413 #[must_use]
414 pub fn specs(&self) -> Vec<CommandSpec> {
415 let mut out: Vec<CommandSpec> = self
416 .commands
417 .values()
418 .map(|c| CommandSpec {
419 name: c.name.to_string(),
420 description: c.description.to_string(),
421 args: Vec::new(),
422 })
423 .collect();
424 out.sort_by(|a, b| a.name.cmp(&b.name));
425 out
426 }
427}
428
429const ALIAS_FUEL: u8 = 8;
434
435fn builtin_action(sym: &str) -> Option<CommandFn> {
441 Some(match sym {
442 "buffer.save" | "buffer.write" => erase::<Save>(),
443 "buffer.write-all" => erase::<WriteAll>(),
444 "buffer.undo" => erase::<Undo>(),
445 "buffer.redo" => erase::<Redo>(),
446 "buffer.info" => erase::<Info>(),
447 "editor.quit" => erase::<Quit>(),
448 "search.clear-highlight" => erase::<Noh>(),
449 _ => return None,
456 })
457}
458
459fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
464 b.active()
465 .map(BufferView::id)
466 .ok_or_else(|| Outcome::declined("no active buffer"))
467}
468
469type Result2<T> = std::result::Result<T, Outcome>;
470
471struct WriteAll;
477impl Native for WriteAll {
478 type Reads = caps!(Buffers);
479 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
480 let b = v.buffers();
481 let slips: Vec<Negai> = b
482 .ids()
483 .into_iter()
484 .filter(|id| {
485 b.get(*id)
486 .is_some_and(|x| x.is_modified() && x.path().is_some())
487 })
488 .map(|buffer| Negai::Save { buffer })
489 .collect();
490 if slips.is_empty() {
491 return Outcome::declined("no modified files");
492 }
493 Outcome::did(slips)
494 }
495}
496
497struct Save;
498impl Native for Save {
499 type Reads = caps!(Buffers);
500 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
501 match active_or_decline(&v.buffers()) {
502 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
503 Err(o) => o,
504 }
505 }
506}
507
508struct Undo;
509impl Native for Undo {
510 type Reads = caps!(Buffers);
511 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
512 match active_or_decline(&v.buffers()) {
513 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
514 Err(o) => o,
515 }
516 }
517}
518
519struct Redo;
520impl Native for Redo {
521 type Reads = caps!(Buffers);
522 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
523 match active_or_decline(&v.buffers()) {
524 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
525 Err(o) => o,
526 }
527 }
528}
529
530struct Info;
537impl Native for Info {
538 type Reads = caps!(Buffers);
539 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
540 let b = v.buffers();
541 let Some(buf) = b.active() else {
542 return Outcome::declined("no active buffer");
543 };
544 let mut m = String::with_capacity(48);
545 m.push_str("buffer ");
546 m.push_str(&buf.id().0.to_string());
547 m.push_str(" — ");
548 m.push_str(&buf.line_count().to_string());
549 m.push_str(" line(s)");
550 if buf.is_modified() {
551 m.push_str(" [modified]");
552 }
553 Outcome::did(vec![Negai::Message(m)])
554 }
555}
556
557struct OpenPicker<const COMMANDS: bool>;
573impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
574 type Reads = caps!();
575 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
576 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
577 escriba_madoguchi::PickerSource::Commands
578 } else {
579 escriba_madoguchi::PickerSource::Buffers
580 })])
581 }
582}
583
584struct HelpPicker;
586impl Native for HelpPicker {
587 type Reads = caps!();
588 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
589 Outcome::did(vec![Negai::OpenPicker(
590 escriba_madoguchi::PickerSource::Help,
591 )])
592 }
593}
594
595struct GrepPicker;
601impl Native for GrepPicker {
602 type Reads = caps!();
603 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
604 let pattern = args.join(" ");
605 if pattern.is_empty() {
606 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
607 }
608 Outcome::did(vec![Negai::GrepProject { pattern }])
609 }
610}
611
612struct WalkPicker<const PROJECT: bool>;
614impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
615 type Reads = caps!();
616 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
617 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
618 escriba_madoguchi::PickerSource::Project
619 } else {
620 escriba_madoguchi::PickerSource::Files
621 })])
622 }
623}
624
625struct ParentPicker;
632impl Native for ParentPicker {
633 type Reads = caps!();
634 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
635 let root = std::env::current_dir()
636 .ok()
637 .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
638 .unwrap_or_else(|| std::path::PathBuf::from(".."));
639 Outcome::did(vec![Negai::OpenPicker(
640 escriba_madoguchi::PickerSource::FilesUnder(root),
641 )])
642 }
643}
644
645struct FindingsPicker<const WORKSPACE: bool>;
651impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
652 type Reads = caps!();
653 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
654 Outcome::did(vec![Negai::OpenPicker(
655 escriba_madoguchi::PickerSource::Findings {
656 workspace: WORKSPACE,
657 },
658 )])
659 }
660}
661
662struct SplitWindow<const STACKED: bool>;
669impl<const STACKED: bool> Native for SplitWindow<STACKED> {
670 type Reads = caps!();
671 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
672 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
673 }
674}
675
676struct CloseWindow;
678impl Native for CloseWindow {
679 type Reads = caps!();
680 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
681 Outcome::did(vec![Negai::CloseWindow])
682 }
683}
684
685struct FocusDir<const DX: i8, const DY: i8>;
691impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
692 type Reads = caps!();
693 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
694 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
695 }
696}
697
698struct ConflictWalk<const FORWARD: bool>;
704impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
705 type Reads = caps!(Buffers);
706 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
707 let b = v.buffers();
708 let Some(buf) = b.active() else {
709 return Outcome::declined("no active buffer");
710 };
711 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
712 if findings.is_empty() {
713 return Outcome::declined("no merge conflicts in this buffer");
714 }
715 Outcome::did(vec![
716 Negai::PublishFindings {
717 list: "conflict".to_string(),
718 findings,
719 },
720 Negai::WalkList {
721 list: "conflict".to_string(),
722 forward: FORWARD,
723 },
724 ])
725 }
726}
727
728struct ChooseSide<const SIDE: u8>;
734impl<const SIDE: u8> Native for ChooseSide<SIDE> {
735 type Reads = caps!(Buffers, Cursor);
736 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
737 use escriba_shirube::conflict::{Side, at, resolution};
738 let b = v.buffers();
739 let Some(buf) = b.active() else {
740 return Outcome::declined("no active buffer");
741 };
742 let text = buf.text();
743 let line = v.cursor().position().line;
744 let Some(c) = at(&text, line) else {
745 return Outcome::declined("not inside a merge conflict");
748 };
749 let side = match SIDE {
750 0 => Side::Ours,
751 1 => Side::Theirs,
752 _ => Side::Both,
753 };
754 let (from, to) = c.lines();
755 Outcome::did(vec![
756 Negai::Edit {
757 buffer: buf.id(),
758 edit: escriba_core::Edit {
759 range: escriba_core::Range::new(
760 escriba_core::Position::new(from, 0),
761 escriba_core::Position::new(to, 0),
762 ),
763 kind: escriba_core::EditKind::Replace {
764 text: resolution(&text, c, side),
765 },
766 },
767 },
768 Negai::SetCursor {
771 buffer: buf.id(),
772 to: escriba_core::Position::new(from, 0),
773 },
774 ])
775 }
776}
777
778struct BufferNext;
779impl Native for BufferNext {
780 type Reads = caps!();
781 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
782 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
783 }
784}
785
786struct BufferPrev;
787impl Native for BufferPrev {
788 type Reads = caps!();
789 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
790 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
791 }
792}
793
794struct BufferDelete;
799impl Native for BufferDelete {
800 type Reads = caps!(Buffers);
801 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
802 match active_or_decline(&v.buffers()) {
803 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
804 Err(o) => o,
805 }
806 }
807}
808
809struct CommentToggle;
816impl Native for CommentToggle {
817 type Reads = caps!(Buffers, Cursor, Syntax);
818 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
819 let Some(ft) = v.syntax().filetype() else {
820 return Outcome::declined("no filetype for this buffer");
821 };
822 let Some(comment) = ft.comment.as_ref() else {
823 let mut m = String::from("no comment syntax for ");
824 m.push_str(&ft.name);
825 return Outcome::declined(m);
826 };
827 let b = v.buffers();
828 let Some(buf) = b.active() else {
829 return Outcome::declined("no active buffer");
830 };
831 let line_no = v.cursor().position().line;
832 let Some(line) = buf.line(line_no) else {
833 return Outcome::declined("cursor past the end of the buffer");
834 };
835 if line.trim().is_empty() {
838 return Outcome::declined("nothing on this line");
839 }
840
841 let indent_len = line.len() - line.trim_start().len();
844 let (indent, body) = line.split_at(indent_len);
845 let toggled = match comment.strip(body) {
846 Some(uncommented) => uncommented.to_string(),
847 None => comment.wrap(body),
848 };
849 let mut text = String::with_capacity(indent.len() + toggled.len());
850 text.push_str(indent);
851 text.push_str(&toggled);
852
853 Outcome::did(vec![Negai::Edit {
854 buffer: buf.id(),
855 edit: escriba_core::Edit {
856 range: escriba_core::Range::new(
857 escriba_core::Position::new(line_no, 0),
858 escriba_core::Position::new(
859 line_no,
860 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
861 ),
862 ),
863 kind: escriba_core::EditKind::Replace { text },
864 },
865 }])
866 }
867}
868
869struct TodoWalk<const FORWARD: bool>;
881impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
882 type Reads = caps!(Buffers);
883 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
884 let b = v.buffers();
885 let Some(buf) = b.active() else {
886 return Outcome::declined("no active buffer");
887 };
888 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
889 if findings.is_empty() {
890 return Outcome::declined("no TODO markers in this buffer");
891 }
892 Outcome::did(vec![
893 Negai::PublishFindings {
894 list: "todo".to_string(),
895 findings,
896 },
897 Negai::WalkList {
898 list: "todo".to_string(),
899 forward: FORWARD,
900 },
901 ])
902 }
903}
904
905struct Quit;
906impl Native for Quit {
907 type Reads = caps!();
908 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
909 Outcome::did(vec![Negai::Quit])
910 }
911}
912
913struct Noh;
921impl Native for Noh {
922 type Reads = caps!();
923 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
924 Outcome::did(vec![Negai::ClearSearchHighlight])
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931 use escriba_core::BufferId;
932 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
933
934 fn dirty_file() -> FakeSnapshot {
936 let mut s = FakeSnapshot::default();
937 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
938 s.active = Some(BufferId(1));
939 s
940 }
941
942 #[test]
943 fn default_set_is_populated() {
944 let r = CommandRegistry::default_set();
945 let names = r.names();
946 assert!(names.contains(&"save"));
947 assert!(names.contains(&"quit"));
948 }
949
950 #[test]
951 fn specs_are_sorted() {
952 let r = CommandRegistry::default_set();
953 let specs = r.specs();
954 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
955 }
956
957 #[test]
958 fn not_found_errors() {
959 let r = CommandRegistry::new();
962 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
963 assert!(matches!(err, CommandError::NotFound(_)));
964 }
965
966 #[test]
967 fn a_command_asks_rather_than_acts() {
968 let mut r = CommandRegistry::new();
975 r.register(Command::action(
976 "w-all",
977 "Write every modified buffer",
978 "buffer.write-all",
979 ));
980 let out = r
981 .run("w-all", &dirty_file(), &[])
982 .expect("registered command dispatches");
983 assert_eq!(
984 out.slips,
985 vec![Negai::Save {
986 buffer: BufferId(1)
987 }]
988 );
989 assert_eq!(out.verdict, Verdict::Did);
990 }
991
992 #[test]
993 fn nothing_to_save_declines_rather_than_claiming_success() {
994 let mut r = CommandRegistry::new();
998 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
999 let out = r
1000 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1001 .expect("dispatches");
1002 assert!(out.slips.is_empty());
1003 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1004 }
1005
1006 #[test]
1007 fn no_active_buffer_declines_rather_than_failing() {
1008 let mut r = CommandRegistry::new();
1011 r.register(Command::action("w", "Save", "buffer.save"));
1012 let out = r
1013 .run("w", &FakeSnapshot::default(), &[])
1014 .expect("dispatches");
1015 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1016 assert!(out.slips.is_empty(), "a decline asks for nothing");
1017 }
1018
1019 #[test]
1020 fn unknown_action_symbol_is_reported_not_silent() {
1021 let mut r = CommandRegistry::new();
1030 r.register(Command::action("pick", "Pick a file", "picker.files"));
1031 let err = r
1032 .run("pick", &FakeSnapshot::default(), &[])
1033 .expect_err("an unimplemented action must report, not report success");
1034 assert!(
1035 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1036 "expected Unhandled(picker.files), got {err:?}",
1037 );
1038 assert!(r.contains("pick"), "the command survives its own failure");
1039 }
1040
1041 #[test]
1042 fn action_naming_a_command_is_inert_not_recursive() {
1043 let mut r = CommandRegistry::new();
1051 r.register(Command::action("alias", "aliases save by name", "save"));
1052 let err = r
1053 .run("alias", &dirty_file(), &[])
1054 .expect_err("a command-name alias resolves nothing, and says so");
1055 assert!(
1056 matches!(&err, CommandError::Unhandled(s) if s == "save"),
1057 "expected Unhandled(save), got {err:?}",
1058 );
1059 }
1060
1061 #[test]
1062 fn quit_is_a_request_not_a_flag_poke() {
1063 let mut r = CommandRegistry::new();
1068 r.register(Command::action("bye", "Quit", "editor.quit"));
1069 let out = r
1070 .run("bye", &FakeSnapshot::default(), &[])
1071 .expect("dispatches");
1072 assert_eq!(out.slips, vec![Negai::Quit]);
1073 }
1074
1075 #[test]
1076 fn buffer_info_speaks_through_a_slip_not_stderr() {
1077 let mut r = CommandRegistry::new();
1081 r.register(Command::action("info", "Buffer info", "buffer.info"));
1082 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1083 let Some(Negai::Message(m)) = out.slips.first() else {
1084 panic!("expected a Message slip, got {:?}", out.slips);
1085 };
1086 assert!(m.contains("buffer 1"), "{m}");
1087 assert!(m.contains("[modified]"), "{m}");
1088 }
1089}