replman/
lib.rs

1use std::str::FromStr;
2
3use rustyline::Editor;
4
5pub mod prelude {
6    pub use replman_derive::ReplCmd;
7
8    pub use crate::{read_command, Repl, ReplCmd};
9}
10
11pub struct Repl {
12    editor: Editor<()>,
13}
14
15impl Repl {
16    #[allow(clippy::new_without_default)]
17    pub fn new() -> Self {
18        Self {
19            editor: Editor::new(),
20        }
21    }
22
23    pub fn read_command<R>(&mut self) -> anyhow::Result<R>
24    where
25        R: ReplCmd,
26    {
27        loop {
28            let line = self.editor.readline("> ")?;
29            let trimmed = line.trim();
30
31            if trimmed.is_empty() {
32                continue;
33            }
34
35            match R::parse(split_string_unescape(trimmed)) {
36                Ok(cmd) => {
37                    self.editor.add_history_entry(trimmed);
38                    return Ok(cmd);
39                }
40                Err(err) => eprintln!("Failed to parse command: {}", err),
41            }
42        }
43    }
44}
45
46pub trait ReplCmd {
47    fn help() -> &'static str;
48    fn parse<'a, I>(parts: I) -> anyhow::Result<Self>
49    where
50        Self: Sized,
51        I: Iterator<Item = anyhow::Result<&'a str>> + 'a;
52
53    fn parse_str(s: &str) -> anyhow::Result<Self>
54    where
55        Self: Sized,
56    {
57        Self::parse(split_string_unescape(s))
58    }
59}
60
61pub trait ReplCmdParse {
62    fn parse(item: Option<&str>) -> anyhow::Result<Self>
63    where
64        Self: Sized;
65
66    fn parse_default(s: &str) -> anyhow::Result<Self>
67    where
68        Self: Sized;
69}
70
71macro_rules! impl_with_from_str {
72    ($t:ty) => {
73        impl ReplCmdParse for $t {
74            fn parse(item: Option<&str>) -> anyhow::Result<Self>
75            where
76                Self: Sized,
77            {
78                Ok(item
79                    .ok_or_else(|| anyhow::anyhow!("Missing field"))?
80                    .parse()?)
81            }
82
83            fn parse_default(s: &str) -> anyhow::Result<Self>
84            where
85                Self: Sized,
86            {
87                Ok(s.parse()?)
88            }
89        }
90    };
91}
92
93impl_with_from_str!(std::net::IpAddr);
94impl_with_from_str!(std::net::SocketAddr);
95impl_with_from_str!(bool);
96impl_with_from_str!(char);
97impl_with_from_str!(f32);
98impl_with_from_str!(f64);
99impl_with_from_str!(i8);
100impl_with_from_str!(i16);
101impl_with_from_str!(i32);
102impl_with_from_str!(i64);
103impl_with_from_str!(i128);
104impl_with_from_str!(isize);
105impl_with_from_str!(u8);
106impl_with_from_str!(u16);
107impl_with_from_str!(u32);
108impl_with_from_str!(u64);
109impl_with_from_str!(u128);
110impl_with_from_str!(usize);
111impl_with_from_str!(std::ffi::OsString);
112impl_with_from_str!(std::net::Ipv4Addr);
113impl_with_from_str!(std::net::Ipv6Addr);
114impl_with_from_str!(std::net::SocketAddrV4);
115impl_with_from_str!(std::net::SocketAddrV6);
116impl_with_from_str!(std::num::NonZeroI8);
117impl_with_from_str!(std::num::NonZeroI16);
118impl_with_from_str!(std::num::NonZeroI32);
119impl_with_from_str!(std::num::NonZeroI64);
120impl_with_from_str!(std::num::NonZeroI128);
121impl_with_from_str!(std::num::NonZeroIsize);
122impl_with_from_str!(std::num::NonZeroU8);
123impl_with_from_str!(std::num::NonZeroU16);
124impl_with_from_str!(std::num::NonZeroU32);
125impl_with_from_str!(std::num::NonZeroU64);
126impl_with_from_str!(std::num::NonZeroU128);
127impl_with_from_str!(std::num::NonZeroUsize);
128impl_with_from_str!(std::path::PathBuf);
129impl_with_from_str!(String);
130
131impl<T> ReplCmdParse for Option<T>
132where
133    T: FromStr,
134    anyhow::Error: From<<T as FromStr>::Err>,
135{
136    fn parse(item: Option<&str>) -> anyhow::Result<Self>
137    where
138        Self: Sized,
139    {
140        Ok(item.map(|s| s.parse()).transpose()?)
141    }
142
143    fn parse_default(_s: &str) -> anyhow::Result<Self>
144    where
145        Self: Sized,
146    {
147        unimplemented!("Using default on an Option field makes no sense")
148    }
149}
150
151pub fn read_command<R>() -> anyhow::Result<R>
152where
153    R: ReplCmd,
154{
155    let mut rl = Editor::<()>::new();
156
157    loop {
158        let line = rl.readline("> ")?;
159
160        if line.trim().is_empty() {
161            continue;
162        }
163
164        match R::parse(split_string_unescape(line.trim())) {
165            Ok(cmd) => return Ok(cmd),
166            Err(err) => eprintln!("Failed to parse command: {}", err),
167        }
168    }
169}
170
171fn split_string_unescape(
172    mut s: &str,
173) -> impl Iterator<Item = anyhow::Result<&str>> {
174    std::iter::from_fn(move || {
175        if s.is_empty() {
176            return None;
177        }
178
179        let next_unescaped_space = find_next_unescaped_space(s);
180
181        let ret = match next_unescaped_space {
182            Ok(x) => match x {
183                Some(x) => {
184                    let ret = &s[..x];
185                    s = &s[x + 1..];
186
187                    ret
188                }
189                None => {
190                    let temp = s;
191                    s = "";
192
193                    temp
194                }
195            },
196            Err(err) => return Some(Err(err)),
197        };
198
199        Some(Ok(unescape(ret)))
200    })
201}
202
203fn unescape(s: &str) -> &str {
204    if (s.starts_with('"') && s.ends_with('"'))
205        || (s.starts_with('\'') && s.ends_with('\''))
206    {
207        &s[1..s.len() - 1]
208    } else {
209        s
210    }
211}
212
213fn find_next_unescaped_space(s: &str) -> anyhow::Result<Option<usize>> {
214    let mut is_in_double_qoutes = false;
215    let mut is_in_single_quotes = false;
216
217    let mut previous_was_quoted = false;
218    for (idx, c) in s.char_indices() {
219        if previous_was_quoted && c != ' ' {
220            return Err(anyhow::anyhow!(
221                "Invalid command fragment, expected a space or end of string, found '{}'",
222                c
223            ));
224        }
225
226        match c {
227            '"' if !is_in_double_qoutes && !is_in_single_quotes => {
228                is_in_double_qoutes = true
229            }
230            '"' if is_in_double_qoutes => {
231                is_in_double_qoutes = false;
232                previous_was_quoted = true;
233            }
234            '\'' if !is_in_double_qoutes && !is_in_single_quotes => {
235                is_in_single_quotes = true
236            }
237            '\'' if is_in_single_quotes => {
238                is_in_single_quotes = false;
239                previous_was_quoted = true;
240            }
241            _ => previous_was_quoted = false,
242        }
243
244        if is_in_double_qoutes || is_in_single_quotes {
245            continue;
246        }
247
248        if c == ' ' {
249            return Ok(Some(idx));
250        }
251    }
252
253    Ok(None)
254}
255
256#[cfg(test)]
257mod tests {
258    use test_case::test_case;
259
260    use super::*;
261
262    #[test_case("Hello", vec!["Hello"] ; "Single item")]
263    #[test_case("Hello World!", vec!["Hello", "World!"] ; "Two items")]
264    #[test_case("", vec![] ; "Empty")]
265    fn basic(s: &str, exp: Vec<&str>) {
266        let actual: Vec<_> =
267            split_string_unescape(s).map(Result::unwrap).collect();
268        assert_eq!(actual, exp);
269    }
270
271    #[test_case(r#""Hello, World!""#, vec!["Hello, World!"] ; "Single - double quotes")]
272    #[test_case(r#"'Hello, World!'"#, vec!["Hello, World!"] ; "Single - single quotes")]
273    #[test_case(r#"'Hello, World!' "What is going on?""#, vec!["Hello, World!", "What is going on?"] ; "Two items - mixed")]
274    #[test_case(r#""" "" """#, vec!["", "", ""] ; "Sequence of double quotes")]
275    fn escaped(s: &str, exp: Vec<&str>) {
276        let actual: Vec<_> =
277            split_string_unescape(s).map(Result::unwrap).collect();
278        assert_eq!(actual, exp);
279    }
280}