1extern crate self as escriba_command;
4
5use std::collections::HashMap;
6
7use escriba_core::BufferId;
8use escriba_madoguchi::cap::Buffers;
9use escriba_madoguchi::{BufferView, Native, Negai, Outcome, Snapshot, View, caps, erase};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum CommandError {
16 #[error("command not found: {0}")]
17 NotFound(String),
18 #[error("action `{0}` is declared but not implemented yet")]
27 Unhandled(String),
28 #[error("command failed: {0}")]
29 Failed(String),
30 }
35
36pub type Result<T> = std::result::Result<T, CommandError>;
37
38pub type CommandFn = fn(&dyn Snapshot, &[String]) -> Outcome;
47
48#[derive(Debug, Clone)]
63pub enum Handler {
64 Native(CommandFn),
66 Action(String),
68}
69
70#[derive(Debug, Clone)]
71pub struct Command {
72 pub name: String,
73 pub description: String,
74 pub handler: Handler,
75}
76
77impl Command {
78 pub fn native(
80 name: impl Into<String>,
81 description: impl Into<String>,
82 handler: CommandFn,
83 ) -> Self {
84 Self {
85 name: name.into(),
86 description: description.into(),
87 handler: Handler::Native(handler),
88 }
89 }
90
91 pub fn action(
95 name: impl Into<String>,
96 description: impl Into<String>,
97 action: impl Into<String>,
98 ) -> Self {
99 Self {
100 name: name.into(),
101 description: description.into(),
102 handler: Handler::Action(action.into()),
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108pub struct CommandSpec {
109 pub name: String,
110 pub description: String,
111 #[serde(default)]
112 pub args: Vec<CommandArgSpec>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
116pub struct CommandArgSpec {
117 pub name: String,
118 pub description: String,
119 #[serde(default)]
120 pub required: bool,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
122 pub variants: Vec<String>,
123}
124
125#[derive(Debug, Default, Clone)]
126pub struct CommandRegistry {
127 commands: HashMap<String, Command>,
128}
129
130impl CommandRegistry {
131 #[must_use]
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 #[must_use]
137 pub fn default_set() -> Self {
138 let mut r = Self::new();
139 r.register(Command::native(
140 "save",
141 "Write the active buffer to disk",
142 erase::<Save>(),
143 ));
144 r.register(Command::native("quit", "Exit the editor", erase::<Quit>()));
145 r.register(Command::native(
150 "buffer.next",
151 "Go to the next buffer",
152 erase::<BufferNext>(),
153 ));
154 r.register(Command::native(
155 "buffer.prev",
156 "Go to the previous buffer",
157 erase::<BufferPrev>(),
158 ));
159 r.register(Command::native(
160 "buffer.delete",
161 "Close the active buffer",
162 erase::<BufferDelete>(),
163 ));
164 for alias in ["noh", "nohl", "nohlsearch"] {
165 r.register(Command::action(
166 alias,
167 "Stop highlighting matches, keep the pattern",
168 "search.clear-highlight",
169 ));
170 }
171 r.register(Command::native(
172 "undo",
173 "Undo the last change",
174 erase::<Undo>(),
175 ));
176 r.register(Command::native(
177 "redo",
178 "Redo the last undone change",
179 erase::<Redo>(),
180 ));
181 r.register(Command::native(
182 "buffer-info",
183 "Print the active buffer summary",
184 erase::<Info>(),
185 ));
186 r
187 }
188
189 pub fn register(&mut self, command: Command) {
190 self.commands.insert(command.name.clone(), command);
191 }
192
193 #[must_use]
196 pub fn contains(&self, name: &str) -> bool {
197 self.commands.contains_key(name)
198 }
199
200 #[must_use]
202 pub fn len(&self) -> usize {
203 self.commands.len()
204 }
205
206 #[must_use]
208 pub fn is_empty(&self) -> bool {
209 self.commands.is_empty()
210 }
211
212 pub fn run(&self, name: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
218 let cmd = self
219 .commands
220 .get(name)
221 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
222 match &cmd.handler {
223 Handler::Native(f) => Ok(f(snap, args)),
224 Handler::Action(sym) => run_action(sym, snap, args),
225 }
226 }
227
228 #[must_use]
229 pub fn names(&self) -> Vec<&str> {
230 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
231 v.sort_unstable();
232 v
233 }
234
235 #[must_use]
236 pub fn specs(&self) -> Vec<CommandSpec> {
237 let mut out: Vec<CommandSpec> = self
238 .commands
239 .values()
240 .map(|c| CommandSpec {
241 name: c.name.to_string(),
242 description: c.description.to_string(),
243 args: Vec::new(),
244 })
245 .collect();
246 out.sort_by(|a, b| a.name.cmp(&b.name));
247 out
248 }
249}
250
251fn run_action(sym: &str, snap: &dyn Snapshot, args: &[String]) -> Result<Outcome> {
253 match sym {
254 "buffer.save" | "buffer.write" => Ok(erase::<Save>()(snap, args)),
255 "buffer.write-all" => Ok(erase::<WriteAll>()(snap, args)),
256 "buffer.undo" => Ok(erase::<Undo>()(snap, args)),
257 "buffer.redo" => Ok(erase::<Redo>()(snap, args)),
258 "buffer.info" => Ok(erase::<Info>()(snap, args)),
259 "editor.quit" => Ok(erase::<Quit>()(snap, args)),
260 "search.clear-highlight" => Ok(erase::<Noh>()(snap, args)),
261 _ => Err(CommandError::Unhandled(sym.to_string())),
265 }
266}
267
268fn active_or_decline(b: &escriba_madoguchi::snapshot::Buffers<'_>) -> Result2<BufferId> {
273 b.active()
274 .map(BufferView::id)
275 .ok_or_else(|| Outcome::declined("no active buffer"))
276}
277
278type Result2<T> = std::result::Result<T, Outcome>;
279
280struct WriteAll;
286impl Native for WriteAll {
287 type Reads = caps!(Buffers);
288 fn run(v: &View<'_, Self::Reads>, _args: &[String]) -> Outcome {
289 let b = v.buffers();
290 let slips: Vec<Negai> = b
291 .ids()
292 .into_iter()
293 .filter(|id| {
294 b.get(*id)
295 .is_some_and(|x| x.is_modified() && x.path().is_some())
296 })
297 .map(|buffer| Negai::Save { buffer })
298 .collect();
299 if slips.is_empty() {
300 return Outcome::declined("no modified files");
301 }
302 Outcome::did(slips)
303 }
304}
305
306struct Save;
307impl Native for Save {
308 type Reads = caps!(Buffers);
309 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
310 match active_or_decline(&v.buffers()) {
311 Ok(buffer) => Outcome::did(vec![Negai::Save { buffer }]),
312 Err(o) => o,
313 }
314 }
315}
316
317struct Undo;
318impl Native for Undo {
319 type Reads = caps!(Buffers);
320 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
321 match active_or_decline(&v.buffers()) {
322 Ok(buffer) => Outcome::did(vec![Negai::Undo { buffer }]),
323 Err(o) => o,
324 }
325 }
326}
327
328struct Redo;
329impl Native for Redo {
330 type Reads = caps!(Buffers);
331 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
332 match active_or_decline(&v.buffers()) {
333 Ok(buffer) => Outcome::did(vec![Negai::Redo { buffer }]),
334 Err(o) => o,
335 }
336 }
337}
338
339struct Info;
346impl Native for Info {
347 type Reads = caps!(Buffers);
348 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
349 let b = v.buffers();
350 let Some(buf) = b.active() else {
351 return Outcome::declined("no active buffer");
352 };
353 let mut m = String::with_capacity(48);
354 m.push_str("buffer ");
355 m.push_str(&buf.id().0.to_string());
356 m.push_str(" — ");
357 m.push_str(&buf.line_count().to_string());
358 m.push_str(" line(s)");
359 if buf.is_modified() {
360 m.push_str(" [modified]");
361 }
362 Outcome::did(vec![Negai::Message(m)])
363 }
364}
365
366struct BufferNext;
375impl Native for BufferNext {
376 type Reads = caps!();
377 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
378 Outcome::did(vec![Negai::CycleBuffer { forward: true }])
379 }
380}
381
382struct BufferPrev;
383impl Native for BufferPrev {
384 type Reads = caps!();
385 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
386 Outcome::did(vec![Negai::CycleBuffer { forward: false }])
387 }
388}
389
390struct BufferDelete;
395impl Native for BufferDelete {
396 type Reads = caps!(Buffers);
397 fn run(v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
398 match active_or_decline(&v.buffers()) {
399 Ok(buffer) => Outcome::did(vec![Negai::CloseBuffer(buffer)]),
400 Err(o) => o,
401 }
402 }
403}
404
405struct Quit;
406impl Native for Quit {
407 type Reads = caps!();
408 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
409 Outcome::did(vec![Negai::Quit])
410 }
411}
412
413struct Noh;
421impl Native for Noh {
422 type Reads = caps!();
423 fn run(_v: &View<'_, Self::Reads>, _: &[String]) -> Outcome {
424 Outcome::did(vec![Negai::ClearSearchHighlight])
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use escriba_core::BufferId;
432 use escriba_madoguchi::{FakeBuffer, FakeSnapshot, Verdict};
433
434 fn dirty_file() -> FakeSnapshot {
436 let mut s = FakeSnapshot::default();
437 s.buffers = vec![FakeBuffer::new(1, "dirty").at("/tmp/x.txt").dirty()];
438 s.active = Some(BufferId(1));
439 s
440 }
441
442 #[test]
443 fn default_set_is_populated() {
444 let r = CommandRegistry::default_set();
445 let names = r.names();
446 assert!(names.contains(&"save"));
447 assert!(names.contains(&"quit"));
448 }
449
450 #[test]
451 fn specs_are_sorted() {
452 let r = CommandRegistry::default_set();
453 let specs = r.specs();
454 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
455 }
456
457 #[test]
458 fn not_found_errors() {
459 let r = CommandRegistry::new();
462 let err = r.run("nope", &FakeSnapshot::default(), &[]).unwrap_err();
463 assert!(matches!(err, CommandError::NotFound(_)));
464 }
465
466 #[test]
467 fn a_command_asks_rather_than_acts() {
468 let mut r = CommandRegistry::new();
475 r.register(Command::action(
476 "w-all",
477 "Write every modified buffer",
478 "buffer.write-all",
479 ));
480 let out = r
481 .run("w-all", &dirty_file(), &[])
482 .expect("registered command dispatches");
483 assert_eq!(
484 out.slips,
485 vec![Negai::Save {
486 buffer: BufferId(1)
487 }]
488 );
489 assert_eq!(out.verdict, Verdict::Did);
490 }
491
492 #[test]
493 fn nothing_to_save_declines_rather_than_claiming_success() {
494 let mut r = CommandRegistry::new();
498 r.register(Command::action("w-all", "Write all", "buffer.write-all"));
499 let out = r
500 .run("w-all", &FakeSnapshot::with_buffer("scratch"), &[])
501 .expect("dispatches");
502 assert!(out.slips.is_empty());
503 assert_eq!(out.verdict, Verdict::Declined("no modified files".into()));
504 }
505
506 #[test]
507 fn no_active_buffer_declines_rather_than_failing() {
508 let mut r = CommandRegistry::new();
511 r.register(Command::action("w", "Save", "buffer.save"));
512 let out = r
513 .run("w", &FakeSnapshot::default(), &[])
514 .expect("dispatches");
515 assert_eq!(out.verdict, Verdict::Declined("no active buffer".into()));
516 assert!(out.slips.is_empty(), "a decline asks for nothing");
517 }
518
519 #[test]
520 fn unknown_action_symbol_is_reported_not_silent() {
521 let mut r = CommandRegistry::new();
530 r.register(Command::action("pick", "Pick a file", "picker.files"));
531 let err = r
532 .run("pick", &FakeSnapshot::default(), &[])
533 .expect_err("an unimplemented action must report, not report success");
534 assert!(
535 matches!(&err, CommandError::Unhandled(s) if s == "picker.files"),
536 "expected Unhandled(picker.files), got {err:?}",
537 );
538 assert!(r.contains("pick"), "the command survives its own failure");
539 }
540
541 #[test]
542 fn action_naming_a_command_is_inert_not_recursive() {
543 let mut r = CommandRegistry::new();
551 r.register(Command::action("alias", "aliases save by name", "save"));
552 let err = r
553 .run("alias", &dirty_file(), &[])
554 .expect_err("a command-name alias resolves nothing, and says so");
555 assert!(
556 matches!(&err, CommandError::Unhandled(s) if s == "save"),
557 "expected Unhandled(save), got {err:?}",
558 );
559 }
560
561 #[test]
562 fn quit_is_a_request_not_a_flag_poke() {
563 let mut r = CommandRegistry::new();
568 r.register(Command::action("bye", "Quit", "editor.quit"));
569 let out = r
570 .run("bye", &FakeSnapshot::default(), &[])
571 .expect("dispatches");
572 assert_eq!(out.slips, vec![Negai::Quit]);
573 }
574
575 #[test]
576 fn buffer_info_speaks_through_a_slip_not_stderr() {
577 let mut r = CommandRegistry::new();
581 r.register(Command::action("info", "Buffer info", "buffer.info"));
582 let out = r.run("info", &dirty_file(), &[]).expect("dispatches");
583 let Some(Negai::Message(m)) = out.slips.first() else {
584 panic!("expected a Message slip, got {:?}", out.slips);
585 };
586 assert!(m.contains("buffer 1"), "{m}");
587 assert!(m.contains("[modified]"), "{m}");
588 }
589}