1extern crate self as escriba_command;
4
5use std::collections::HashMap;
6
7use escriba_buffer::BufferSet;
8use escriba_core::BufferId;
9use escriba_mode::ModalState;
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("command failed: {0}")]
19 Failed(String),
20 #[error("buffer: {0}")]
21 Buffer(#[from] escriba_buffer::BufferError),
22}
23
24pub type Result<T> = std::result::Result<T, CommandError>;
25
26pub struct EditContext<'a> {
27 pub buffers: &'a mut BufferSet,
28 pub active: Option<BufferId>,
29 pub state: &'a mut ModalState,
30 pub quit_requested: &'a mut bool,
38}
39
40pub type CommandFn = fn(&mut EditContext<'_>, &[String]) -> Result<()>;
41
42#[derive(Debug, Clone)]
57pub enum Handler {
58 Native(CommandFn),
60 Action(String),
62}
63
64#[derive(Debug, Clone)]
65pub struct Command {
66 pub name: String,
67 pub description: String,
68 pub handler: Handler,
69}
70
71impl Command {
72 pub fn native(
74 name: impl Into<String>,
75 description: impl Into<String>,
76 handler: CommandFn,
77 ) -> Self {
78 Self {
79 name: name.into(),
80 description: description.into(),
81 handler: Handler::Native(handler),
82 }
83 }
84
85 pub fn action(
89 name: impl Into<String>,
90 description: impl Into<String>,
91 action: impl Into<String>,
92 ) -> Self {
93 Self {
94 name: name.into(),
95 description: description.into(),
96 handler: Handler::Action(action.into()),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102pub struct CommandSpec {
103 pub name: String,
104 pub description: String,
105 #[serde(default)]
106 pub args: Vec<CommandArgSpec>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct CommandArgSpec {
111 pub name: String,
112 pub description: String,
113 #[serde(default)]
114 pub required: bool,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub variants: Vec<String>,
117}
118
119#[derive(Debug, Default, Clone)]
120pub struct CommandRegistry {
121 commands: HashMap<String, Command>,
122}
123
124impl CommandRegistry {
125 #[must_use]
126 pub fn new() -> Self {
127 Self::default()
128 }
129
130 #[must_use]
131 pub fn default_set() -> Self {
132 let mut r = Self::new();
133 r.register(Command::native(
134 "save",
135 "Write the active buffer to disk",
136 cmd_save,
137 ));
138 r.register(Command::native("quit", "Exit the editor", cmd_quit));
139 r.register(Command::native("undo", "Undo the last change", cmd_undo));
140 r.register(Command::native(
141 "redo",
142 "Redo the last undone change",
143 cmd_redo,
144 ));
145 r.register(Command::native(
146 "buffer-info",
147 "Print the active buffer summary",
148 cmd_buffer_info,
149 ));
150 r
151 }
152
153 pub fn register(&mut self, command: Command) {
154 self.commands.insert(command.name.clone(), command);
155 }
156
157 #[must_use]
160 pub fn contains(&self, name: &str) -> bool {
161 self.commands.contains_key(name)
162 }
163
164 #[must_use]
166 pub fn len(&self) -> usize {
167 self.commands.len()
168 }
169
170 #[must_use]
172 pub fn is_empty(&self) -> bool {
173 self.commands.is_empty()
174 }
175
176 pub fn run(&self, name: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
177 let cmd = self
178 .commands
179 .get(name)
180 .ok_or_else(|| CommandError::NotFound(name.to_string()))?;
181 match &cmd.handler {
182 Handler::Native(f) => f(ctx, args),
183 Handler::Action(sym) => run_action(sym, ctx, args),
184 }
185 }
186
187 #[must_use]
188 pub fn names(&self) -> Vec<&str> {
189 let mut v: Vec<&str> = self.commands.keys().map(String::as_str).collect();
190 v.sort_unstable();
191 v
192 }
193
194 #[must_use]
195 pub fn specs(&self) -> Vec<CommandSpec> {
196 let mut out: Vec<CommandSpec> = self
197 .commands
198 .values()
199 .map(|c| CommandSpec {
200 name: c.name.to_string(),
201 description: c.description.to_string(),
202 args: Vec::new(),
203 })
204 .collect();
205 out.sort_by(|a, b| a.name.cmp(&b.name));
206 out
207 }
208}
209
210fn run_action(sym: &str, ctx: &mut EditContext<'_>, args: &[String]) -> Result<()> {
222 match sym {
223 "buffer.save" | "buffer.write" => cmd_save(ctx, args),
224 "buffer.write-all" => cmd_write_all(ctx, args),
225 "buffer.undo" => cmd_undo(ctx, args),
226 "buffer.redo" => cmd_redo(ctx, args),
227 "buffer.info" => cmd_buffer_info(ctx, args),
228 "editor.quit" => cmd_quit(ctx, args),
229 _ => Ok(()),
233 }
234}
235
236fn cmd_write_all(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
241 for id in ctx.buffers.ids() {
242 if let Some(buf) = ctx.buffers.get_mut(id) {
243 if buf.modified && buf.path.is_some() {
244 let _ = buf.save();
245 }
246 }
247 }
248 Ok(())
249}
250
251fn cmd_save(ctx: &mut EditContext<'_>, _args: &[String]) -> Result<()> {
252 let id = ctx
253 .active
254 .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
255 let buf = ctx
256 .buffers
257 .get_mut(id)
258 .ok_or_else(|| CommandError::Failed("active buffer gone".into()))?;
259 buf.save()?;
260 Ok(())
261}
262
263fn cmd_quit(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
264 *ctx.quit_requested = true;
268 Ok(())
269}
270
271fn cmd_undo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
272 let id = ctx
273 .active
274 .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
275 ctx.buffers
276 .get_mut(id)
277 .ok_or_else(|| CommandError::Failed("gone".into()))?
278 .undo()?;
279 Ok(())
280}
281
282fn cmd_redo(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
283 let id = ctx
284 .active
285 .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
286 ctx.buffers
287 .get_mut(id)
288 .ok_or_else(|| CommandError::Failed("gone".into()))?
289 .redo()?;
290 Ok(())
291}
292
293fn cmd_buffer_info(ctx: &mut EditContext<'_>, _: &[String]) -> Result<()> {
294 let id = ctx
295 .active
296 .ok_or_else(|| CommandError::Failed("no active buffer".into()))?;
297 let buf = ctx
298 .buffers
299 .get(id)
300 .ok_or_else(|| CommandError::Failed("gone".into()))?;
301 eprintln!(
302 "buffer {} — {} line(s), {} char(s){}",
303 id,
304 buf.line_count(),
305 buf.char_count(),
306 if buf.modified { " [modified]" } else { "" }
307 );
308 Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn default_set_is_populated() {
317 let r = CommandRegistry::default_set();
318 let names = r.names();
319 assert!(names.contains(&"save"));
320 assert!(names.contains(&"quit"));
321 }
322
323 #[test]
324 fn specs_are_sorted() {
325 let r = CommandRegistry::default_set();
326 let specs = r.specs();
327 assert!(specs.windows(2).all(|w| w[0].name <= w[1].name));
328 }
329
330 #[test]
331 fn not_found_errors() {
332 let r = CommandRegistry::new();
333 let mut bufs = BufferSet::new();
334 let mut state = ModalState::new();
335 let mut quit = false;
336 let mut ctx = EditContext {
337 buffers: &mut bufs,
338 active: None,
339 state: &mut state,
340 quit_requested: &mut quit,
341 };
342 let err = r.run("nope", &mut ctx, &[]).unwrap_err();
343 assert!(matches!(err, CommandError::NotFound(_)));
344 }
345
346 #[test]
347 fn action_command_registers_and_is_invokable() {
348 let mut r = CommandRegistry::new();
353 r.register(Command::action(
354 "w-all",
355 "Write every modified buffer",
356 "buffer.write-all",
357 ));
358 assert!(r.contains("w-all"));
359
360 let mut bufs = BufferSet::new();
361 let id = bufs.scratch("dirty");
362 bufs.get_mut(id).unwrap().modified = true;
363 let mut state = ModalState::new();
364 let mut quit = false;
365 let mut ctx = EditContext {
366 buffers: &mut bufs,
367 active: Some(id),
368 state: &mut state,
369 quit_requested: &mut quit,
370 };
371 r.run("w-all", &mut ctx, &[]).expect("action command runs");
373 }
374
375 #[test]
376 fn unknown_action_symbol_is_inert_not_fatal() {
377 let mut r = CommandRegistry::new();
381 r.register(Command::action("pick", "Pick a file", "picker.files"));
382 let mut bufs = BufferSet::new();
383 let mut state = ModalState::new();
384 let mut quit = false;
385 let mut ctx = EditContext {
386 buffers: &mut bufs,
387 active: None,
388 state: &mut state,
389 quit_requested: &mut quit,
390 };
391 r.run("pick", &mut ctx, &[]).expect("unknown action is inert");
392 }
393
394 #[test]
395 fn action_naming_a_command_is_inert_not_recursive() {
396 let mut r = CommandRegistry::new();
404 r.register(Command::action("alias", "aliases save by name", "save"));
405 let mut bufs = BufferSet::new();
406 let id = bufs.scratch("dirty");
407 bufs.get_mut(id).unwrap().modified = true;
408 let mut state = ModalState::new();
409 let mut quit = false;
410 {
411 let mut ctx = EditContext {
412 buffers: &mut bufs,
413 active: Some(id),
414 state: &mut state,
415 quit_requested: &mut quit,
416 };
417 r.run("alias", &mut ctx, &[]).expect("alias runs inertly");
418 }
419 assert!(
420 bufs.get(id).unwrap().modified,
421 "command-name alias must be inert — save did not fire (no recursion)",
422 );
423 }
424
425 #[test]
426 fn action_quit_sets_quit_flag() {
427 let mut r = CommandRegistry::new();
431 r.register(Command::action("bye", "Quit", "editor.quit"));
432 let mut bufs = BufferSet::new();
433 let mut state = ModalState::new();
434 let mut quit = false;
435 let mut ctx = EditContext {
436 buffers: &mut bufs,
437 active: None,
438 state: &mut state,
439 quit_requested: &mut quit,
440 };
441 r.run("bye", &mut ctx, &[]).expect("quit action runs");
442 assert!(*ctx.quit_requested, "editor.quit sets the typed quit flag");
443 }
444}