1extern crate self as escriba_command;
4
5pub mod ex;
6
7use std::collections::HashMap;
8
9use escriba_core::BufferId;
10use escriba_madoguchi::cap::{Buffers, Cursor, Syntax};
11use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16#[derive(Debug, Error)]
17pub enum CommandError {
18 #[error("command not found: {0}")]
19 NotFound(String),
20 #[error("action `{0}` is declared but not implemented yet")]
29 Unhandled(String),
30 #[error("command failed: {0}")]
31 Failed(String),
32 #[error("alias cycle resolving `{0}`")]
38 AliasCycle(String),
39 }
44
45pub type Result<T> = std::result::Result<T, CommandError>;
46
47pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
56
57#[derive(Debug, Clone)]
72pub enum Handler {
73 Native(CommandFn),
75 Action(String),
77}
78
79#[derive(Debug, Clone)]
80pub struct Command {
81 pub name: String,
82 pub description: String,
83 pub handler: Handler,
84}
85
86impl Command {
87 pub fn native(
89 name: impl Into<String>,
90 description: impl Into<String>,
91 handler: CommandFn,
92 ) -> Self {
93 Self {
94 name: name.into(),
95 description: description.into(),
96 handler: Handler::Native(handler),
97 }
98 }
99
100 pub fn action(
104 name: impl Into<String>,
105 description: impl Into<String>,
106 action: impl Into<String>,
107 ) -> Self {
108 Self {
109 name: name.into(),
110 description: description.into(),
111 handler: Handler::Action(action.into()),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct CommandSpec {
118 pub name: String,
119 pub description: String,
120 #[serde(default)]
121 pub args: Vec<CommandArgSpec>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125pub struct CommandArgSpec {
126 pub name: String,
127 pub description: String,
128 #[serde(default)]
129 pub required: bool,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub variants: Vec<String>,
132}
133
134#[derive(Debug, Default, Clone)]
135pub struct CommandRegistry {
136 commands: HashMap<String, Command>,
137}
138
139impl CommandRegistry {
140 #[must_use]
141 pub fn new() -> Self {
142 Self::default()
143 }
144
145 #[must_use]
146 pub fn default_set() -> Self {
147 let mut r = Self::new();
148 r.register(Command::native(
149 "save",
150 "Write the active buffer to disk",
151 erase::<Save>(),
152 ));
153 r.register(Command::native(
159 "quit",
160 "Exit the editor, unless a buffer is modified",
161 erase::<QuitChecked<false>>(),
162 ));
163 r.register(Command::native(
164 "quit!",
165 "Exit the editor, discarding unsaved changes",
166 erase::<Quit>(),
167 ));
168 r.register(Command::native(
169 "quit-all",
170 "Exit the editor, unless any buffer is modified",
171 erase::<QuitChecked<true>>(),
172 ));
173 r.register(Command::native(
174 "quit-all!",
175 "Exit the editor, discarding every unsaved change",
176 erase::<Quit>(),
177 ));
178 r.register(Command::native(
179 "write-quit",
180 "Write the active buffer, then exit",
181 erase::<WriteQuit>(),
182 ));
183 r.register(Command::native(
184 "write-quit-all",
185 "Write every modified buffer, then exit",
186 erase::<WriteQuitAll>(),
187 ));
188 r.register(Command::native(
189 "exit-write",
190 "Write the active buffer if modified, then exit",
191 erase::<ExitWrite>(),
192 ));
193 r.register(Command::native(
194 "buffer.write-all",
195 "Write every modified buffer",
196 erase::<WriteAll>(),
197 ));
198 r.register(Command::native(
203 "buffer.next",
204 "Go to the next buffer",
205 erase::<BufferNext>(),
206 ));
207 r.register(Command::native(
208 "buffer.prev",
209 "Go to the previous buffer",
210 erase::<BufferPrev>(),
211 ));
212 r.register(Command::native(
213 "buffer.delete",
214 "Close the active buffer",
215 erase::<BufferDelete>(),
216 ));
217 r.register(Command::native(
218 "picker.buffers",
219 "Pick an open buffer",
220 erase::<OpenPicker<false>>(),
221 ));
222 r.register(Command::native(
223 "picker.commands",
224 "Pick a command",
225 erase::<OpenPicker<true>>(),
226 ));
227 r.register(Command::native(
228 "picker.help",
229 "Search every keybinding",
230 erase::<HelpPicker>(),
231 ));
232 r.register(Command::native(
233 "picker.grep",
234 "Search the project for a pattern",
235 erase::<GrepPicker>(),
236 ));
237 r.register(Command::native(
238 "picker.files",
239 "Pick a file under the working directory",
240 erase::<WalkPicker<false>>(),
241 ));
242 r.register(Command::native(
243 "picker.project",
244 "Pick a project root",
245 erase::<WalkPicker<true>>(),
246 ));
247 r.register(Command::native(
252 "files.open",
253 "Browse files under the working directory",
254 erase::<WalkPicker<false>>(),
255 ));
256 r.register(Command::native(
257 "files.open-parent",
258 "Browse files from the parent directory",
259 erase::<ParentPicker>(),
260 ));
261 r.register(Command::native(
263 "trouble.toggle",
264 "Show located findings",
265 erase::<FindingsPicker<true>>(),
266 ));
267 r.register(Command::native(
268 "trouble.workspace",
269 "Show findings across the workspace",
270 erase::<FindingsPicker<true>>(),
271 ));
272 r.register(Command::native(
273 "trouble.document",
274 "Show findings in this buffer",
275 erase::<FindingsPicker<false>>(),
276 ));
277 r.register(Command::native(
278 "window.split",
279 "Split the window horizontally (:sp)",
280 erase::<SplitWindow<true>>(),
281 ));
282 r.register(Command::native(
283 "window.vsplit",
284 "Split the window vertically (:vsp)",
285 erase::<SplitWindow<false>>(),
286 ));
287 r.register(Command::native(
288 "window.close",
289 "Close the active window (:close)",
290 erase::<CloseWindow>(),
291 ));
292 r.register(Command::native(
293 "pane.left",
294 "Focus the window to the left",
295 erase::<FocusDir<-1, 0>>(),
296 ));
297 r.register(Command::native(
298 "pane.right",
299 "Focus the window to the right",
300 erase::<FocusDir<1, 0>>(),
301 ));
302 r.register(Command::native(
303 "pane.up",
304 "Focus the window above",
305 erase::<FocusDir<0, -1>>(),
306 ));
307 r.register(Command::native(
308 "pane.down",
309 "Focus the window below",
310 erase::<FocusDir<0, 1>>(),
311 ));
312 r.register(Command::native(
313 "conflict.next",
314 "Go to the next merge conflict",
315 erase::<ConflictWalk<true>>(),
316 ));
317 r.register(Command::native(
318 "conflict.prev",
319 "Go to the previous merge conflict",
320 erase::<ConflictWalk<false>>(),
321 ));
322 r.register(Command::native(
323 "conflict.choose-ours",
324 "Resolve the conflict keeping ours",
325 erase::<ChooseSide<0>>(),
326 ));
327 r.register(Command::native(
328 "conflict.choose-theirs",
329 "Resolve the conflict keeping theirs",
330 erase::<ChooseSide<1>>(),
331 ));
332 r.register(Command::native(
333 "conflict.choose-both",
334 "Resolve the conflict keeping both",
335 erase::<ChooseSide<2>>(),
336 ));
337 r.register(Command::native(
338 "todo.next",
339 "Go to the next TODO/FIXME marker",
340 erase::<TodoWalk<true>>(),
341 ));
342 r.register(Command::native(
343 "todo.prev",
344 "Go to the previous TODO/FIXME marker",
345 erase::<TodoWalk<false>>(),
346 ));
347 for name in ["comment.toggle-line", "comment.toggle-block"] {
348 r.register(Command::native(
349 name,
350 "Toggle the comment on the current line",
351 erase::<CommentToggle>(),
352 ));
353 }
354 for alias in ["noh", "nohl", "nohlsearch"] {
355 r.register(Command::action(
356 alias,
357 "Stop highlighting matches, keep the pattern",
358 "search.clear-highlight",
359 ));
360 }
361 r.register(Command::native(
362 "undo",
363 "Undo the last change",
364 erase::<Undo>(),
365 ));
366 r.register(Command::native(
367 "redo",
368 "Redo the last undone change",
369 erase::<Redo>(),
370 ));
371 r.register(Command::native(
372 "buffer-info",
373 "Print the active buffer summary",
374 erase::<Info>(),
375 ));
376 r
377 }
378
379 pub fn register(&mut self, command: Command) {
380 self.commands.insert(command.name.clone(), command);
381 }
382
383 #[must_use]
386 pub fn contains(&self, name: &str) -> bool {
387 self.commands.contains_key(name)
388 }
389
390 #[must_use]
392 pub fn len(&self) -> usize {
393 self.commands.len()
394 }
395
396 #[must_use]
398 pub fn is_empty(&self) -> bool {
399 self.commands.is_empty()
400 }
401
402 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
408 self.run_bounded(name, snap, args, ALIAS_FUEL)
409 }
410
411 fn run_bounded(
424 &self,
425 name: &str,
426 snap: &dyn Snapshot,
427 args: &[String],
428 fuel: u8,
429 ) -> Result<Outcome> {
430 let Some(fuel) = fuel.checked_sub(1) else {
431 return Err(CommandError::AliasCycle(name.to_string()));
432 };
433 let cmd = self
434 .commands
435 .get(name)
436 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
437 match &cmd.handler {
438 Handler::Native(f) => Ok(f(snap, args)),
439 Handler::Action(sym) => match builtin_action(sym) {
440 Some(f) => Ok(f(snap, args)),
441 None if sym != name && self.commands.contains_key(sym.as_str()) => {
445 self.run_bounded(sym, snap, args, fuel)
446 }
447 None => Err(CommandError::Unhandled(sym.to_string())),
448 },
449 }
450 }
451
452 #[must_use]
453 pub fn names(&self) -> Vec<&str> {
454 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
455 v.sort_unstable();
456 v
457 }
458
459 #[must_use]
460 pub fn specs(&self) -> Vec<CommandSpec> {
461 let mut out: Vec<CommandSpec> = self
462 .commands
463 .values()
464 .map(|c| CommandSpec {
465 name: c.name.to_string(),
466 description: c.description.to_string(),
467 args: Vec::new(),
468 })
469 .collect();
470 out.sort_by(|a, b| a.name.cmp(&b.name));
471 out
472 }
473}
474
475const ALIAS_FUEL: u8 = 8;
480
481fn builtin_action(sym: &str) -> Option<CommandFn> {
487 Some(match sym {
488 "buffer.save" | "buffer.write" => erase::<Save>(),
489 "buffer.write-all" => erase::<WriteAll>(),
490 "buffer.undo" => erase::<Undo>(),
491 "buffer.redo" => erase::<Redo>(),
492 "buffer.info" => erase::<Info>(),
493 "editor.quit" => erase::<Quit>(),
494 "search.clear-highlight" => erase::<Noh>(),
495 _ => return None,
502 })
503}
504
505fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
510 b.active()
511 .map(BufferView::id)
512 .ok_or_else(|| Outcome::declined("no active buffer"))
513}
514
515type Result2<T> = std::result::Result<T, Outcome>;
516
517struct WriteAll;
523impl Native for WriteAll {
524 type Reads = caps!(Buffers);
525 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
526 let b = v.buffers();
527 let slips: Vec<Negai> = b
528 .ids()
529 .into_iter()
530 .filter(|id| {
531 b.get(*id)
532 .is_some_and(|x| x.is_modified() && x.path().is_some())
533 })
534 .map(|buffer| Negai::Save { buffer })
535 .collect();
536 if slips.is_empty() {
537 return Outcome::declined("no modified files");
538 }
539 Outcome::did(slips)
540 }
541}
542
543struct Save;
544impl Native for Save {
545 type Reads = caps!(Buffers);
546 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
547 match active_or_decline(&v.buffers()) {
548 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
549 Err(o) => o,
550 }
551 }
552}
553
554struct Undo;
555impl Native for Undo {
556 type Reads = caps!(Buffers);
557 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
558 match active_or_decline(&v.buffers()) {
559 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
560 Err(o) => o,
561 }
562 }
563}
564
565struct Redo;
566impl Native for Redo {
567 type Reads = caps!(Buffers);
568 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
569 match active_or_decline(&v.buffers()) {
570 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
571 Err(o) => o,
572 }
573 }
574}
575
576struct Info;
583impl Native for Info {
584 type Reads = caps!(Buffers);
585 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
586 let b = v.buffers();
587 let Some(buf) = b.active() else {
588 return Outcome::declined("no active buffer");
589 };
590 let mut m = String::with_capacity(48);
591 m.push_str("buffer ");
592 m.push_str(&buf.id().0.to_string());
593 m.push_str(" — ");
594 m.push_str(&buf.line_count().to_string());
595 m.push_str(" line(s)");
596 if buf.is_modified() {
597 m.push_str(" [modified]");
598 }
599 Outcome::did(vec![Negai::Message(m)])
600 }
601}
602
603struct OpenPicker<const COMMANDS: bool>;
619impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
620 type Reads = caps!();
621 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
622 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
623 escriba_madoguchi::PickerSource::Commands
624 } else {
625 escriba_madoguchi::PickerSource::Buffers
626 })])
627 }
628}
629
630struct HelpPicker;
632impl Native for HelpPicker {
633 type Reads = caps!();
634 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
635 Outcome::did(vec![Negai::OpenPicker(
636 escriba_madoguchi::PickerSource::Help,
637 )])
638 }
639}
640
641struct GrepPicker;
647impl Native for GrepPicker {
648 type Reads = caps!();
649 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
650 let pattern = args.join(" ");
651 if pattern.is_empty() {
652 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
653 }
654 Outcome::did(vec![Negai::GrepProject { pattern }])
655 }
656}
657
658struct WalkPicker<const PROJECT: bool>;
660impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
661 type Reads = caps!();
662 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
663 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
664 escriba_madoguchi::PickerSource::Project
665 } else {
666 escriba_madoguchi::PickerSource::Files
667 })])
668 }
669}
670
671struct ParentPicker;
678impl Native for ParentPicker {
679 type Reads = caps!();
680 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
681 let root = std::env::current_dir()
682 .ok()
683 .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
684 .unwrap_or_else(|| std::path::PathBuf::from(".."));
685 Outcome::did(vec![Negai::OpenPicker(
686 escriba_madoguchi::PickerSource::FilesUnder(root),
687 )])
688 }
689}
690
691struct FindingsPicker<const WORKSPACE: bool>;
697impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
698 type Reads = caps!();
699 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
700 Outcome::did(vec![Negai::OpenPicker(
701 escriba_madoguchi::PickerSource::Findings {
702 workspace: WORKSPACE,
703 },
704 )])
705 }
706}
707
708struct SplitWindow<const STACKED: bool>;
715impl<const STACKED: bool> Native for SplitWindow<STACKED> {
716 type Reads = caps!();
717 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
718 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
719 }
720}
721
722struct CloseWindow;
724impl Native for CloseWindow {
725 type Reads = caps!();
726 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
727 Outcome::did(vec![Negai::CloseWindow])
728 }
729}
730
731struct FocusDir<const DX: i8, const DY: i8>;
737impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
738 type Reads = caps!();
739 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
740 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
741 }
742}
743
744struct ConflictWalk<const FORWARD: bool>;
750impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
751 type Reads = caps!(Buffers);
752 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
753 let b = v.buffers();
754 let Some(buf) = b.active() else {
755 return Outcome::declined("no active buffer");
756 };
757 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
758 if findings.is_empty() {
759 return Outcome::declined("no merge conflicts in this buffer");
760 }
761 Outcome::did(vec![
762 Negai::PublishFindings {
763 list: "conflict".to_string(),
764 findings,
765 },
766 Negai::WalkList {
767 list: "conflict".to_string(),
768 forward: FORWARD,
769 },
770 ])
771 }
772}
773
774struct ChooseSide<const SIDE: u8>;
780impl<const SIDE: u8> Native for ChooseSide<SIDE> {
781 type Reads = caps!(Buffers, Cursor);
782 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
783 use escriba_shirube::conflict::{Side, at, resolution};
784 let b = v.buffers();
785 let Some(buf) = b.active() else {
786 return Outcome::declined("no active buffer");
787 };
788 let text = buf.text();
789 let line = v.cursor().position().line;
790 let Some(c) = at(&text, line) else {
791 return Outcome::declined("not inside a merge conflict");
794 };
795 let side = match SIDE {
796 0 => Side::Ours,
797 1 => Side::Theirs,
798 _ => Side::Both,
799 };
800 let (from, to) = c.lines();
801 Outcome::did(vec![
802 Negai::Edit {
803 buffer: buf.id(),
804 edit: escriba_core::Edit {
805 range: escriba_core::Range::new(
806 escriba_core::Position::new(from, 0),
807 escriba_core::Position::new(to, 0),
808 ),
809 kind: escriba_core::EditKind::Replace {
810 text: resolution(&text, c, side),
811 },
812 },
813 },
814 Negai::SetCursor {
817 buffer: buf.id(),
818 to: escriba_core::Position::new(from, 0),
819 },
820 ])
821 }
822}
823
824struct BufferNext;
825impl Native for BufferNext {
826 type Reads = caps!();
827 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
828 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
829 }
830}
831
832struct BufferPrev;
833impl Native for BufferPrev {
834 type Reads = caps!();
835 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
836 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
837 }
838}
839
840struct BufferDelete;
845impl Native for BufferDelete {
846 type Reads = caps!(Buffers);
847 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
848 match active_or_decline(&v.buffers()) {
849 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
850 Err(o) => o,
851 }
852 }
853}
854
855struct CommentToggle;
862impl Native for CommentToggle {
863 type Reads = caps!(Buffers, Cursor, Syntax);
864 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
865 let Some(ft) = v.syntax().filetype() else {
866 return Outcome::declined("no filetype for this buffer");
867 };
868 let Some(comment) = ft.comment.as_ref() else {
869 let mut m = String::from("no comment syntax for ");
870 m.push_str(&ft.name);
871 return Outcome::declined(m);
872 };
873 let b = v.buffers();
874 let Some(buf) = b.active() else {
875 return Outcome::declined("no active buffer");
876 };
877 let line_no = v.cursor().position().line;
878 let Some(line) = buf.line(line_no) else {
879 return Outcome::declined("cursor past the end of the buffer");
880 };
881 if line.trim().is_empty() {
884 return Outcome::declined("nothing on this line");
885 }
886
887 let indent_len = line.len() - line.trim_start().len();
890 let (indent, body) = line.split_at(indent_len);
891 let toggled = match comment.strip(body) {
892 Some(uncommented) => uncommented.to_string(),
893 None => comment.wrap(body),
894 };
895 let mut text = String::with_capacity(indent.len() + toggled.len());
896 text.push_str(indent);
897 text.push_str(&toggled);
898
899 Outcome::did(vec![Negai::Edit {
900 buffer: buf.id(),
901 edit: escriba_core::Edit {
902 range: escriba_core::Range::new(
903 escriba_core::Position::new(line_no, 0),
904 escriba_core::Position::new(
905 line_no,
906 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
907 ),
908 ),
909 kind: escriba_core::EditKind::Replace { text },
910 },
911 }])
912 }
913}
914
915struct TodoWalk<const FORWARD: bool>;
927impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
928 type Reads = caps!(Buffers);
929 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
930 let b = v.buffers();
931 let Some(buf) = b.active() else {
932 return Outcome::declined("no active buffer");
933 };
934 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
935 if findings.is_empty() {
936 return Outcome::declined("no TODO markers in this buffer");
937 }
938 Outcome::did(vec![
939 Negai::PublishFindings {
940 list: "todo".to_string(),
941 findings,
942 },
943 Negai::WalkList {
944 list: "todo".to_string(),
945 forward: FORWARD,
946 },
947 ])
948 }
949}
950
951struct Quit;
957impl Native for Quit {
958 type Reads = caps!();
959 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
960 Outcome::did(vec![Negai::Quit])
961 }
962}
963
964fn no_write_since_last_change(what: &str) -> Outcome {
967 let mut m = String::with_capacity(64);
968 m.push_str("E37: No write since last change in ");
969 m.push_str(what);
970 m.push_str(" (add ! to override)");
971 Outcome::declined(m)
972}
973
974fn buffer_label(b: &dyn BufferView) -> String {
977 b.path()
978 .map_or_else(|| "[No Name]".to_string(), |p| p.display().to_string())
979}
980
981struct QuitChecked<const ALL: bool>;
988impl<const ALL: bool> Native for QuitChecked<ALL> {
989 type Reads = caps!(Buffers);
990 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
991 let b = v.buffers();
992 let blocker = if ALL {
995 b.ids()
996 .into_iter()
997 .filter_map(|id| b.get(id))
998 .find(|x| x.is_modified())
999 .map(buffer_label)
1000 } else {
1001 b.active().filter(|x| x.is_modified()).map(buffer_label)
1002 };
1003 match blocker {
1004 Some(what) => no_write_since_last_change(&what),
1005 None => Outcome::did(vec![Negai::Quit]),
1006 }
1007 }
1008}
1009
1010struct WriteQuit;
1017impl Native for WriteQuit {
1018 type Reads = caps!(Buffers);
1019 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1020 let b = v.buffers();
1021 let Some(buf) = b.active() else {
1022 return Outcome::declined("no active buffer");
1023 };
1024 if buf.path().is_none() {
1025 return Outcome::declined("E32: No file name");
1026 }
1027 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1028 }
1029}
1030
1031struct ExitWrite;
1037impl Native for ExitWrite {
1038 type Reads = caps!(Buffers);
1039 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1040 let b = v.buffers();
1041 let Some(buf) = b.active() else {
1042 return Outcome::declined("no active buffer");
1043 };
1044 if !buf.is_modified() {
1045 return Outcome::did(vec![Negai::Quit]);
1046 }
1047 if buf.path().is_none() {
1048 return Outcome::declined("E32: No file name");
1049 }
1050 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1051 }
1052}
1053
1054struct WriteQuitAll;
1063impl Native for WriteQuitAll {
1064 type Reads = caps!(Buffers);
1065 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1066 let b = v.buffers();
1067 let mut slips: Vec<Negai> = b
1068 .ids()
1069 .into_iter()
1070 .filter(|id| {
1071 b.get(*id)
1072 .is_some_and(|x| x.is_modified() && x.path().is_some())
1073 })
1074 .map(|buffer| Negai::Save { buffer })
1075 .collect();
1076 slips.push(Negai::Quit);
1077 Outcome::did(slips)
1078 }
1079}
1080
1081struct Noh;
1089impl Native for Noh {
1090 type Reads = caps!();
1091 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1092 Outcome::did(vec![Negai::ClearSearchHighlight])
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use escriba_core::BufferId;
1100 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
1101
1102 fn dirty_file() -> FakeSnapshot {
1104 let mut s = FakeSnapshot::default();
1105 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
1106 s.active = Some(BufferId(1));
1107 s
1108 }
1109
1110 #[test]
1111 fn default_set_is_populated() {
1112 let r = CommandRegistry::default_set();
1113 let names = r.names();
1114 assert!(names.contains(&"save"));
1115 assert!(names.contains(&"quit"));
1116 }
1117
1118 #[test]
1119 fn specs_are_sorted() {
1120 let r = CommandRegistry::default_set();
1121 let specs = r.specs();
1122 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
1123 }
1124
1125 #[test]
1126 fn not_found_errors() {
1127 let r = CommandRegistry::new();
1130 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
1131 assert!(matches!(err, CommandError::NotFound(_)));
1132 }
1133
1134 #[test]
1135 fn a_command_asks_rather_than_acts() {
1136 let mut r = CommandRegistry::new();
1143 r.register(Command::action(
1144 "w-all",
1145 "Write every modified buffer",
1146 "buffer.write-all",
1147 ));
1148 let out = r
1149 .run("w-all", &dirty_file(), &[])
1150 .expect("registered command dispatches");
1151 assert_eq!(
1152 out.slips,
1153 vec![Negai::Save {
1154 buffer: BufferId(1)
1155 }]
1156 );
1157 assert_eq!(out.verdict, Verdict::Did);
1158 }
1159
1160 #[test]
1161 fn nothing_to_save_declines_rather_than_claiming_success() {
1162 let mut r = CommandRegistry::new();
1166 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
1167 let out = r
1168 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1169 .expect("dispatches");
1170 assert!(out.slips.is_empty());
1171 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1172 }
1173
1174 #[test]
1175 fn no_active_buffer_declines_rather_than_failing() {
1176 let mut r = CommandRegistry::new();
1179 r.register(Command::action("w", "Save", "buffer.save"));
1180 let out = r
1181 .run("w", &FakeSnapshot::default(), &[])
1182 .expect("dispatches");
1183 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1184 assert!(out.slips.is_empty(), "a decline asks for nothing");
1185 }
1186
1187 #[test]
1188 fn unknown_action_symbol_is_reported_not_silent() {
1189 let mut r = CommandRegistry::new();
1198 r.register(Command::action("pick", "Pick a file", "picker.files"));
1199 let err = r
1200 .run("pick", &FakeSnapshot::default(), &[])
1201 .expect_err("an unimplemented action must report, not report success");
1202 assert!(
1203 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1204 "expected Unhandled(picker.files), got {err:?}",
1205 );
1206 assert!(r.contains("pick"), "the command survives its own failure");
1207 }
1208
1209 #[test]
1210 fn action_naming_a_command_is_inert_not_recursive() {
1211 let mut r = CommandRegistry::new();
1219 r.register(Command::action("alias", "aliases save by name", "save"));
1220 let err = r
1221 .run("alias", &dirty_file(), &[])
1222 .expect_err("a command-name alias resolves nothing, and says so");
1223 assert!(
1224 matches!(&err, CommandError::Unhandled(s) if s == "save"),
1225 "expected Unhandled(save), got {err:?}",
1226 );
1227 }
1228
1229 #[test]
1230 fn quit_is_a_request_not_a_flag_poke() {
1231 let mut r = CommandRegistry::new();
1236 r.register(Command::action("bye", "Quit", "editor.quit"));
1237 let out = r
1238 .run("bye", &FakeSnapshot::default(), &[])
1239 .expect("dispatches");
1240 assert_eq!(out.slips, vec![Negai::Quit]);
1241 }
1242
1243 #[test]
1244 fn buffer_info_speaks_through_a_slip_not_stderr() {
1245 let mut r = CommandRegistry::new();
1249 r.register(Command::action("info", "Buffer info", "buffer.info"));
1250 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1251 let Some(Negai::Message(m)) = out.slips.first() else {
1252 panic!("expected a Message slip, got {:?}", out.slips);
1253 };
1254 assert!(m.contains("buffer 1"), "{m}");
1255 assert!(m.contains("[modified]"), "{m}");
1256 }
1257}