Skip to main content

sys_rs/
repl.rs

1use nix::errno::Errno;
2use rustyline::{
3    completion::{Completer, Pair},
4    error::ReadlineError,
5    highlight::Highlighter,
6    hint::Hinter,
7    history::FileHistory,
8    validate::Validator,
9    Context, Editor, Helper,
10};
11
12use crate::{
13    command::Registry,
14    diag::{Error, Result},
15    progress::State,
16};
17
18struct CmdHelper {
19    options: Vec<String>,
20}
21
22impl Completer for CmdHelper {
23    type Candidate = Pair;
24
25    fn complete(
26        &self,
27        line: &str,
28        pos: usize,
29        _: &Context<'_>,
30    ) -> rustyline::Result<(usize, Vec<Pair>)> {
31        let prefix = line[..pos].split_whitespace().collect::<Vec<_>>().join(" ");
32        let input_words: Vec<&str> = prefix.split_whitespace().collect();
33
34        let matches = self
35            .options
36            .iter()
37            .filter(|cmd| {
38                let cmd_words: Vec<&str> = cmd.split_whitespace().collect();
39                input_words
40                    .iter()
41                    .zip(cmd_words.iter())
42                    .all(|(input, cmd)| cmd.starts_with(input))
43                    && input_words.len() <= cmd_words.len()
44            })
45            .map(|cmd| Pair {
46                display: cmd.as_str().to_owned(),
47                replacement: cmd.as_str().to_owned(),
48            })
49            .collect();
50
51        Ok((0, matches))
52    }
53}
54
55impl Hinter for CmdHelper {
56    type Hint = String;
57}
58impl Highlighter for CmdHelper {}
59impl Validator for CmdHelper {}
60impl Helper for CmdHelper {}
61
62/// REPL runner that manages the line-editor and command registry.
63///
64/// The `Runner` owns a `rustyline::Editor` configured with a simple
65/// `CmdHelper` for tab-completion and a `Registry` of available commands.
66/// Construct a `Runner` with `Runner::new()` and call `run(state)` to read
67/// a single line of input and dispatch the corresponding command.
68pub struct Runner {
69    readline: Editor<CmdHelper, FileHistory>,
70    registry: Registry,
71}
72
73impl Runner {
74    /// Construct a new REPL `Runner` with default command registry and
75    /// line-editing helper.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the underlying `rustyline::Editor` cannot be
80    /// created.
81    ///
82    /// # Returns
83    ///
84    /// A `Result` containing the newly created `Runner`.
85    pub fn new() -> Result<Self> {
86        let registry = Registry::default();
87        let mut readline = Editor::new()?;
88        readline.set_helper(Some(CmdHelper {
89            options: registry.completions(),
90        }));
91
92        Ok(Self { readline, registry })
93    }
94
95    /// Read a single line from the user and dispatch the corresponding
96    /// command.
97    ///
98    /// # Arguments
99    ///
100    /// * `state` - Mutable reference to the runtime `State` passed to
101    ///   command handlers.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error when readline operations fail or when command
106    /// dispatching returns an error.
107    ///
108    /// # Returns
109    ///
110    /// Returns the `Result` returned by the invoked command handler.
111    pub fn run(&mut self, state: &mut State) -> Result<()> {
112        let readline = self.readline.readline("dbg> ");
113        match readline {
114            Ok(line) => {
115                let trimmed = line.trim();
116                let input = if trimmed.is_empty() {
117                    self.readline
118                        .history()
119                        .into_iter()
120                        .last()
121                        .map_or("", |s| s.trim())
122                } else {
123                    self.readline.add_history_entry(trimmed)?;
124                    trimmed
125                };
126
127                self.registry.run(input, state)
128            }
129            Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
130                self.registry.run("quit", state)
131            }
132            _ => Err(Error::from(Errno::EIO)),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_cmdhelper_complete_prefix() {
143        let helper = CmdHelper {
144            options: vec!["foo bar".into(), "foo baz".into(), "other".into()],
145        };
146
147        let line = "fo";
148        let pos = 2usize;
149        let prefix = line[..pos].split_whitespace().collect::<Vec<_>>().join(" ");
150        let input_words: Vec<&str> = prefix.split_whitespace().collect();
151
152        let matches: Vec<String> = helper
153            .options
154            .iter()
155            .filter(|cmd| {
156                let cmd_words: Vec<&str> = cmd.split_whitespace().collect();
157                input_words
158                    .iter()
159                    .zip(cmd_words.iter())
160                    .all(|(input, cmd)| cmd.starts_with(input))
161                    && input_words.len() <= cmd_words.len()
162            })
163            .cloned()
164            .collect();
165
166        assert!(matches.contains(&"foo bar".to_string()));
167        assert!(matches.contains(&"foo baz".to_string()));
168    }
169
170    #[test]
171    fn test_runner_new() {
172        let _runner = Runner::new().expect("runner new");
173    }
174}