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