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 "lsp.format",
239 "Format the buffer through its language server",
240 erase::<LspFormat>(),
241 ));
242 r.register(Command::native(
243 "picker.files",
244 "Pick a file under the working directory",
245 erase::<WalkPicker<false>>(),
246 ));
247 r.register(Command::native(
248 "picker.project",
249 "Pick a project root",
250 erase::<WalkPicker<true>>(),
251 ));
252 r.register(Command::native(
257 "files.open",
258 "Browse files under the working directory",
259 erase::<WalkPicker<false>>(),
260 ));
261 r.register(Command::native(
262 "files.open-parent",
263 "Browse files from the parent directory",
264 erase::<ParentPicker>(),
265 ));
266 r.register(Command::native(
268 "trouble.toggle",
269 "Show located findings",
270 erase::<FindingsPicker<true>>(),
271 ));
272 r.register(Command::native(
273 "trouble.workspace",
274 "Show findings across the workspace",
275 erase::<FindingsPicker<true>>(),
276 ));
277 r.register(Command::native(
278 "trouble.document",
279 "Show findings in this buffer",
280 erase::<FindingsPicker<false>>(),
281 ));
282 r.register(Command::native(
283 "window.split",
284 "Split the window horizontally (:sp)",
285 erase::<SplitWindow<true>>(),
286 ));
287 r.register(Command::native(
288 "window.vsplit",
289 "Split the window vertically (:vsp)",
290 erase::<SplitWindow<false>>(),
291 ));
292 r.register(Command::native(
293 "window.close",
294 "Close the active window (:close)",
295 erase::<CloseWindow>(),
296 ));
297 r.register(Command::native(
298 "pane.left",
299 "Focus the window to the left",
300 erase::<FocusDir<-1, 0>>(),
301 ));
302 r.register(Command::native(
303 "pane.right",
304 "Focus the window to the right",
305 erase::<FocusDir<1, 0>>(),
306 ));
307 r.register(Command::native(
308 "pane.up",
309 "Focus the window above",
310 erase::<FocusDir<0, -1>>(),
311 ));
312 r.register(Command::native(
313 "pane.down",
314 "Focus the window below",
315 erase::<FocusDir<0, 1>>(),
316 ));
317 r.register(Command::native(
318 "conflict.next",
319 "Go to the next merge conflict",
320 erase::<ConflictWalk<true>>(),
321 ));
322 r.register(Command::native(
323 "conflict.prev",
324 "Go to the previous merge conflict",
325 erase::<ConflictWalk<false>>(),
326 ));
327 r.register(Command::native(
328 "conflict.choose-ours",
329 "Resolve the conflict keeping ours",
330 erase::<ChooseSide<0>>(),
331 ));
332 r.register(Command::native(
333 "conflict.choose-theirs",
334 "Resolve the conflict keeping theirs",
335 erase::<ChooseSide<1>>(),
336 ));
337 r.register(Command::native(
338 "conflict.choose-both",
339 "Resolve the conflict keeping both",
340 erase::<ChooseSide<2>>(),
341 ));
342 r.register(Command::native(
343 "todo.next",
344 "Go to the next TODO/FIXME marker",
345 erase::<TodoWalk<true>>(),
346 ));
347 r.register(Command::native(
348 "todo.prev",
349 "Go to the previous TODO/FIXME marker",
350 erase::<TodoWalk<false>>(),
351 ));
352 for name in ["comment.toggle-line", "comment.toggle-block"] {
353 r.register(Command::native(
354 name,
355 "Toggle the comment on the current line",
356 erase::<CommentToggle>(),
357 ));
358 }
359 for alias in ["noh", "nohl", "nohlsearch"] {
360 r.register(Command::action(
361 alias,
362 "Stop highlighting matches, keep the pattern",
363 "search.clear-highlight",
364 ));
365 }
366 r.register(Command::native(
367 "undo",
368 "Undo the last change",
369 erase::<Undo>(),
370 ));
371 r.register(Command::native(
372 "redo",
373 "Redo the last undone change",
374 erase::<Redo>(),
375 ));
376 r.register(Command::native(
377 "buffer-info",
378 "Print the active buffer summary",
379 erase::<Info>(),
380 ));
381 r
382 }
383
384 pub fn register(&mut self, command: Command) {
385 self.commands.insert(command.name.clone(), command);
386 }
387
388 #[must_use]
391 pub fn contains(&self, name: &str) -> bool {
392 self.commands.contains_key(name)
393 }
394
395 #[must_use]
397 pub fn len(&self) -> usize {
398 self.commands.len()
399 }
400
401 #[must_use]
403 pub fn is_empty(&self) -> bool {
404 self.commands.is_empty()
405 }
406
407 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
413 self.run_bounded(name, snap, args, ALIAS_FUEL)
414 }
415
416 fn run_bounded(
429 &self,
430 name: &str,
431 snap: &dyn Snapshot,
432 args: &[String],
433 fuel: u8,
434 ) -> Result<Outcome> {
435 let Some(fuel) = fuel.checked_sub(1) else {
436 return Err(CommandError::AliasCycle(name.to_string()));
437 };
438 let cmd = self
439 .commands
440 .get(name)
441 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
442 match &cmd.handler {
443 Handler::Native(f) => Ok(f(snap, args)),
444 Handler::Action(sym) => match builtin_action(sym) {
445 Some(f) => Ok(f(snap, args)),
446 None if sym != name && self.commands.contains_key(sym.as_str()) => {
450 self.run_bounded(sym, snap, args, fuel)
451 }
452 None => Err(CommandError::Unhandled(sym.to_string())),
453 },
454 }
455 }
456
457 #[must_use]
458 pub fn names(&self) -> Vec<&str> {
459 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
460 v.sort_unstable();
461 v
462 }
463
464 #[must_use]
465 pub fn specs(&self) -> Vec<CommandSpec> {
466 let mut out: Vec<CommandSpec> = self
467 .commands
468 .values()
469 .map(|c| CommandSpec {
470 name: c.name.to_string(),
471 description: c.description.to_string(),
472 args: Vec::new(),
473 })
474 .collect();
475 out.sort_by(|a, b| a.name.cmp(&b.name));
476 out
477 }
478}
479
480const ALIAS_FUEL: u8 = 8;
485
486fn builtin_action(sym: &str) -> Option<CommandFn> {
492 Some(match sym {
493 "buffer.save" | "buffer.write" => erase::<Save>(),
494 "buffer.write-all" => erase::<WriteAll>(),
495 "buffer.undo" => erase::<Undo>(),
496 "buffer.redo" => erase::<Redo>(),
497 "buffer.info" => erase::<Info>(),
498 "editor.quit" => erase::<Quit>(),
499 "search.clear-highlight" => erase::<Noh>(),
500 _ => return None,
507 })
508}
509
510fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
515 b.active()
516 .map(BufferView::id)
517 .ok_or_else(|| Outcome::declined("no active buffer"))
518}
519
520type Result2<T> = std::result::Result<T, Outcome>;
521
522struct WriteAll;
528impl Native for WriteAll {
529 type Reads = caps!(Buffers);
530 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
531 let b = v.buffers();
532 let slips: Vec<Negai> = b
533 .ids()
534 .into_iter()
535 .filter(|id| {
536 b.get(*id)
537 .is_some_and(|x| x.is_modified() && x.path().is_some())
538 })
539 .map(|buffer| Negai::Save { buffer })
540 .collect();
541 if slips.is_empty() {
542 return Outcome::declined("no modified files");
543 }
544 Outcome::did(slips)
545 }
546}
547
548struct Save;
549impl Native for Save {
550 type Reads = caps!(Buffers);
551 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
552 match active_or_decline(&v.buffers()) {
553 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
554 Err(o) => o,
555 }
556 }
557}
558
559struct Undo;
560impl Native for Undo {
561 type Reads = caps!(Buffers);
562 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
563 match active_or_decline(&v.buffers()) {
564 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
565 Err(o) => o,
566 }
567 }
568}
569
570struct Redo;
571impl Native for Redo {
572 type Reads = caps!(Buffers);
573 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
574 match active_or_decline(&v.buffers()) {
575 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
576 Err(o) => o,
577 }
578 }
579}
580
581struct Info;
588impl Native for Info {
589 type Reads = caps!(Buffers);
590 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
591 let b = v.buffers();
592 let Some(buf) = b.active() else {
593 return Outcome::declined("no active buffer");
594 };
595 let mut m = String::with_capacity(48);
596 m.push_str("buffer ");
597 m.push_str(&buf.id().0.to_string());
598 m.push_str(" — ");
599 m.push_str(&buf.line_count().to_string());
600 m.push_str(" line(s)");
601 if buf.is_modified() {
602 m.push_str(" [modified]");
603 }
604 Outcome::did(vec![Negai::Message(m)])
605 }
606}
607
608struct OpenPicker<const COMMANDS: bool>;
624impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
625 type Reads = caps!();
626 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
627 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
628 escriba_madoguchi::PickerSource::Commands
629 } else {
630 escriba_madoguchi::PickerSource::Buffers
631 })])
632 }
633}
634
635struct HelpPicker;
637impl Native for HelpPicker {
638 type Reads = caps!();
639 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
640 Outcome::did(vec![Negai::OpenPicker(
641 escriba_madoguchi::PickerSource::Help,
642 )])
643 }
644}
645
646struct GrepPicker;
652impl Native for GrepPicker {
653 type Reads = caps!();
654 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
655 let pattern = args.join(" ");
656 if pattern.is_empty() {
657 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
658 }
659 Outcome::did(vec![Negai::GrepProject { pattern }])
660 }
661}
662
663struct LspFormat;
672impl Native for LspFormat {
673 type Reads = caps!();
674 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
675 Outcome::did(vec![Negai::FormatBuffer])
676 }
677}
678
679struct WalkPicker<const PROJECT: bool>;
681impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
682 type Reads = caps!();
683 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
684 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
685 escriba_madoguchi::PickerSource::Project
686 } else {
687 escriba_madoguchi::PickerSource::Files
688 })])
689 }
690}
691
692struct ParentPicker;
699impl Native for ParentPicker {
700 type Reads = caps!();
701 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
702 let root = std::env::current_dir()
703 .ok()
704 .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
705 .unwrap_or_else(|| std::path::PathBuf::from(".."));
706 Outcome::did(vec![Negai::OpenPicker(
707 escriba_madoguchi::PickerSource::FilesUnder(root),
708 )])
709 }
710}
711
712struct FindingsPicker<const WORKSPACE: bool>;
718impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
719 type Reads = caps!();
720 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
721 Outcome::did(vec![Negai::OpenPicker(
722 escriba_madoguchi::PickerSource::Findings {
723 workspace: WORKSPACE,
724 },
725 )])
726 }
727}
728
729struct SplitWindow<const STACKED: bool>;
736impl<const STACKED: bool> Native for SplitWindow<STACKED> {
737 type Reads = caps!();
738 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
739 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
740 }
741}
742
743struct CloseWindow;
745impl Native for CloseWindow {
746 type Reads = caps!();
747 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
748 Outcome::did(vec![Negai::CloseWindow])
749 }
750}
751
752struct FocusDir<const DX: i8, const DY: i8>;
758impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
759 type Reads = caps!();
760 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
761 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
762 }
763}
764
765struct ConflictWalk<const FORWARD: bool>;
771impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
772 type Reads = caps!(Buffers);
773 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
774 let b = v.buffers();
775 let Some(buf) = b.active() else {
776 return Outcome::declined("no active buffer");
777 };
778 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
779 if findings.is_empty() {
780 return Outcome::declined("no merge conflicts in this buffer");
781 }
782 Outcome::did(vec![
783 Negai::PublishFindings {
784 list: "conflict".to_string(),
785 findings,
786 },
787 Negai::WalkList {
788 list: "conflict".to_string(),
789 forward: FORWARD,
790 },
791 ])
792 }
793}
794
795struct ChooseSide<const SIDE: u8>;
801impl<const SIDE: u8> Native for ChooseSide<SIDE> {
802 type Reads = caps!(Buffers, Cursor);
803 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
804 use escriba_shirube::conflict::{Side, at, resolution};
805 let b = v.buffers();
806 let Some(buf) = b.active() else {
807 return Outcome::declined("no active buffer");
808 };
809 let text = buf.text();
810 let line = v.cursor().position().line;
811 let Some(c) = at(&text, line) else {
812 return Outcome::declined("not inside a merge conflict");
815 };
816 let side = match SIDE {
817 0 => Side::Ours,
818 1 => Side::Theirs,
819 _ => Side::Both,
820 };
821 let (from, to) = c.lines();
822 Outcome::did(vec![
823 Negai::Edit {
824 buffer: buf.id(),
825 edit: escriba_core::Edit {
826 range: escriba_core::Range::new(
827 escriba_core::Position::new(from, 0),
828 escriba_core::Position::new(to, 0),
829 ),
830 kind: escriba_core::EditKind::Replace {
831 text: resolution(&text, c, side),
832 },
833 },
834 },
835 Negai::SetCursor {
838 buffer: buf.id(),
839 to: escriba_core::Position::new(from, 0),
840 },
841 ])
842 }
843}
844
845struct BufferNext;
846impl Native for BufferNext {
847 type Reads = caps!();
848 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
849 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
850 }
851}
852
853struct BufferPrev;
854impl Native for BufferPrev {
855 type Reads = caps!();
856 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
857 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
858 }
859}
860
861struct BufferDelete;
866impl Native for BufferDelete {
867 type Reads = caps!(Buffers);
868 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
869 match active_or_decline(&v.buffers()) {
870 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
871 Err(o) => o,
872 }
873 }
874}
875
876struct CommentToggle;
883impl Native for CommentToggle {
884 type Reads = caps!(Buffers, Cursor, Syntax);
885 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
886 let Some(ft) = v.syntax().filetype() else {
887 return Outcome::declined("no filetype for this buffer");
888 };
889 let Some(comment) = ft.comment.as_ref() else {
890 let mut m = String::from("no comment syntax for ");
891 m.push_str(&ft.name);
892 return Outcome::declined(m);
893 };
894 let b = v.buffers();
895 let Some(buf) = b.active() else {
896 return Outcome::declined("no active buffer");
897 };
898 let line_no = v.cursor().position().line;
899 let Some(line) = buf.line(line_no) else {
900 return Outcome::declined("cursor past the end of the buffer");
901 };
902 if line.trim().is_empty() {
905 return Outcome::declined("nothing on this line");
906 }
907
908 let indent_len = line.len() - line.trim_start().len();
911 let (indent, body) = line.split_at(indent_len);
912 let toggled = match comment.strip(body) {
913 Some(uncommented) => uncommented.to_string(),
914 None => comment.wrap(body),
915 };
916 let mut text = String::with_capacity(indent.len() + toggled.len());
917 text.push_str(indent);
918 text.push_str(&toggled);
919
920 Outcome::did(vec![Negai::Edit {
921 buffer: buf.id(),
922 edit: escriba_core::Edit {
923 range: escriba_core::Range::new(
924 escriba_core::Position::new(line_no, 0),
925 escriba_core::Position::new(
926 line_no,
927 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
928 ),
929 ),
930 kind: escriba_core::EditKind::Replace { text },
931 },
932 }])
933 }
934}
935
936struct TodoWalk<const FORWARD: bool>;
948impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
949 type Reads = caps!(Buffers);
950 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
951 let b = v.buffers();
952 let Some(buf) = b.active() else {
953 return Outcome::declined("no active buffer");
954 };
955 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
956 if findings.is_empty() {
957 return Outcome::declined("no TODO markers in this buffer");
958 }
959 Outcome::did(vec![
960 Negai::PublishFindings {
961 list: "todo".to_string(),
962 findings,
963 },
964 Negai::WalkList {
965 list: "todo".to_string(),
966 forward: FORWARD,
967 },
968 ])
969 }
970}
971
972struct Quit;
978impl Native for Quit {
979 type Reads = caps!();
980 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
981 Outcome::did(vec![Negai::Quit])
982 }
983}
984
985fn no_write_since_last_change(what: &str) -> Outcome {
988 let mut m = String::with_capacity(64);
989 m.push_str("E37: No write since last change in ");
990 m.push_str(what);
991 m.push_str(" (add ! to override)");
992 Outcome::declined(m)
993}
994
995fn buffer_label(b: &dyn BufferView) -> String {
998 b.path()
999 .map_or_else(|| "[No Name]".to_string(), |p| p.display().to_string())
1000}
1001
1002struct QuitChecked<const ALL: bool>;
1009impl<const ALL: bool> Native for QuitChecked<ALL> {
1010 type Reads = caps!(Buffers);
1011 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1012 let b = v.buffers();
1013 let blocker = if ALL {
1016 b.ids()
1017 .into_iter()
1018 .filter_map(|id| b.get(id))
1019 .find(|x| x.is_modified())
1020 .map(buffer_label)
1021 } else {
1022 b.active().filter(|x| x.is_modified()).map(buffer_label)
1023 };
1024 match blocker {
1025 Some(what) => no_write_since_last_change(&what),
1026 None => Outcome::did(vec![Negai::Quit]),
1027 }
1028 }
1029}
1030
1031struct WriteQuit;
1038impl Native for WriteQuit {
1039 type Reads = caps!(Buffers);
1040 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1041 let b = v.buffers();
1042 let Some(buf) = b.active() else {
1043 return Outcome::declined("no active buffer");
1044 };
1045 if buf.path().is_none() {
1046 return Outcome::declined("E32: No file name");
1047 }
1048 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1049 }
1050}
1051
1052struct ExitWrite;
1058impl Native for ExitWrite {
1059 type Reads = caps!(Buffers);
1060 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1061 let b = v.buffers();
1062 let Some(buf) = b.active() else {
1063 return Outcome::declined("no active buffer");
1064 };
1065 if !buf.is_modified() {
1066 return Outcome::did(vec![Negai::Quit]);
1067 }
1068 if buf.path().is_none() {
1069 return Outcome::declined("E32: No file name");
1070 }
1071 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1072 }
1073}
1074
1075struct WriteQuitAll;
1084impl Native for WriteQuitAll {
1085 type Reads = caps!(Buffers);
1086 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1087 let b = v.buffers();
1088 let mut slips: Vec<Negai> = b
1089 .ids()
1090 .into_iter()
1091 .filter(|id| {
1092 b.get(*id)
1093 .is_some_and(|x| x.is_modified() && x.path().is_some())
1094 })
1095 .map(|buffer| Negai::Save { buffer })
1096 .collect();
1097 slips.push(Negai::Quit);
1098 Outcome::did(slips)
1099 }
1100}
1101
1102struct Noh;
1110impl Native for Noh {
1111 type Reads = caps!();
1112 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1113 Outcome::did(vec![Negai::ClearSearchHighlight])
1114 }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120 use escriba_core::BufferId;
1121 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
1122
1123 fn dirty_file() -> FakeSnapshot {
1125 let mut s = FakeSnapshot::default();
1126 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
1127 s.active = Some(BufferId(1));
1128 s
1129 }
1130
1131 #[test]
1132 fn default_set_is_populated() {
1133 let r = CommandRegistry::default_set();
1134 let names = r.names();
1135 assert!(names.contains(&"save"));
1136 assert!(names.contains(&"quit"));
1137 }
1138
1139 #[test]
1140 fn specs_are_sorted() {
1141 let r = CommandRegistry::default_set();
1142 let specs = r.specs();
1143 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
1144 }
1145
1146 #[test]
1147 fn not_found_errors() {
1148 let r = CommandRegistry::new();
1151 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
1152 assert!(matches!(err, CommandError::NotFound(_)));
1153 }
1154
1155 #[test]
1156 fn a_command_asks_rather_than_acts() {
1157 let mut r = CommandRegistry::new();
1164 r.register(Command::action(
1165 "w-all",
1166 "Write every modified buffer",
1167 "buffer.write-all",
1168 ));
1169 let out = r
1170 .run("w-all", &dirty_file(), &[])
1171 .expect("registered command dispatches");
1172 assert_eq!(
1173 out.slips,
1174 vec![Negai::Save {
1175 buffer: BufferId(1)
1176 }]
1177 );
1178 assert_eq!(out.verdict, Verdict::Did);
1179 }
1180
1181 #[test]
1182 fn nothing_to_save_declines_rather_than_claiming_success() {
1183 let mut r = CommandRegistry::new();
1187 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
1188 let out = r
1189 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1190 .expect("dispatches");
1191 assert!(out.slips.is_empty());
1192 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1193 }
1194
1195 #[test]
1196 fn no_active_buffer_declines_rather_than_failing() {
1197 let mut r = CommandRegistry::new();
1200 r.register(Command::action("w", "Save", "buffer.save"));
1201 let out = r
1202 .run("w", &FakeSnapshot::default(), &[])
1203 .expect("dispatches");
1204 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1205 assert!(out.slips.is_empty(), "a decline asks for nothing");
1206 }
1207
1208 #[test]
1209 fn unknown_action_symbol_is_reported_not_silent() {
1210 let mut r = CommandRegistry::new();
1219 r.register(Command::action("pick", "Pick a file", "picker.files"));
1220 let err = r
1221 .run("pick", &FakeSnapshot::default(), &[])
1222 .expect_err("an unimplemented action must report, not report success");
1223 assert!(
1224 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1225 "expected Unhandled(picker.files), got {err:?}",
1226 );
1227 assert!(r.contains("pick"), "the command survives its own failure");
1228 }
1229
1230 #[test]
1231 fn action_naming_a_command_is_inert_not_recursive() {
1232 let mut r = CommandRegistry::new();
1240 r.register(Command::action("alias", "aliases save by name", "save"));
1241 let err = r
1242 .run("alias", &dirty_file(), &[])
1243 .expect_err("a command-name alias resolves nothing, and says so");
1244 assert!(
1245 matches!(&err, CommandError::Unhandled(s) if s == "save"),
1246 "expected Unhandled(save), got {err:?}",
1247 );
1248 }
1249
1250 #[test]
1251 fn quit_is_a_request_not_a_flag_poke() {
1252 let mut r = CommandRegistry::new();
1257 r.register(Command::action("bye", "Quit", "editor.quit"));
1258 let out = r
1259 .run("bye", &FakeSnapshot::default(), &[])
1260 .expect("dispatches");
1261 assert_eq!(out.slips, vec![Negai::Quit]);
1262 }
1263
1264 #[test]
1265 fn buffer_info_speaks_through_a_slip_not_stderr() {
1266 let mut r = CommandRegistry::new();
1270 r.register(Command::action("info", "Buffer info", "buffer.info"));
1271 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1272 let Some(Negai::Message(m)) = out.slips.first() else {
1273 panic!("expected a Message slip, got {:?}", out.slips);
1274 };
1275 assert!(m.contains("buffer 1"), "{m}");
1276 assert!(m.contains("[modified]"), "{m}");
1277 }
1278}