mini_async_repl/
repl.rs

1//! Main REPL logic.
2
3use std::{collections::HashMap, io::Write, rc::Rc};
4
5use rustyline::{self, completion::FilenameCompleter, error::ReadlineError};
6use shell_words;
7use textwrap;
8use thiserror;
9use trie_rs::{Trie, TrieBuilder};
10
11use crate::command::{ArgsError, Command, CommandStatus, CriticalError};
12use crate::completion::{completion_candidates, Completion};
13
14/// Reserved command names. These commands are always added to REPL.
15pub const RESERVED: &[(&str, &str)] = &[("help", "Show this help message"), ("quit", "Quit repl")];
16
17/// Read-eval-print loop.
18///
19/// REPL is ment do be constructed using the builder pattern via [`Repl::builder()`].
20/// Commands are added during building and currently cannot be added/removed/modified
21/// after [`Repl`] has been built. This is because the names are used to generate Trie
22/// with all the names for fast name lookup and completion.
23///
24/// [`Repl`] can be used in two ways: one can use the [`Repl::run`] method directly to just
25/// start the evaluation loop, or [`Repl::next`] can be used to get back control between
26/// loop steps.
27pub struct Repl {
28    description: String,
29    prompt: String,
30    text_width: usize,
31    commands: HashMap<String, Vec<Command>>,
32    trie: Rc<Trie<u8>>,
33    editor: rustyline::Editor<Completion>,
34    out: Box<dyn Write>,
35    predict_commands: bool,
36}
37
38/// State of the REPL after command execution.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum LoopStatus {
41    /// REPL should continue execution.
42    Continue,
43    /// Should break of evaluation loop (quit command or end of input).
44    Break,
45}
46
47/// Builder pattern implementation for [`Repl`].
48///
49/// All setter methods take owned `self` so the calls can be chained, for example:
50/// ```rust
51/// # use mini_async_repl::Repl;
52/// let repl = Repl::builder()
53///     .description("My REPL")
54///     .prompt("repl> ")
55///     .build()
56///     .expect("Failed to build REPL");
57/// ```
58pub struct ReplBuilder {
59    commands: Vec<(String, Command)>,
60    description: String,
61    prompt: String,
62    text_width: usize,
63    editor_config: rustyline::config::Config,
64    out: Box<dyn Write>,
65    with_hints: bool,
66    with_completion: bool,
67    with_filename_completion: bool,
68    predict_commands: bool,
69}
70
71/// Error when building REPL.
72#[derive(Debug, thiserror::Error)]
73pub enum BuilderError {
74    /// More than one command have the same.
75    #[error("more than one command with name '{0}' added")]
76    DuplicateCommands(String),
77    /// Given command name is not valid.
78    #[error("name '{0}' cannot be parsed correctly, thus would be impossible to call")]
79    InvalidName(String),
80    /// Command name is one of [`RESERVED`] names.
81    #[error("'{0}' is a reserved command name")]
82    ReservedName(String),
83}
84
85pub(crate) fn split_args(line: &str) -> Result<Vec<String>, shell_words::ParseError> {
86    shell_words::split(line)
87}
88
89impl Default for ReplBuilder {
90    fn default() -> Self {
91        ReplBuilder {
92            prompt: "> ".into(),
93            text_width: 80,
94            description: Default::default(),
95            commands: Default::default(),
96            out: Box::new(std::io::stderr()),
97            editor_config: rustyline::config::Config::builder()
98                .output_stream(rustyline::OutputStreamType::Stderr) // NOTE: cannot specify `out`
99                .completion_type(rustyline::CompletionType::List)
100                .build(),
101            with_hints: true,
102            with_completion: true,
103            with_filename_completion: false,
104            predict_commands: true,
105        }
106    }
107}
108
109macro_rules! setters {
110    ($( $(#[$meta:meta])* $name:ident: $type:ty )+) => {
111        $(
112            $(#[$meta])*
113            pub fn $name<T: Into<$type>>(mut self, v: T) -> Self {
114                self.$name = v.into();
115                self
116            }
117        )+
118    };
119}
120
121impl ReplBuilder {
122    setters! {
123        /// Repl description shown in [`Repl::help`]. Defaults to an empty string.
124        description: String
125        /// Prompt string, defaults to `"> "`.
126        prompt: String
127        /// Width of the text used when wrapping the help message. Defaults to 80.
128        text_width: usize
129        /// Configuration for [`rustyline`]. Some sane defaults are used.
130        editor_config: rustyline::config::Config
131        /// Where to print REPL output. By default [`std::io::Stderr`] is used.
132        ///
133        /// Note that [`rustyline`] will always use [`std::io::Stderr`] or [`std::io::Stdout`].
134        /// These must be configured in [`ReplBuilder::editor_config`], and currently there seems to be no way
135        /// to use other output stream for [`rustyline`] (which probably also makes little sense).
136        out: Box<dyn Write>
137        /// Print command hints. Defaults to `true`.
138        ///
139        /// Hints will show the end of a command if there is only one avaliable.
140        /// For example, assuming commands `"move"` and `"make"`, in the following position (`|`
141        /// indicates the cursor):
142        /// ```text
143        /// > mo|
144        /// ```
145        /// a hint will be shown as
146        /// ```text
147        /// > mo|ve
148        /// ```
149        /// but when there is only
150        /// ```text
151        /// > m|
152        /// ```
153        /// then no hints will be shown.
154        with_hints: bool
155        /// Use completion. Defaults to `true`.
156        with_completion: bool
157        /// Add filename completion, besides command completion. Defaults to `false`.
158        with_filename_completion: bool
159        /// Execute commands when entering incomplete names. Defaults to `true`.
160        ///
161        /// With this option commands can be executed by entering only part of command name.
162        /// If there is only a single command mathing given prefix, then it will be executed.
163        /// For example, with commands `"make"` and "`move`", entering just `mo` will resolve
164        /// to `move` and the command will be executed, but entering `m` will result in an error.
165        predict_commands: bool
166    }
167
168    /// Add a command with given `name`. Use along with the [`command!`] macro.
169    pub fn add(mut self, name: &str, cmd: Command) -> Self {
170        self.commands.push((name.into(), cmd));
171        self
172    }
173
174    /// Finalize the configuration and return the REPL or error.
175    pub fn build(self) -> Result<Repl, BuilderError> {
176        let mut commands: HashMap<String, Vec<Command>> = HashMap::new();
177        let mut trie = TrieBuilder::new();
178        for (name, cmd) in self.commands {
179            let cmds = commands.entry(name.clone()).or_default();
180            let args = split_args(&name).map_err(|_e| BuilderError::InvalidName(name.clone()))?;
181            if args.len() != 1 || name.is_empty() {
182                return Err(BuilderError::InvalidName(name));
183            } else if RESERVED.iter().any(|(n, _)| *n == name) {
184                return Err(BuilderError::ReservedName(name));
185            } else if cmds.iter().any(|c| c.arg_types() == cmd.arg_types()) {
186                return Err(BuilderError::DuplicateCommands(name));
187            }
188            cmds.push(cmd);
189            trie.push(name);
190        }
191        for (name, _) in RESERVED.iter() {
192            trie.push(name);
193        }
194
195        let trie = Rc::new(trie.build());
196        let helper = Completion {
197            trie: trie.clone(),
198            with_hints: self.with_hints,
199            with_completion: self.with_completion,
200            filename_completer: if self.with_filename_completion {
201                Some(FilenameCompleter::new())
202            } else {
203                None
204            },
205        };
206        let mut editor = rustyline::Editor::with_config(self.editor_config);
207        editor.set_helper(Some(helper));
208
209        Ok(Repl {
210            description: self.description,
211            prompt: self.prompt,
212            text_width: self.text_width,
213            commands,
214            trie,
215            editor,
216            out: self.out,
217            predict_commands: self.predict_commands,
218        })
219    }
220}
221
222impl Repl {
223    /// Start [`ReplBuilder`] with default values.
224    pub fn builder() -> ReplBuilder {
225        ReplBuilder::default()
226    }
227
228    fn format_help_entries(&self, entries: &[(String, String)]) -> String {
229        if entries.is_empty() {
230            return String::new();
231        }
232        let width = entries
233            .iter()
234            .map(|(sig, _)| sig)
235            .max_by_key(|sig| sig.len())
236            .unwrap()
237            .len();
238        entries
239            .iter()
240            .map(|(sig, desc)| {
241                let indent = " ".repeat(width + 2 + 2);
242                let opts = textwrap::Options::new(self.text_width)
243                    .initial_indent("")
244                    .subsequent_indent(&indent);
245                let line = format!("  {sig:width$}  {desc}");
246                textwrap::fill(&line, opts)
247            })
248            .fold(String::new(), |mut out, next| {
249                out.push('\n');
250                out.push_str(&next);
251                out
252            })
253    }
254
255    /// Returns formatted help message.
256    pub fn help(&self) -> String {
257        let mut names: Vec<_> = self.commands.keys().collect();
258        names.sort();
259
260        let signature =
261            |name: &String, args_info: &Vec<String>| format!("{} {}", name, args_info.join(" "));
262        let user: Vec<_> = self
263            .commands
264            .iter()
265            .flat_map(|(name, cmds)| {
266                cmds.iter()
267                    .map(move |cmd| (signature(name, &cmd.arg_types()), cmd.description.clone()))
268            })
269            .collect();
270
271        let other: Vec<_> = RESERVED
272            .iter()
273            .map(|(name, desc)| ((*name).to_string(), desc.to_string()))
274            .collect();
275
276        let msg = format!(
277            r#"
278{}
279
280Available commands:
281{}
282
283Other commands:
284{}
285        "#,
286            self.description,
287            self.format_help_entries(&user),
288            self.format_help_entries(&other)
289        );
290        msg.trim().into()
291    }
292
293    async fn handle_line(&mut self, line: &str) -> anyhow::Result<LoopStatus> {
294        // if there is any parsing error just continue to next input
295        let args = match split_args(line) {
296            Err(err) => {
297                writeln!(&mut self.out, "Error: {err}")?;
298                return Ok(LoopStatus::Continue);
299            }
300            Ok(args) => args,
301        };
302        let prefix = &args[0];
303        let mut candidates = completion_candidates(&self.trie, prefix);
304        let exact = !candidates.is_empty() && &candidates[0] == prefix;
305        let can_take_first = !candidates.is_empty() && (exact || self.predict_commands);
306        if !can_take_first {
307            writeln!(&mut self.out, "Command not found: {prefix}")?;
308            if candidates.len() > 1 || (!self.predict_commands && !exact) {
309                candidates.sort();
310                writeln!(&mut self.out, "Candidates:\n  {}", candidates.join("\n  "))?;
311            }
312            writeln!(&mut self.out, "Use 'help' to see available commands.")?;
313            Ok(LoopStatus::Continue)
314        } else {
315            let name = &candidates[0];
316            let tail: Vec<_> = args[1..].iter().map(String::as_str).collect();
317            match self.handle_command(name, &tail).await {
318                Ok(CommandStatus::Done) => Ok(LoopStatus::Continue),
319                Ok(CommandStatus::Quit) => Ok(LoopStatus::Break),
320                Err(err) if err.downcast_ref::<CriticalError>().is_some() => Err(err),
321                Err(err) => {
322                    // other errors are handled here
323                    writeln!(&mut self.out, "Error: {err}")?;
324                    if err.is::<ArgsError>() {
325                        // in case of ArgsError we know it could not have been a reserved command
326                        let cmds = self.commands.get_mut(name).unwrap();
327                        writeln!(&mut self.out, "Usage:")?;
328                        for cmd in cmds.iter() {
329                            writeln!(
330                                &mut self.out,
331                                "  {} {}",
332                                name,
333                                cmd.args_info
334                                    .clone()
335                                    .into_iter()
336                                    .map(|info| info.to_string())
337                                    .collect::<Vec<_>>()
338                                    .join(" ")
339                            )?;
340                        }
341                    }
342                    Ok(LoopStatus::Continue)
343                }
344            }
345        }
346    }
347
348    /// Run a single REPL iteration and return whether this is the last one or not.
349    pub async fn next(&mut self) -> anyhow::Result<LoopStatus> {
350        match self.editor.readline(&self.prompt) {
351            Ok(line) => {
352                if !line.trim().is_empty() {
353                    self.editor.add_history_entry(line.trim());
354                    self.handle_line(&line).await
355                } else {
356                    Ok(LoopStatus::Continue)
357                }
358            }
359            Err(ReadlineError::Interrupted) => {
360                writeln!(&mut self.out, "CTRL-C")?;
361                Ok(LoopStatus::Break)
362            }
363            Err(ReadlineError::Eof) => Ok(LoopStatus::Break),
364            // TODO: not sure if these should be propagated or handler here
365            Err(err) => {
366                writeln!(&mut self.out, "Error: {err:?}")?;
367                Ok(LoopStatus::Continue)
368            }
369        }
370    }
371
372    async fn handle_command(&mut self, name: &str, args: &[&str]) -> anyhow::Result<CommandStatus> {
373        match name {
374            "help" => {
375                let help = self.help();
376                writeln!(&mut self.out, "{help}")?;
377                Ok(CommandStatus::Done)
378            }
379            "quit" => Ok(CommandStatus::Quit),
380            _ => {
381                // find_command must have returned correct name
382
383                // if all commands are not possible to call because of argument error
384                // return the last argument one as our result
385                let mut last_arg_err = None;
386                let cmds = self.commands.get_mut(name).unwrap();
387                for cmd in cmds.iter_mut() {
388                    match cmd.execute(args).await {
389                        Err(e) => {
390                            if !e.is::<ArgsError>() {
391                                return Err(e);
392                            } else {
393                                last_arg_err = Some(Err(e));
394                            }
395                        }
396                        other => return other,
397                    }
398                }
399                // last_arg_err should always have at least a value here
400                last_arg_err.unwrap()
401            }
402        }
403    }
404
405    /// Run the evaluation loop until [`LoopStatus::Break`] is received.
406    pub async fn run(&mut self) -> anyhow::Result<()> {
407        while self.next().await? == LoopStatus::Continue {}
408        Ok(())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::command::{CommandArgInfo, CommandArgType, ExecuteCommand, TrivialCommandHandler};
416    use std::future::Future;
417    use std::pin::Pin;
418
419    #[test]
420    fn builder_duplicate() {
421        let command_x_1 = Command::new("Command X", vec![], Box::new(TrivialCommandHandler::new()));
422
423        let command_x_2 = Command::new(
424            "Command X 2",
425            vec![],
426            Box::new(TrivialCommandHandler::new()),
427        );
428
429        let result = Repl::builder()
430            .add("name_x", command_x_1)
431            .add("name_x", command_x_2)
432            .build();
433
434        assert!(matches!(result, Err(BuilderError::DuplicateCommands(_))));
435    }
436
437    #[test]
438    fn builder_overload() {
439        let command_x_1 = Command::new(
440            "Command X".into(),
441            vec![],
442            Box::new(TrivialCommandHandler::new()),
443        );
444
445        let command_x_2 = Command::new(
446            "Command X 2",
447            vec![CommandArgInfo::new(CommandArgType::I32)],
448            Box::new(TrivialCommandHandler::new()),
449        );
450
451        #[rustfmt::skip]
452        let result = Repl::builder()
453            .add("name_x", command_x_1)
454            .add("name_x", command_x_2)
455            .build();
456        assert!(matches!(result, Ok(_)));
457    }
458
459    #[test]
460    fn builder_empty() {
461        let command_empty = Command::new("", vec![], Box::new(TrivialCommandHandler::new()));
462
463        let result = Repl::builder().add("", command_empty).build();
464        assert!(matches!(result, Err(BuilderError::InvalidName(_))));
465    }
466
467    #[test]
468    fn builder_spaces() {
469        let command_empty = Command::new("", vec![], Box::new(TrivialCommandHandler::new()));
470
471        let result = Repl::builder()
472            .add("name-with spaces", command_empty)
473            .build();
474        assert!(matches!(result, Err(BuilderError::InvalidName(_))));
475    }
476
477    #[test]
478    fn builder_reserved() {
479        let command_help = Command::new("", vec![], Box::new(TrivialCommandHandler::new()));
480
481        let result = Repl::builder().add("help", command_help).build();
482        assert!(matches!(result, Err(BuilderError::ReservedName(_))));
483
484        let command_quit = Command::new("", vec![], Box::new(TrivialCommandHandler::new()));
485
486        let result = Repl::builder().add("quit", command_quit).build();
487        assert!(matches!(result, Err(BuilderError::ReservedName(_))));
488    }
489
490    #[tokio::test]
491    async fn repl_quits() {
492        let command_foo = Command::new(
493            "description",
494            vec![],
495            Box::new(TrivialCommandHandler::new()),
496        );
497
498        let mut repl = Repl::builder().add("foo", command_foo).build().unwrap();
499        assert_eq!(
500            repl.handle_line("quit".into()).await.unwrap(),
501            LoopStatus::Break
502        );
503
504        struct QuittingCommandHandler {}
505        impl QuittingCommandHandler {
506            pub fn new() -> Self {
507                Self {}
508            }
509            async fn handle_command(
510                &mut self,
511                _args: Vec<String>,
512            ) -> anyhow::Result<CommandStatus> {
513                Ok(CommandStatus::Quit)
514            }
515        }
516        impl ExecuteCommand for QuittingCommandHandler {
517            fn execute(
518                &mut self,
519                args: Vec<String>,
520                _args_info: Vec<CommandArgInfo>,
521            ) -> Pin<Box<dyn Future<Output = anyhow::Result<CommandStatus>> + '_>> {
522                Box::pin(self.handle_command(args))
523            }
524        }
525        let command_quit = Command::new(
526            "description",
527            vec![],
528            Box::new(QuittingCommandHandler::new()),
529        );
530
531        let mut repl = Repl::builder().add("foo", command_quit).build().unwrap();
532        assert_eq!(
533            repl.handle_line("foo".into()).await.unwrap(),
534            LoopStatus::Break
535        );
536    }
537}