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 "dap.toggle-breakpoint",
244 "Set or clear a breakpoint on the cursor's line",
245 erase::<DapToggleBreakpoint>(),
246 ));
247 r.register(Command::native(
248 "picker.files",
249 "Pick a file under the working directory",
250 erase::<WalkPicker<false>>(),
251 ));
252 r.register(Command::native(
253 "picker.project",
254 "Pick a project root",
255 erase::<WalkPicker<true>>(),
256 ));
257 r.register(Command::native(
262 "files.open",
263 "Browse files under the working directory",
264 erase::<WalkPicker<false>>(),
265 ));
266 r.register(Command::native(
267 "files.open-parent",
268 "Browse files from the parent directory",
269 erase::<ParentPicker>(),
270 ));
271 r.register(Command::native(
273 "trouble.toggle",
274 "Show located findings",
275 erase::<FindingsPicker<true>>(),
276 ));
277 r.register(Command::native(
278 "trouble.workspace",
279 "Show findings across the workspace",
280 erase::<FindingsPicker<true>>(),
281 ));
282 r.register(Command::native(
283 "trouble.document",
284 "Show findings in this buffer",
285 erase::<FindingsPicker<false>>(),
286 ));
287 r.register(Command::native(
288 "window.split",
289 "Split the window horizontally (:sp)",
290 erase::<SplitWindow<true>>(),
291 ));
292 r.register(Command::native(
293 "window.vsplit",
294 "Split the window vertically (:vsp)",
295 erase::<SplitWindow<false>>(),
296 ));
297 r.register(Command::native(
298 "window.close",
299 "Close the active window (:close)",
300 erase::<CloseWindow>(),
301 ));
302 r.register(Command::native(
303 "pane.left",
304 "Focus the window to the left",
305 erase::<FocusDir<-1, 0>>(),
306 ));
307 r.register(Command::native(
308 "pane.right",
309 "Focus the window to the right",
310 erase::<FocusDir<1, 0>>(),
311 ));
312 r.register(Command::native(
313 "pane.up",
314 "Focus the window above",
315 erase::<FocusDir<0, -1>>(),
316 ));
317 r.register(Command::native(
318 "pane.down",
319 "Focus the window below",
320 erase::<FocusDir<0, 1>>(),
321 ));
322 r.register(Command::native(
323 "conflict.next",
324 "Go to the next merge conflict",
325 erase::<ConflictWalk<true>>(),
326 ));
327 r.register(Command::native(
328 "conflict.prev",
329 "Go to the previous merge conflict",
330 erase::<ConflictWalk<false>>(),
331 ));
332 r.register(Command::native(
333 "conflict.choose-ours",
334 "Resolve the conflict keeping ours",
335 erase::<ChooseSide<0>>(),
336 ));
337 r.register(Command::native(
338 "conflict.choose-theirs",
339 "Resolve the conflict keeping theirs",
340 erase::<ChooseSide<1>>(),
341 ));
342 r.register(Command::native(
343 "conflict.choose-both",
344 "Resolve the conflict keeping both",
345 erase::<ChooseSide<2>>(),
346 ));
347 r.register(Command::native(
348 "todo.next",
349 "Go to the next TODO/FIXME marker",
350 erase::<TodoWalk<true>>(),
351 ));
352 r.register(Command::native(
353 "todo.prev",
354 "Go to the previous TODO/FIXME marker",
355 erase::<TodoWalk<false>>(),
356 ));
357 for name in ["comment.toggle-line", "comment.toggle-block"] {
358 r.register(Command::native(
359 name,
360 "Toggle the comment on the current line",
361 erase::<CommentToggle>(),
362 ));
363 }
364 for alias in ["noh", "nohl", "nohlsearch"] {
365 r.register(Command::action(
366 alias,
367 "Stop highlighting matches, keep the pattern",
368 "search.clear-highlight",
369 ));
370 }
371 r.register(Command::native(
372 "undo",
373 "Undo the last change",
374 erase::<Undo>(),
375 ));
376 r.register(Command::native(
377 "redo",
378 "Redo the last undone change",
379 erase::<Redo>(),
380 ));
381 r.register(Command::native(
382 "buffer-info",
383 "Print the active buffer summary",
384 erase::<Info>(),
385 ));
386 r
387 }
388
389 pub fn register(&mut self, command: Command) {
390 self.commands.insert(command.name.clone(), command);
391 }
392
393 #[must_use]
396 pub fn contains(&self, name: &str) -> bool {
397 self.commands.contains_key(name)
398 }
399
400 #[must_use]
402 pub fn len(&self) -> usize {
403 self.commands.len()
404 }
405
406 #[must_use]
408 pub fn is_empty(&self) -> bool {
409 self.commands.is_empty()
410 }
411
412 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
418 self.run_bounded(name, snap, args, ALIAS_FUEL)
419 }
420
421 fn run_bounded(
434 &self,
435 name: &str,
436 snap: &dyn Snapshot,
437 args: &[String],
438 fuel: u8,
439 ) -> Result<Outcome> {
440 let Some(fuel) = fuel.checked_sub(1) else {
441 return Err(CommandError::AliasCycle(name.to_string()));
442 };
443 let cmd = self
444 .commands
445 .get(name)
446 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
447 match &cmd.handler {
448 Handler::Native(f) => Ok(f(snap, args)),
449 Handler::Action(sym) => match builtin_action(sym) {
450 Some(f) => Ok(f(snap, args)),
451 None if sym != name && self.commands.contains_key(sym.as_str()) => {
455 self.run_bounded(sym, snap, args, fuel)
456 }
457 None => Err(CommandError::Unhandled(sym.to_string())),
458 },
459 }
460 }
461
462 #[must_use]
463 pub fn names(&self) -> Vec<&str> {
464 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
465 v.sort_unstable();
466 v
467 }
468
469 #[must_use]
470 pub fn specs(&self) -> Vec<CommandSpec> {
471 let mut out: Vec<CommandSpec> = self
472 .commands
473 .values()
474 .map(|c| CommandSpec {
475 name: c.name.to_string(),
476 description: c.description.to_string(),
477 args: Vec::new(),
478 })
479 .collect();
480 out.sort_by(|a, b| a.name.cmp(&b.name));
481 out
482 }
483}
484
485const ALIAS_FUEL: u8 = 8;
490
491fn builtin_action(sym: &str) -> Option<CommandFn> {
497 Some(match sym {
498 "buffer.save" | "buffer.write" => erase::<Save>(),
499 "buffer.write-all" => erase::<WriteAll>(),
500 "buffer.undo" => erase::<Undo>(),
501 "buffer.redo" => erase::<Redo>(),
502 "buffer.info" => erase::<Info>(),
503 "editor.quit" => erase::<Quit>(),
504 "search.clear-highlight" => erase::<Noh>(),
505 _ => return None,
512 })
513}
514
515fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
520 b.active()
521 .map(BufferView::id)
522 .ok_or_else(|| Outcome::declined("no active buffer"))
523}
524
525type Result2<T> = std::result::Result<T, Outcome>;
526
527struct WriteAll;
533impl Native for WriteAll {
534 type Reads = caps!(Buffers);
535 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
536 let b = v.buffers();
537 let slips: Vec<Negai> = b
538 .ids()
539 .into_iter()
540 .filter(|id| {
541 b.get(*id)
542 .is_some_and(|x| x.is_modified() && x.path().is_some())
543 })
544 .map(|buffer| Negai::Save { buffer })
545 .collect();
546 if slips.is_empty() {
547 return Outcome::declined("no modified files");
548 }
549 Outcome::did(slips)
550 }
551}
552
553struct Save;
554impl Native for Save {
555 type Reads = caps!(Buffers);
556 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
557 match active_or_decline(&v.buffers()) {
558 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
559 Err(o) => o,
560 }
561 }
562}
563
564struct Undo;
565impl Native for Undo {
566 type Reads = caps!(Buffers);
567 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
568 match active_or_decline(&v.buffers()) {
569 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
570 Err(o) => o,
571 }
572 }
573}
574
575struct Redo;
576impl Native for Redo {
577 type Reads = caps!(Buffers);
578 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
579 match active_or_decline(&v.buffers()) {
580 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
581 Err(o) => o,
582 }
583 }
584}
585
586struct Info;
593impl Native for Info {
594 type Reads = caps!(Buffers);
595 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
596 let b = v.buffers();
597 let Some(buf) = b.active() else {
598 return Outcome::declined("no active buffer");
599 };
600 let mut m = String::with_capacity(48);
601 m.push_str("buffer ");
602 m.push_str(&buf.id().0.to_string());
603 m.push_str(" — ");
604 m.push_str(&buf.line_count().to_string());
605 m.push_str(" line(s)");
606 if buf.is_modified() {
607 m.push_str(" [modified]");
608 }
609 Outcome::did(vec![Negai::Message(m)])
610 }
611}
612
613struct OpenPicker<const COMMANDS: bool>;
629impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
630 type Reads = caps!();
631 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
632 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
633 escriba_madoguchi::PickerSource::Commands
634 } else {
635 escriba_madoguchi::PickerSource::Buffers
636 })])
637 }
638}
639
640struct HelpPicker;
642impl Native for HelpPicker {
643 type Reads = caps!();
644 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
645 Outcome::did(vec![Negai::OpenPicker(
646 escriba_madoguchi::PickerSource::Help,
647 )])
648 }
649}
650
651struct GrepPicker;
657impl Native for GrepPicker {
658 type Reads = caps!();
659 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
660 let pattern = args.join(" ");
661 if pattern.is_empty() {
662 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
663 }
664 Outcome::did(vec![Negai::GrepProject { pattern }])
665 }
666}
667
668struct LspFormat;
677impl Native for LspFormat {
678 type Reads = caps!();
679 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
680 Outcome::did(vec![Negai::FormatBuffer])
681 }
682}
683
684struct DapToggleBreakpoint;
698impl Native for DapToggleBreakpoint {
699 type Reads = caps!();
700 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
701 Outcome::did(vec![Negai::ToggleBreakpoint])
702 }
703}
704
705struct WalkPicker<const PROJECT: bool>;
707impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
708 type Reads = caps!();
709 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
710 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
711 escriba_madoguchi::PickerSource::Project
712 } else {
713 escriba_madoguchi::PickerSource::Files
714 })])
715 }
716}
717
718struct ParentPicker;
725impl Native for ParentPicker {
726 type Reads = caps!();
727 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
728 let root = std::env::current_dir()
729 .ok()
730 .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
731 .unwrap_or_else(|| std::path::PathBuf::from(".."));
732 Outcome::did(vec![Negai::OpenPicker(
733 escriba_madoguchi::PickerSource::FilesUnder(root),
734 )])
735 }
736}
737
738struct FindingsPicker<const WORKSPACE: bool>;
744impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
745 type Reads = caps!();
746 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
747 Outcome::did(vec![Negai::OpenPicker(
748 escriba_madoguchi::PickerSource::Findings {
749 workspace: WORKSPACE,
750 },
751 )])
752 }
753}
754
755struct SplitWindow<const STACKED: bool>;
762impl<const STACKED: bool> Native for SplitWindow<STACKED> {
763 type Reads = caps!();
764 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
765 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
766 }
767}
768
769struct CloseWindow;
771impl Native for CloseWindow {
772 type Reads = caps!();
773 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
774 Outcome::did(vec![Negai::CloseWindow])
775 }
776}
777
778struct FocusDir<const DX: i8, const DY: i8>;
784impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
785 type Reads = caps!();
786 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
787 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
788 }
789}
790
791struct ConflictWalk<const FORWARD: bool>;
797impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
798 type Reads = caps!(Buffers);
799 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
800 let b = v.buffers();
801 let Some(buf) = b.active() else {
802 return Outcome::declined("no active buffer");
803 };
804 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
805 if findings.is_empty() {
806 return Outcome::declined("no merge conflicts in this buffer");
807 }
808 Outcome::did(vec![
809 Negai::PublishFindings {
810 list: "conflict".to_string(),
811 findings,
812 },
813 Negai::WalkList {
814 list: "conflict".to_string(),
815 forward: FORWARD,
816 },
817 ])
818 }
819}
820
821struct ChooseSide<const SIDE: u8>;
827impl<const SIDE: u8> Native for ChooseSide<SIDE> {
828 type Reads = caps!(Buffers, Cursor);
829 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
830 use escriba_shirube::conflict::{Side, at, resolution};
831 let b = v.buffers();
832 let Some(buf) = b.active() else {
833 return Outcome::declined("no active buffer");
834 };
835 let text = buf.text();
836 let line = v.cursor().position().line;
837 let Some(c) = at(&text, line) else {
838 return Outcome::declined("not inside a merge conflict");
841 };
842 let side = match SIDE {
843 0 => Side::Ours,
844 1 => Side::Theirs,
845 _ => Side::Both,
846 };
847 let (from, to) = c.lines();
848 Outcome::did(vec![
849 Negai::Edit {
850 buffer: buf.id(),
851 edit: escriba_core::Edit {
852 range: escriba_core::Range::new(
853 escriba_core::Position::new(from, 0),
854 escriba_core::Position::new(to, 0),
855 ),
856 kind: escriba_core::EditKind::Replace {
857 text: resolution(&text, c, side),
858 },
859 },
860 },
861 Negai::SetCursor {
864 buffer: buf.id(),
865 to: escriba_core::Position::new(from, 0),
866 },
867 ])
868 }
869}
870
871struct BufferNext;
872impl Native for BufferNext {
873 type Reads = caps!();
874 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
875 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
876 }
877}
878
879struct BufferPrev;
880impl Native for BufferPrev {
881 type Reads = caps!();
882 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
883 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
884 }
885}
886
887struct BufferDelete;
892impl Native for BufferDelete {
893 type Reads = caps!(Buffers);
894 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
895 match active_or_decline(&v.buffers()) {
896 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
897 Err(o) => o,
898 }
899 }
900}
901
902struct CommentToggle;
909impl Native for CommentToggle {
910 type Reads = caps!(Buffers, Cursor, Syntax);
911 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
912 let Some(ft) = v.syntax().filetype() else {
913 return Outcome::declined("no filetype for this buffer");
914 };
915 let Some(comment) = ft.comment.as_ref() else {
916 let mut m = String::from("no comment syntax for ");
917 m.push_str(&ft.name);
918 return Outcome::declined(m);
919 };
920 let b = v.buffers();
921 let Some(buf) = b.active() else {
922 return Outcome::declined("no active buffer");
923 };
924 let line_no = v.cursor().position().line;
925 let Some(line) = buf.line(line_no) else {
926 return Outcome::declined("cursor past the end of the buffer");
927 };
928 if line.trim().is_empty() {
931 return Outcome::declined("nothing on this line");
932 }
933
934 let indent_len = line.len() - line.trim_start().len();
937 let (indent, body) = line.split_at(indent_len);
938 let toggled = match comment.strip(body) {
939 Some(uncommented) => uncommented.to_string(),
940 None => comment.wrap(body),
941 };
942 let mut text = String::with_capacity(indent.len() + toggled.len());
943 text.push_str(indent);
944 text.push_str(&toggled);
945
946 Outcome::did(vec![Negai::Edit {
947 buffer: buf.id(),
948 edit: escriba_core::Edit {
949 range: escriba_core::Range::new(
950 escriba_core::Position::new(line_no, 0),
951 escriba_core::Position::new(
952 line_no,
953 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
954 ),
955 ),
956 kind: escriba_core::EditKind::Replace { text },
957 },
958 }])
959 }
960}
961
962struct TodoWalk<const FORWARD: bool>;
974impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
975 type Reads = caps!(Buffers);
976 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
977 let b = v.buffers();
978 let Some(buf) = b.active() else {
979 return Outcome::declined("no active buffer");
980 };
981 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
982 if findings.is_empty() {
983 return Outcome::declined("no TODO markers in this buffer");
984 }
985 Outcome::did(vec![
986 Negai::PublishFindings {
987 list: "todo".to_string(),
988 findings,
989 },
990 Negai::WalkList {
991 list: "todo".to_string(),
992 forward: FORWARD,
993 },
994 ])
995 }
996}
997
998struct Quit;
1004impl Native for Quit {
1005 type Reads = caps!();
1006 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1007 Outcome::did(vec![Negai::Quit])
1008 }
1009}
1010
1011fn no_write_since_last_change(what: &str) -> Outcome {
1014 let mut m = String::with_capacity(64);
1015 m.push_str("E37: No write since last change in ");
1016 m.push_str(what);
1017 m.push_str(" (add ! to override)");
1018 Outcome::declined(m)
1019}
1020
1021fn buffer_label(b: &dyn BufferView) -> String {
1024 b.path()
1025 .map_or_else(|| "[No Name]".to_string(), |p| p.display().to_string())
1026}
1027
1028struct QuitChecked<const ALL: bool>;
1035impl<const ALL: bool> Native for QuitChecked<ALL> {
1036 type Reads = caps!(Buffers);
1037 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1038 let b = v.buffers();
1039 let blocker = if ALL {
1042 b.ids()
1043 .into_iter()
1044 .filter_map(|id| b.get(id))
1045 .find(|x| x.is_modified())
1046 .map(buffer_label)
1047 } else {
1048 b.active().filter(|x| x.is_modified()).map(buffer_label)
1049 };
1050 match blocker {
1051 Some(what) => no_write_since_last_change(&what),
1052 None => Outcome::did(vec![Negai::Quit]),
1053 }
1054 }
1055}
1056
1057struct WriteQuit;
1064impl Native for WriteQuit {
1065 type Reads = caps!(Buffers);
1066 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1067 let b = v.buffers();
1068 let Some(buf) = b.active() else {
1069 return Outcome::declined("no active buffer");
1070 };
1071 if buf.path().is_none() {
1072 return Outcome::declined("E32: No file name");
1073 }
1074 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1075 }
1076}
1077
1078struct ExitWrite;
1084impl Native for ExitWrite {
1085 type Reads = caps!(Buffers);
1086 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1087 let b = v.buffers();
1088 let Some(buf) = b.active() else {
1089 return Outcome::declined("no active buffer");
1090 };
1091 if !buf.is_modified() {
1092 return Outcome::did(vec![Negai::Quit]);
1093 }
1094 if buf.path().is_none() {
1095 return Outcome::declined("E32: No file name");
1096 }
1097 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1098 }
1099}
1100
1101struct WriteQuitAll;
1110impl Native for WriteQuitAll {
1111 type Reads = caps!(Buffers);
1112 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1113 let b = v.buffers();
1114 let mut slips: Vec<Negai> = b
1115 .ids()
1116 .into_iter()
1117 .filter(|id| {
1118 b.get(*id)
1119 .is_some_and(|x| x.is_modified() && x.path().is_some())
1120 })
1121 .map(|buffer| Negai::Save { buffer })
1122 .collect();
1123 slips.push(Negai::Quit);
1124 Outcome::did(slips)
1125 }
1126}
1127
1128struct Noh;
1136impl Native for Noh {
1137 type Reads = caps!();
1138 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1139 Outcome::did(vec![Negai::ClearSearchHighlight])
1140 }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use escriba_core::BufferId;
1147 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
1148
1149 fn dirty_file() -> FakeSnapshot {
1151 let mut s = FakeSnapshot::default();
1152 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
1153 s.active = Some(BufferId(1));
1154 s
1155 }
1156
1157 #[test]
1158 fn default_set_is_populated() {
1159 let r = CommandRegistry::default_set();
1160 let names = r.names();
1161 assert!(names.contains(&"save"));
1162 assert!(names.contains(&"quit"));
1163 }
1164
1165 #[test]
1166 fn specs_are_sorted() {
1167 let r = CommandRegistry::default_set();
1168 let specs = r.specs();
1169 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
1170 }
1171
1172 #[test]
1173 fn not_found_errors() {
1174 let r = CommandRegistry::new();
1177 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
1178 assert!(matches!(err, CommandError::NotFound(_)));
1179 }
1180
1181 #[test]
1182 fn a_command_asks_rather_than_acts() {
1183 let mut r = CommandRegistry::new();
1190 r.register(Command::action(
1191 "w-all",
1192 "Write every modified buffer",
1193 "buffer.write-all",
1194 ));
1195 let out = r
1196 .run("w-all", &dirty_file(), &[])
1197 .expect("registered command dispatches");
1198 assert_eq!(
1199 out.slips,
1200 vec![Negai::Save {
1201 buffer: BufferId(1)
1202 }]
1203 );
1204 assert_eq!(out.verdict, Verdict::Did);
1205 }
1206
1207 #[test]
1208 fn nothing_to_save_declines_rather_than_claiming_success() {
1209 let mut r = CommandRegistry::new();
1213 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
1214 let out = r
1215 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1216 .expect("dispatches");
1217 assert!(out.slips.is_empty());
1218 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1219 }
1220
1221 #[test]
1222 fn no_active_buffer_declines_rather_than_failing() {
1223 let mut r = CommandRegistry::new();
1226 r.register(Command::action("w", "Save", "buffer.save"));
1227 let out = r
1228 .run("w", &FakeSnapshot::default(), &[])
1229 .expect("dispatches");
1230 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1231 assert!(out.slips.is_empty(), "a decline asks for nothing");
1232 }
1233
1234 #[test]
1235 fn unknown_action_symbol_is_reported_not_silent() {
1236 let mut r = CommandRegistry::new();
1245 r.register(Command::action("pick", "Pick a file", "picker.files"));
1246 let err = r
1247 .run("pick", &FakeSnapshot::default(), &[])
1248 .expect_err("an unimplemented action must report, not report success");
1249 assert!(
1250 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1251 "expected Unhandled(picker.files), got {err:?}",
1252 );
1253 assert!(r.contains("pick"), "the command survives its own failure");
1254 }
1255
1256 #[test]
1257 fn action_naming_a_command_is_inert_not_recursive() {
1258 let mut r = CommandRegistry::new();
1266 r.register(Command::action("alias", "aliases save by name", "save"));
1267 let err = r
1268 .run("alias", &dirty_file(), &[])
1269 .expect_err("a command-name alias resolves nothing, and says so");
1270 assert!(
1271 matches!(&err, CommandError::Unhandled(s) if s == "save"),
1272 "expected Unhandled(save), got {err:?}",
1273 );
1274 }
1275
1276 #[test]
1277 fn quit_is_a_request_not_a_flag_poke() {
1278 let mut r = CommandRegistry::new();
1283 r.register(Command::action("bye", "Quit", "editor.quit"));
1284 let out = r
1285 .run("bye", &FakeSnapshot::default(), &[])
1286 .expect("dispatches");
1287 assert_eq!(out.slips, vec![Negai::Quit]);
1288 }
1289
1290 #[test]
1291 fn buffer_info_speaks_through_a_slip_not_stderr() {
1292 let mut r = CommandRegistry::new();
1296 r.register(Command::action("info", "Buffer info", "buffer.info"));
1297 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1298 let Some(Negai::Message(m)) = out.slips.first() else {
1299 panic!("expected a Message slip, got {:?}", out.slips);
1300 };
1301 assert!(m.contains("buffer 1"), "{m}");
1302 assert!(m.contains("[modified]"), "{m}");
1303 }
1304}