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 Blue(String),
78}
79
80#[derive(Debug, Clone)]
81pub struct Command {
82 pub name: String,
83 pub description: String,
84 pub handler: Handler,
85}
86
87impl Command {
88 pub fn native(
90 name: impl Into<String>,
91 description: impl Into<String>,
92 handler: CommandFn,
93 ) -> Self {
94 Self {
95 name: name.into(),
96 description: description.into(),
97 handler: Handler::Native(handler),
98 }
99 }
100
101 pub fn action(
105 name: impl Into<String>,
106 description: impl Into<String>,
107 action: impl Into<String>,
108 ) -> Self {
109 Self {
110 name: name.into(),
111 description: description.into(),
112 handler: Handler::Action(action.into()),
113 }
114 }
115
116 pub fn blue(
117 name: impl Into<String>,
118 description: impl Into<String>,
119 function: impl Into<String>,
120 ) -> Self {
121 Self {
122 name: name.into(),
123 description: description.into(),
124 handler: Handler::Blue(function.into()),
125 }
126 }
127}
128
129fn run_blue_source(_: &dyn Snapshot, args: &[String]) -> Outcome {
130 Outcome::did([Negai::RunBlue(args.join(" "))])
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
134pub struct CommandSpec {
135 pub name: String,
136 pub description: String,
137 #[serde(default)]
138 pub args: Vec<CommandArgSpec>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct CommandArgSpec {
143 pub name: String,
144 pub description: String,
145 #[serde(default)]
146 pub required: bool,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub variants: Vec<String>,
149}
150
151#[derive(Debug, Default, Clone)]
152pub struct CommandRegistry {
153 commands: HashMap<String, Command>,
154}
155
156impl CommandRegistry {
157 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 #[must_use]
163 pub fn default_set() -> Self {
164 let mut r = Self::new();
165 r.register(Command::native(
166 "blue",
167 "Run a line of blue against the editor",
168 run_blue_source,
169 ));
170 r.register(Command::native(
171 "save",
172 "Write the active buffer to disk",
173 erase::<Save>(),
174 ));
175 r.register(Command::native(
181 "quit",
182 "Exit the editor, unless a buffer is modified",
183 erase::<QuitChecked<false>>(),
184 ));
185 r.register(Command::native(
186 "quit!",
187 "Exit the editor, discarding unsaved changes",
188 erase::<Quit>(),
189 ));
190 r.register(Command::native(
191 "quit-all",
192 "Exit the editor, unless any buffer is modified",
193 erase::<QuitChecked<true>>(),
194 ));
195 r.register(Command::native(
196 "quit-all!",
197 "Exit the editor, discarding every unsaved change",
198 erase::<Quit>(),
199 ));
200 r.register(Command::native(
201 "write-quit",
202 "Write the active buffer, then exit",
203 erase::<WriteQuit>(),
204 ));
205 r.register(Command::native(
206 "write-quit-all",
207 "Write every modified buffer, then exit",
208 erase::<WriteQuitAll>(),
209 ));
210 r.register(Command::native(
211 "exit-write",
212 "Write the active buffer if modified, then exit",
213 erase::<ExitWrite>(),
214 ));
215 r.register(Command::native(
216 "buffer.write-all",
217 "Write every modified buffer",
218 erase::<WriteAll>(),
219 ));
220 r.register(Command::native(
225 "buffer.next",
226 "Go to the next buffer",
227 erase::<BufferNext>(),
228 ));
229 r.register(Command::native(
230 "buffer.prev",
231 "Go to the previous buffer",
232 erase::<BufferPrev>(),
233 ));
234 r.register(Command::native(
235 "buffer.delete",
236 "Close the active buffer",
237 erase::<BufferDelete>(),
238 ));
239 r.register(Command::native(
240 "picker.buffers",
241 "Pick an open buffer",
242 erase::<OpenPicker<false>>(),
243 ));
244 r.register(Command::native(
245 "picker.commands",
246 "Pick a command",
247 erase::<OpenPicker<true>>(),
248 ));
249 r.register(Command::native(
250 "picker.help",
251 "Search every keybinding",
252 erase::<HelpPicker>(),
253 ));
254 r.register(Command::native(
255 "picker.grep",
256 "Search the project for a pattern",
257 erase::<GrepPicker>(),
258 ));
259 r.register(Command::native(
260 "lsp.format",
261 "Format the buffer through its language server",
262 erase::<LspFormat>(),
263 ));
264 r.register(Command::native(
265 "dap.toggle-breakpoint",
266 "Set or clear a breakpoint on the cursor's line",
267 erase::<DapToggleBreakpoint>(),
268 ));
269 r.register(Command::native(
270 "picker.files",
271 "Pick a file under the working directory",
272 erase::<WalkPicker<false>>(),
273 ));
274 r.register(Command::native(
275 "picker.project",
276 "Pick a project root",
277 erase::<WalkPicker<true>>(),
278 ));
279 r.register(Command::native(
284 "files.open",
285 "Browse files under the working directory",
286 erase::<WalkPicker<false>>(),
287 ));
288 r.register(Command::native(
289 "files.open-parent",
290 "Browse files from the parent directory",
291 erase::<ParentPicker>(),
292 ));
293 r.register(Command::native(
295 "trouble.toggle",
296 "Show located findings",
297 erase::<FindingsPicker<true>>(),
298 ));
299 r.register(Command::native(
300 "trouble.workspace",
301 "Show findings across the workspace",
302 erase::<FindingsPicker<true>>(),
303 ));
304 r.register(Command::native(
305 "trouble.document",
306 "Show findings in this buffer",
307 erase::<FindingsPicker<false>>(),
308 ));
309 r.register(Command::native(
310 "window.split",
311 "Split the window horizontally (:sp)",
312 erase::<SplitWindow<true>>(),
313 ));
314 r.register(Command::native(
315 "window.vsplit",
316 "Split the window vertically (:vsp)",
317 erase::<SplitWindow<false>>(),
318 ));
319 r.register(Command::native(
320 "window.close",
321 "Close the active window (:close)",
322 erase::<CloseWindow>(),
323 ));
324 r.register(Command::native(
325 "pane.left",
326 "Focus the window to the left",
327 erase::<FocusDir<-1, 0>>(),
328 ));
329 r.register(Command::native(
330 "pane.right",
331 "Focus the window to the right",
332 erase::<FocusDir<1, 0>>(),
333 ));
334 r.register(Command::native(
335 "pane.up",
336 "Focus the window above",
337 erase::<FocusDir<0, -1>>(),
338 ));
339 r.register(Command::native(
340 "pane.down",
341 "Focus the window below",
342 erase::<FocusDir<0, 1>>(),
343 ));
344 r.register(Command::native(
345 "conflict.next",
346 "Go to the next merge conflict",
347 erase::<ConflictWalk<true>>(),
348 ));
349 r.register(Command::native(
350 "conflict.prev",
351 "Go to the previous merge conflict",
352 erase::<ConflictWalk<false>>(),
353 ));
354 r.register(Command::native(
355 "conflict.choose-ours",
356 "Resolve the conflict keeping ours",
357 erase::<ChooseSide<0>>(),
358 ));
359 r.register(Command::native(
360 "conflict.choose-theirs",
361 "Resolve the conflict keeping theirs",
362 erase::<ChooseSide<1>>(),
363 ));
364 r.register(Command::native(
365 "conflict.choose-both",
366 "Resolve the conflict keeping both",
367 erase::<ChooseSide<2>>(),
368 ));
369 r.register(Command::native(
370 "todo.next",
371 "Go to the next TODO/FIXME marker",
372 erase::<TodoWalk<true>>(),
373 ));
374 r.register(Command::native(
375 "todo.prev",
376 "Go to the previous TODO/FIXME marker",
377 erase::<TodoWalk<false>>(),
378 ));
379 for name in ["comment.toggle-line", "comment.toggle-block"] {
380 r.register(Command::native(
381 name,
382 "Toggle the comment on the current line",
383 erase::<CommentToggle>(),
384 ));
385 }
386 for alias in ["noh", "nohl", "nohlsearch"] {
387 r.register(Command::action(
388 alias,
389 "Stop highlighting matches, keep the pattern",
390 "search.clear-highlight",
391 ));
392 }
393 r.register(Command::native(
394 "undo",
395 "Undo the last change",
396 erase::<Undo>(),
397 ));
398 r.register(Command::native(
399 "redo",
400 "Redo the last undone change",
401 erase::<Redo>(),
402 ));
403 r.register(Command::native(
404 "buffer-info",
405 "Print the active buffer summary",
406 erase::<Info>(),
407 ));
408 r
409 }
410
411 pub fn register(&mut self, command: Command) {
412 self.commands.insert(command.name.clone(), command);
413 }
414
415 #[must_use]
418 pub fn contains(&self, name: &str) -> bool {
419 self.commands.contains_key(name)
420 }
421
422 #[must_use]
424 pub fn len(&self) -> usize {
425 self.commands.len()
426 }
427
428 #[must_use]
430 pub fn is_empty(&self) -> bool {
431 self.commands.is_empty()
432 }
433
434 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
440 self.run_bounded(name, snap, args, ALIAS_FUEL)
441 }
442
443 fn run_bounded(
456 &self,
457 name: &str,
458 snap: &dyn Snapshot,
459 args: &[String],
460 fuel: u8,
461 ) -> Result<Outcome> {
462 let Some(fuel) = fuel.checked_sub(1) else {
463 return Err(CommandError::AliasCycle(name.to_string()));
464 };
465 let cmd = self
466 .commands
467 .get(name)
468 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
469 match &cmd.handler {
470 Handler::Native(f) => Ok(f(snap, args)),
471 Handler::Blue(function) => Ok(Outcome::did([Negai::CallBlue {
472 function: function.clone(),
473 args: args.to_vec(),
474 }])),
475 Handler::Action(sym) => match builtin_action(sym) {
476 Some(f) => Ok(f(snap, args)),
477 None if sym != name && self.commands.contains_key(sym.as_str()) => {
481 self.run_bounded(sym, snap, args, fuel)
482 }
483 None => Err(CommandError::Unhandled(sym.to_string())),
484 },
485 }
486 }
487
488 #[must_use]
489 pub fn names(&self) -> Vec<&str> {
490 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
491 v.sort_unstable();
492 v
493 }
494
495 #[must_use]
496 pub fn specs(&self) -> Vec<CommandSpec> {
497 let mut out: Vec<CommandSpec> = self
498 .commands
499 .values()
500 .map(|c| CommandSpec {
501 name: c.name.to_string(),
502 description: c.description.to_string(),
503 args: Vec::new(),
504 })
505 .collect();
506 out.sort_by(|a, b| a.name.cmp(&b.name));
507 out
508 }
509}
510
511const ALIAS_FUEL: u8 = 8;
516
517fn builtin_action(sym: &str) -> Option<CommandFn> {
523 Some(match sym {
524 "buffer.save" | "buffer.write" => erase::<Save>(),
525 "buffer.write-all" => erase::<WriteAll>(),
526 "buffer.undo" => erase::<Undo>(),
527 "buffer.redo" => erase::<Redo>(),
528 "buffer.info" => erase::<Info>(),
529 "editor.quit" => erase::<Quit>(),
530 "search.clear-highlight" => erase::<Noh>(),
531 _ => return None,
538 })
539}
540
541fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
546 b.active()
547 .map(BufferView::id)
548 .ok_or_else(|| Outcome::declined("no active buffer"))
549}
550
551type Result2<T> = std::result::Result<T, Outcome>;
552
553struct WriteAll;
559impl Native for WriteAll {
560 type Reads = caps!(Buffers);
561 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
562 let b = v.buffers();
563 let slips: Vec<Negai> = b
564 .ids()
565 .into_iter()
566 .filter(|id| {
567 b.get(*id)
568 .is_some_and(|x| x.is_modified() && x.path().is_some())
569 })
570 .map(|buffer| Negai::Save { buffer })
571 .collect();
572 if slips.is_empty() {
573 return Outcome::declined("no modified files");
574 }
575 Outcome::did(slips)
576 }
577}
578
579struct Save;
580impl Native for Save {
581 type Reads = caps!(Buffers);
582 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
583 match active_or_decline(&v.buffers()) {
584 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
585 Err(o) => o,
586 }
587 }
588}
589
590struct Undo;
591impl Native for Undo {
592 type Reads = caps!(Buffers);
593 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
594 match active_or_decline(&v.buffers()) {
595 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
596 Err(o) => o,
597 }
598 }
599}
600
601struct Redo;
602impl Native for Redo {
603 type Reads = caps!(Buffers);
604 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
605 match active_or_decline(&v.buffers()) {
606 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
607 Err(o) => o,
608 }
609 }
610}
611
612struct Info;
619impl Native for Info {
620 type Reads = caps!(Buffers);
621 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
622 let b = v.buffers();
623 let Some(buf) = b.active() else {
624 return Outcome::declined("no active buffer");
625 };
626 let mut m = String::with_capacity(48);
627 m.push_str("buffer ");
628 m.push_str(&buf.id().0.to_string());
629 m.push_str(" — ");
630 m.push_str(&buf.line_count().to_string());
631 m.push_str(" line(s)");
632 if buf.is_modified() {
633 m.push_str(" [modified]");
634 }
635 Outcome::did(vec![Negai::Message(m)])
636 }
637}
638
639struct OpenPicker<const COMMANDS: bool>;
655impl<const COMMANDS: bool> Native for OpenPicker<COMMANDS> {
656 type Reads = caps!();
657 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
658 Outcome::did(vec![Negai::OpenPicker(if COMMANDS {
659 escriba_madoguchi::PickerSource::Commands
660 } else {
661 escriba_madoguchi::PickerSource::Buffers
662 })])
663 }
664}
665
666struct HelpPicker;
668impl Native for HelpPicker {
669 type Reads = caps!();
670 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
671 Outcome::did(vec![Negai::OpenPicker(
672 escriba_madoguchi::PickerSource::Help,
673 )])
674 }
675}
676
677struct GrepPicker;
683impl Native for GrepPicker {
684 type Reads = caps!();
685 fn run(_v: &View<'_, Self::Reads>, args: &[String]) -> Outcome {
686 let pattern = args.join(" ");
687 if pattern.is_empty() {
688 return Outcome::declined("grep: give a pattern — `:picker.grep <pattern>`");
689 }
690 Outcome::did(vec![Negai::GrepProject { pattern }])
691 }
692}
693
694struct LspFormat;
703impl Native for LspFormat {
704 type Reads = caps!();
705 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
706 Outcome::did(vec![Negai::FormatBuffer])
707 }
708}
709
710struct DapToggleBreakpoint;
724impl Native for DapToggleBreakpoint {
725 type Reads = caps!();
726 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
727 Outcome::did(vec![Negai::ToggleBreakpoint])
728 }
729}
730
731struct WalkPicker<const PROJECT: bool>;
733impl<const PROJECT: bool> Native for WalkPicker<PROJECT> {
734 type Reads = caps!();
735 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
736 Outcome::did(vec![Negai::OpenPicker(if PROJECT {
737 escriba_madoguchi::PickerSource::Project
738 } else {
739 escriba_madoguchi::PickerSource::Files
740 })])
741 }
742}
743
744struct ParentPicker;
751impl Native for ParentPicker {
752 type Reads = caps!();
753 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
754 let root = std::env::current_dir()
755 .ok()
756 .and_then(|d| d.parent().map(std::path::Path::to_path_buf))
757 .unwrap_or_else(|| std::path::PathBuf::from(".."));
758 Outcome::did(vec![Negai::OpenPicker(
759 escriba_madoguchi::PickerSource::FilesUnder(root),
760 )])
761 }
762}
763
764struct FindingsPicker<const WORKSPACE: bool>;
770impl<const WORKSPACE: bool> Native for FindingsPicker<WORKSPACE> {
771 type Reads = caps!();
772 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
773 Outcome::did(vec![Negai::OpenPicker(
774 escriba_madoguchi::PickerSource::Findings {
775 workspace: WORKSPACE,
776 },
777 )])
778 }
779}
780
781struct SplitWindow<const STACKED: bool>;
788impl<const STACKED: bool> Native for SplitWindow<STACKED> {
789 type Reads = caps!();
790 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
791 Outcome::did(vec![Negai::SplitWindow { stacked: STACKED }])
792 }
793}
794
795struct CloseWindow;
797impl Native for CloseWindow {
798 type Reads = caps!();
799 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
800 Outcome::did(vec![Negai::CloseWindow])
801 }
802}
803
804struct FocusDir<const DX: i8, const DY: i8>;
810impl<const DX: i8, const DY: i8> Native for FocusDir<DX, DY> {
811 type Reads = caps!();
812 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
813 Outcome::did(vec![Negai::FocusDir { dx: DX, dy: DY }])
814 }
815}
816
817struct ConflictWalk<const FORWARD: bool>;
823impl<const FORWARD: bool> Native for ConflictWalk<FORWARD> {
824 type Reads = caps!(Buffers);
825 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
826 let b = v.buffers();
827 let Some(buf) = b.active() else {
828 return Outcome::declined("no active buffer");
829 };
830 let findings = escriba_shirube::conflict::findings(buf.id(), &buf.text());
831 if findings.is_empty() {
832 return Outcome::declined("no merge conflicts in this buffer");
833 }
834 Outcome::did(vec![
835 Negai::PublishFindings {
836 list: "conflict".to_string(),
837 findings,
838 },
839 Negai::WalkList {
840 list: "conflict".to_string(),
841 forward: FORWARD,
842 },
843 ])
844 }
845}
846
847struct ChooseSide<const SIDE: u8>;
853impl<const SIDE: u8> Native for ChooseSide<SIDE> {
854 type Reads = caps!(Buffers, Cursor);
855 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
856 use escriba_shirube::conflict::{Side, at, resolution};
857 let b = v.buffers();
858 let Some(buf) = b.active() else {
859 return Outcome::declined("no active buffer");
860 };
861 let text = buf.text();
862 let line = v.cursor().position().line;
863 let Some(c) = at(&text, line) else {
864 return Outcome::declined("not inside a merge conflict");
867 };
868 let side = match SIDE {
869 0 => Side::Ours,
870 1 => Side::Theirs,
871 _ => Side::Both,
872 };
873 let (from, to) = c.lines();
874 Outcome::did(vec![
875 Negai::Edit {
876 buffer: buf.id(),
877 edit: escriba_core::Edit {
878 range: escriba_core::Range::new(
879 escriba_core::Position::new(from, 0),
880 escriba_core::Position::new(to, 0),
881 ),
882 kind: escriba_core::EditKind::Replace {
883 text: resolution(&text, c, side),
884 },
885 },
886 },
887 Negai::SetCursor {
890 buffer: buf.id(),
891 to: escriba_core::Position::new(from, 0),
892 },
893 ])
894 }
895}
896
897struct BufferNext;
898impl Native for BufferNext {
899 type Reads = caps!();
900 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
901 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
902 }
903}
904
905struct BufferPrev;
906impl Native for BufferPrev {
907 type Reads = caps!();
908 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
909 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
910 }
911}
912
913struct BufferDelete;
918impl Native for BufferDelete {
919 type Reads = caps!(Buffers);
920 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
921 match active_or_decline(&v.buffers()) {
922 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
923 Err(o) => o,
924 }
925 }
926}
927
928struct CommentToggle;
935impl Native for CommentToggle {
936 type Reads = caps!(Buffers, Cursor, Syntax);
937 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
938 let Some(ft) = v.syntax().filetype() else {
939 return Outcome::declined("no filetype for this buffer");
940 };
941 let Some(comment) = ft.comment.as_ref() else {
942 let mut m = String::from("no comment syntax for ");
943 m.push_str(&ft.name);
944 return Outcome::declined(m);
945 };
946 let b = v.buffers();
947 let Some(buf) = b.active() else {
948 return Outcome::declined("no active buffer");
949 };
950 let line_no = v.cursor().position().line;
951 let Some(line) = buf.line(line_no) else {
952 return Outcome::declined("cursor past the end of the buffer");
953 };
954 if line.trim().is_empty() {
957 return Outcome::declined("nothing on this line");
958 }
959
960 let indent_len = line.len() - line.trim_start().len();
963 let (indent, body) = line.split_at(indent_len);
964 let toggled = match comment.strip(body) {
965 Some(uncommented) => uncommented.to_string(),
966 None => comment.wrap(body),
967 };
968 let mut text = String::with_capacity(indent.len() + toggled.len());
969 text.push_str(indent);
970 text.push_str(&toggled);
971
972 Outcome::did(vec![Negai::Edit {
973 buffer: buf.id(),
974 edit: escriba_core::Edit {
975 range: escriba_core::Range::new(
976 escriba_core::Position::new(line_no, 0),
977 escriba_core::Position::new(
978 line_no,
979 u32::try_from(line.chars().count()).unwrap_or(u32::MAX),
980 ),
981 ),
982 kind: escriba_core::EditKind::Replace { text },
983 },
984 }])
985 }
986}
987
988struct TodoWalk<const FORWARD: bool>;
1000impl<const FORWARD: bool> Native for TodoWalk<FORWARD> {
1001 type Reads = caps!(Buffers);
1002 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1003 let b = v.buffers();
1004 let Some(buf) = b.active() else {
1005 return Outcome::declined("no active buffer");
1006 };
1007 let findings = escriba_shirube::scan_markers(buf.id(), &buf.text());
1008 if findings.is_empty() {
1009 return Outcome::declined("no TODO markers in this buffer");
1010 }
1011 Outcome::did(vec![
1012 Negai::PublishFindings {
1013 list: "todo".to_string(),
1014 findings,
1015 },
1016 Negai::WalkList {
1017 list: "todo".to_string(),
1018 forward: FORWARD,
1019 },
1020 ])
1021 }
1022}
1023
1024struct Quit;
1030impl Native for Quit {
1031 type Reads = caps!();
1032 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1033 Outcome::did(vec![Negai::Quit])
1034 }
1035}
1036
1037fn no_write_since_last_change(what: &str) -> Outcome {
1040 let mut m = String::with_capacity(64);
1041 m.push_str("E37: No write since last change in ");
1042 m.push_str(what);
1043 m.push_str(" (add ! to override)");
1044 Outcome::declined(m)
1045}
1046
1047fn buffer_label(b: &dyn BufferView) -> String {
1050 b.path()
1051 .map_or_else(|| "[No Name]".to_string(), |p| p.display().to_string())
1052}
1053
1054struct QuitChecked<const ALL: bool>;
1061impl<const ALL: bool> Native for QuitChecked<ALL> {
1062 type Reads = caps!(Buffers);
1063 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1064 let b = v.buffers();
1065 let blocker = if ALL {
1068 b.ids()
1069 .into_iter()
1070 .filter_map(|id| b.get(id))
1071 .find(|x| x.is_modified())
1072 .map(buffer_label)
1073 } else {
1074 b.active().filter(|x| x.is_modified()).map(buffer_label)
1075 };
1076 match blocker {
1077 Some(what) => no_write_since_last_change(&what),
1078 None => Outcome::did(vec![Negai::Quit]),
1079 }
1080 }
1081}
1082
1083struct WriteQuit;
1090impl Native for WriteQuit {
1091 type Reads = caps!(Buffers);
1092 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1093 let b = v.buffers();
1094 let Some(buf) = b.active() else {
1095 return Outcome::declined("no active buffer");
1096 };
1097 if buf.path().is_none() {
1098 return Outcome::declined("E32: No file name");
1099 }
1100 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1101 }
1102}
1103
1104struct ExitWrite;
1110impl Native for ExitWrite {
1111 type Reads = caps!(Buffers);
1112 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1113 let b = v.buffers();
1114 let Some(buf) = b.active() else {
1115 return Outcome::declined("no active buffer");
1116 };
1117 if !buf.is_modified() {
1118 return Outcome::did(vec![Negai::Quit]);
1119 }
1120 if buf.path().is_none() {
1121 return Outcome::declined("E32: No file name");
1122 }
1123 Outcome::did(vec![Negai::Save { buffer: buf.id() }, Negai::Quit])
1124 }
1125}
1126
1127struct WriteQuitAll;
1136impl Native for WriteQuitAll {
1137 type Reads = caps!(Buffers);
1138 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1139 let b = v.buffers();
1140 let mut slips: Vec<Negai> = b
1141 .ids()
1142 .into_iter()
1143 .filter(|id| {
1144 b.get(*id)
1145 .is_some_and(|x| x.is_modified() && x.path().is_some())
1146 })
1147 .map(|buffer| Negai::Save { buffer })
1148 .collect();
1149 slips.push(Negai::Quit);
1150 Outcome::did(slips)
1151 }
1152}
1153
1154struct Noh;
1162impl Native for Noh {
1163 type Reads = caps!();
1164 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
1165 Outcome::did(vec![Negai::ClearSearchHighlight])
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use escriba_core::BufferId;
1173 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
1174
1175 fn dirty_file() -> FakeSnapshot {
1177 let mut s = FakeSnapshot::default();
1178 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
1179 s.active = Some(BufferId(1));
1180 s
1181 }
1182
1183 #[test]
1184 fn default_set_is_populated() {
1185 let r = CommandRegistry::default_set();
1186 let names = r.names();
1187 assert!(names.contains(&"save"));
1188 assert!(names.contains(&"quit"));
1189 }
1190
1191 #[test]
1192 fn specs_are_sorted() {
1193 let r = CommandRegistry::default_set();
1194 let specs = r.specs();
1195 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
1196 }
1197
1198 #[test]
1199 fn not_found_errors() {
1200 let r = CommandRegistry::new();
1203 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
1204 assert!(matches!(err, CommandError::NotFound(_)));
1205 }
1206
1207 #[test]
1208 fn a_command_asks_rather_than_acts() {
1209 let mut r = CommandRegistry::new();
1216 r.register(Command::action(
1217 "w-all",
1218 "Write every modified buffer",
1219 "buffer.write-all",
1220 ));
1221 let out = r
1222 .run("w-all", &dirty_file(), &[])
1223 .expect("registered command dispatches");
1224 assert_eq!(
1225 out.slips,
1226 vec![Negai::Save {
1227 buffer: BufferId(1)
1228 }]
1229 );
1230 assert_eq!(out.verdict, Verdict::Did);
1231 }
1232
1233 #[test]
1234 fn nothing_to_save_declines_rather_than_claiming_success() {
1235 let mut r = CommandRegistry::new();
1239 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
1240 let out = r
1241 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
1242 .expect("dispatches");
1243 assert!(out.slips.is_empty());
1244 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
1245 }
1246
1247 #[test]
1248 fn no_active_buffer_declines_rather_than_failing() {
1249 let mut r = CommandRegistry::new();
1252 r.register(Command::action("w", "Save", "buffer.save"));
1253 let out = r
1254 .run("w", &FakeSnapshot::default(), &[])
1255 .expect("dispatches");
1256 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
1257 assert!(out.slips.is_empty(), "a decline asks for nothing");
1258 }
1259
1260 #[test]
1261 fn unknown_action_symbol_is_reported_not_silent() {
1262 let mut r = CommandRegistry::new();
1271 r.register(Command::action("pick", "Pick a file", "picker.files"));
1272 let err = r
1273 .run("pick", &FakeSnapshot::default(), &[])
1274 .expect_err("an unimplemented action must report, not report success");
1275 assert!(
1276 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
1277 "expected Unhandled(picker.files), got {err:?}",
1278 );
1279 assert!(r.contains("pick"), "the command survives its own failure");
1280 }
1281
1282 #[test]
1283 fn action_naming_a_command_is_inert_not_recursive() {
1284 let mut r = CommandRegistry::new();
1292 r.register(Command::action("alias", "aliases save by name", "save"));
1293 let err = r
1294 .run("alias", &dirty_file(), &[])
1295 .expect_err("a command-name alias resolves nothing, and says so");
1296 assert!(
1297 matches!(&err, CommandError::Unhandled(s) if s == "save"),
1298 "expected Unhandled(save), got {err:?}",
1299 );
1300 }
1301
1302 #[test]
1303 fn quit_is_a_request_not_a_flag_poke() {
1304 let mut r = CommandRegistry::new();
1309 r.register(Command::action("bye", "Quit", "editor.quit"));
1310 let out = r
1311 .run("bye", &FakeSnapshot::default(), &[])
1312 .expect("dispatches");
1313 assert_eq!(out.slips, vec![Negai::Quit]);
1314 }
1315
1316 #[test]
1317 fn buffer_info_speaks_through_a_slip_not_stderr() {
1318 let mut r = CommandRegistry::new();
1322 r.register(Command::action("info", "Buffer info", "buffer.info"));
1323 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
1324 let Some(Negai::Message(m)) = out.slips.first() else {
1325 panic!("expected a Message slip, got {:?}", out.slips);
1326 };
1327 assert!(m.contains("buffer 1"), "{m}");
1328 assert!(m.contains("[modified]"), "{m}");
1329 }
1330}