rust_script_ext/
args.rs

1//! Functional argument parsing.
2use crate::prelude::{anyhow, Context, Result};
3use std::{any::type_name, str::FromStr};
4
5/// Get the command line [`Args`].
6pub fn args() -> Args {
7    let mut args = std::env::args();
8    args.next(); // skip process name
9    let len = args.len();
10    Args {
11        incoming: Box::new(args),
12        seen: Vec::with_capacity(len),
13        idx: 0,
14        excl: vec![false; len].into_boxed_slice(),
15    }
16}
17
18/// Arguments iterator.
19///
20/// This provides an additional utility layer on top of [`std::env::Args`].
21/// It does not aim to be a fully feature argument parser,
22/// [`clap`](https://docs.rs/clap/latest/clap/index.html) is great for this, at the cost of a much
23/// heavier crate.
24///
25/// This struct is meant to provide an iterator-like interface layering on parsing and error
26/// handling.
27///
28/// The two most common functions are [`req`](Args::req) and [`opt`](Args::opt), which will parse the current argument
29/// position and advance to the next position.
30///
31/// To create an [`Args`], use the [`args`] function.
32pub struct Args {
33    /// An iterator of incoming arguments.
34    incoming: Box<dyn ExactSizeIterator<Item = String>>,
35    /// Already iterated arguments, in canonical order.
36    seen: Vec<String>,
37    /// The current argument position.
38    idx: usize,
39    /// Arguments to skip over when iterating.
40    excl: Box<[bool]>,
41}
42
43impl Args {
44    /// Parse current argument, requiring it exist, and advance the argument position.
45    ///
46    /// `desc` describes the argument in case of failure.
47    ///
48    /// # Example
49    /// ```rust
50    /// # use rust_script_ext::prelude::*;
51    /// # use std::path::PathBuf;
52    /// let mut args = Args::from(vec!["fst.txt", "24h"]);
53    ///
54    /// let fst = args.req::<PathBuf>("filepath").unwrap();
55    /// // humantime::Duration can parse nicely
56    /// let dur = args.req::<Duration>("delay length").unwrap();
57    ///
58    /// let err = args.req::<String>("output").unwrap_err().to_string();
59    /// assert_eq!(&err, "expecting an argument at position 3");
60    /// ```
61    pub fn req<T>(&mut self, desc: impl AsRef<str>) -> Result<T>
62    where
63        T: FromStr,
64        T::Err: std::error::Error + Send + Sync + 'static,
65    {
66        let desc = desc.as_ref();
67        self.opt(desc)?.ok_or_else(|| {
68            self.make_err(
69                desc,
70                format!("expecting an argument at position {}", self.idx + 1),
71            )
72        })
73    }
74
75    /// Parse current argument, returning `None` if it does not exist.
76    /// If it does exist (and parses), advances the argument position.
77    ///
78    /// `T` should implement [`FromStr`] with `FromStr::Err` implementing [`IntoDiagnostic`].
79    /// `desc` describes the argument in case of failure.
80    ///
81    /// # Example
82    /// ```rust
83    /// # use rust_script_ext::prelude::*;
84    /// # use std::path::PathBuf;
85    /// let mut args = Args::from(vec!["fst.txt"]);
86    ///
87    /// let fst = args.opt::<String>("filepath").unwrap();
88    /// assert_eq!(fst, Some("fst.txt".to_string()));
89    /// let dur = args.opt::<Duration>("delay").unwrap();
90    /// assert!(dur.is_none());
91    ///
92    /// // parsing error
93    /// let mut args = Args::from(vec!["text"]);
94    /// let err = args.opt::<f64>("a number").unwrap_err();
95    /// assert_eq!(&err.to_string(), "failed to parse `text` as f64");
96    /// ```
97    pub fn opt<T>(&mut self, desc: impl AsRef<str>) -> Result<Option<T>>
98    where
99        T: FromStr,
100        T::Err: std::error::Error + Send + Sync + 'static,
101    {
102        let x = self
103            .peek()
104            .map_err(|e| self.make_err(desc.as_ref(), e.to_string()));
105        if matches!(x, Ok(Some(_))) {
106            self.advance_pos();
107        }
108        x
109    }
110
111    /// Test if there is an argument satifying the predicate.
112    ///
113    /// This tests from the current argument position, supplying the argument text to the predicate
114    /// closure.
115    ///
116    /// If the argument satisfies the predicate, `true` is returned and **that argument is
117    /// excluded** from future queries (including `req` and `opt`).
118    ///
119    /// This is useful to test for flags.
120    ///
121    /// # Example
122    /// ```rust
123    /// # use rust_script_ext::prelude::*;
124    /// let mut args = Args::from(vec!["fst.txt", "-c", "24h"]);
125    ///
126    /// let cut = args.has(|x| x == "-c" || x == "--cut");
127    /// assert!(cut);
128    ///
129    /// // skips '-c' argument when advancing
130    /// assert_eq!(&args.req::<String>("").unwrap(), "fst.txt");
131    /// assert_eq!(&args.req::<String>("").unwrap(), "24h");
132    /// ```
133    pub fn has<P>(&mut self, mut pred: P) -> bool
134    where
135        P: FnMut(&str) -> bool,
136    {
137        let idx = self.idx;
138        let mut fi = None;
139        while let Some(a) = self.peek_str() {
140            if pred(a) {
141                fi = Some(self.idx);
142                break;
143            }
144            self.advance_pos();
145        }
146
147        self.idx = idx; // set pos back
148
149        match fi {
150            Some(i) => {
151                self.excl[i] = true;
152                true
153            }
154            None => false,
155        }
156    }
157
158    /// Assert that no more arguments should be present.
159    ///
160    /// # Example
161    /// ```rust
162    /// # use rust_script_ext::prelude::*;
163    /// let mut args = Args::from(vec!["fst.txt", "-c", "24h"]);
164    ///
165    /// args.req::<String>("").unwrap();
166    ///
167    /// let err = args.finish().unwrap_err().to_string();
168    /// assert_eq!(&err, "unconsumed arguments provided");
169    /// ```
170    pub fn finish(&mut self) -> Result<()> {
171        let mut x = true;
172        let idx = self.idx;
173        while self.peek_str().is_some() {
174            x = false;
175            self.advance_pos();
176        }
177
178        if x {
179            return Ok(());
180        }
181
182        let (offset, src) =
183            self.seen
184                .iter()
185                .enumerate()
186                .fold((0, String::new()), |(o, s), (i, a)| {
187                    let o = if i == idx { s.len() } else { o };
188
189                    (o, s + a + " ")
190                });
191
192        Err(anyhow!(src[offset..src.len()].to_string())).context("unconsumed arguments provided")
193    }
194
195    /// Parse the current argument _without advancing the argument position._
196    ///
197    /// `T` should implement [`FromStr`] with `FromStr::Err` implementing [`IntoDiagnostic`].
198    ///
199    /// # Example
200    /// ```rust
201    /// # use rust_script_ext::prelude::*;
202    /// # use std::str::FromStr;
203    /// let mut args = Args::from(vec!["24h"]);
204    ///
205    /// let d = args.peek::<Duration>().unwrap().unwrap();
206    /// assert_eq!(d, Duration::from_str("24h").unwrap());
207    ///
208    /// assert!(args.finish().is_err()); // position not advanced
209    /// ```
210    pub fn peek<T>(&mut self) -> Result<Option<T>>
211    where
212        T: FromStr,
213        T::Err: std::error::Error + Send + Sync + 'static,
214    {
215        self.peek_str()
216            .map(|x| {
217                T::from_str(x)
218                    .with_context(|| format!("failed to parse `{x}` as {}", type_name::<T>()))
219            })
220            .transpose()
221    }
222
223    /// Retrieve the current argument as a string _without advancing the argument position._
224    ///
225    ///
226    /// # Example
227    /// ```rust
228    /// # use rust_script_ext::prelude::*;
229    /// let mut args = Args::from(vec!["fst.txt", "24h"]);
230    ///
231    /// assert_eq!(args.peek_str(), Some("fst.txt"));
232    /// assert_eq!(&args.req::<String>("filepath").unwrap(), "fst.txt");
233    /// assert_eq!(args.peek_str(), Some("24h"));
234    /// ```
235    pub fn peek_str(&mut self) -> Option<&str> {
236        if self.idx >= self.seen.len() {
237            self.seen.extend(self.incoming.next());
238        }
239        self.seen.get(self.idx).map(|x| x.as_str())
240    }
241
242    /// Retreat the argument position back one.
243    ///
244    /// Skips over excluded arguments.
245    ///
246    /// # Example
247    /// ```rust
248    /// # use rust_script_ext::prelude::*;
249    /// let mut args = Args::from(vec!["fst.txt", "-c", "24h"]);
250    ///
251    /// args.has(|x| x == "-c"); // exclude -c flag
252    /// args.req::<String>("").ok();
253    /// args.req::<String>("").ok(); // at end now
254    ///
255    /// args.move_back();
256    /// assert_eq!(args.peek_str(), Some("24h"));
257    ///
258    /// args.move_back();
259    /// // skips the excluded
260    /// assert_eq!(args.peek_str(), Some("fst.txt"));
261    /// ```
262    pub fn move_back(&mut self) {
263        self.idx = self.idx.saturating_sub(1);
264        while self.idx > 0 && self.excl[self.idx] {
265            self.idx -= 1;
266        }
267
268        if !self.excl.is_empty() && self.excl[self.idx] {
269            self.advance_pos();
270        }
271    }
272
273    /// Move to the front of the arguments.
274    ///
275    /// Skips over excluded arguments.
276    ///
277    /// # Example
278    /// ```rust
279    /// # use rust_script_ext::prelude::*;
280    /// let mut args = Args::from(vec!["-c", "fst.txt", "24h"]);
281    ///
282    /// args.has(|x| x == "-c"); // exclude -c flag
283    /// args.req::<String>("").ok();
284    /// args.req::<String>("").ok(); // at end now
285    ///
286    /// args.move_front();
287    /// assert_eq!(args.peek_str(), Some("fst.txt"));
288    /// ```
289    pub fn move_front(&mut self) {
290        self.idx = 0;
291        if !self.excl.is_empty() && self.excl[self.idx] {
292            self.advance_pos();
293        }
294    }
295
296    /// Advance the argument position, skipping any excluded arguments.
297    fn advance_pos(&mut self) {
298        self.idx += 1; // always advance one
299        while self.idx < self.excl.len() && self.excl[self.idx] {
300            // only advance if less than total len
301            // AND the current index is flagged to exclude
302            self.idx += 1;
303        }
304    }
305
306    fn make_err(&self, desc: &str, msg: impl AsRef<str>) -> anyhow::Error {
307        let (offset, src) =
308            self.seen
309                .iter()
310                .enumerate()
311                .fold((0..0, String::new()), |(o, s), (i, a)| {
312                    let o = if i == self.idx {
313                        s.len()..(s.len() + a.len())
314                    } else {
315                        o
316                    };
317
318                    (o, s + a + " ")
319                });
320
321        let offset = if offset == (0..0) {
322            src.len().saturating_sub(1)..src.len().saturating_sub(1)
323        } else {
324            offset
325        };
326
327        <Result<()>>::Err(anyhow!(src[offset].to_string()))
328            .with_context(|| format!("error with argument <{desc}>"))
329            .with_context(|| msg.as_ref().to_string())
330            .expect_err("is an error")
331    }
332}
333
334/// Consume the _remaining_ arguments as an iterator over the raw strings.
335///
336/// Note that this starts from the argument position **and** skips any excluded arguments.
337///
338/// # Example
339/// ```rust
340/// # use rust_script_ext::prelude::*;
341/// let mut args = Args::from(vec!["fst.txt", "-c", "24h", "output"]);
342/// args.req::<String>("").unwrap();
343/// args.has(|x| x== "-c");
344///
345/// let rem = args.into_iter().collect::<Vec<_>>();
346/// assert_eq!(&rem, &[
347///    "24h".to_string(),
348///    "output".to_string(),
349/// ]);
350/// ```
351impl IntoIterator for Args {
352    type Item = String;
353    type IntoIter = Box<dyn Iterator<Item = String>>;
354
355    fn into_iter(self) -> Self::IntoIter {
356        let Args {
357            incoming,
358            seen,
359            idx,
360            excl,
361        } = self;
362
363        Box::new(
364            seen.into_iter()
365                .enumerate()
366                .skip(idx)
367                .filter_map(move |(i, a)| (!excl[i]).then_some(a))
368                .chain(incoming),
369        )
370    }
371}
372
373impl From<Vec<String>> for Args {
374    fn from(value: Vec<String>) -> Self {
375        let len = value.len();
376        Self {
377            incoming: Box::new(value.into_iter()),
378            seen: Vec::with_capacity(len),
379            idx: 0,
380            excl: vec![false; len].into_boxed_slice(),
381        }
382    }
383}
384
385impl From<Vec<&'static str>> for Args {
386    fn from(value: Vec<&'static str>) -> Self {
387        let len = value.len();
388        Self {
389            incoming: Box::new(value.into_iter().map(Into::into)),
390            seen: Vec::with_capacity(len),
391            idx: 0,
392            excl: vec![false; len].into_boxed_slice(),
393        }
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::prelude::*;
401    use crate::pretty_print_err;
402    use insta::assert_snapshot;
403
404    #[test]
405    fn error_printing_req() {
406        let mut args = Args::from(vec!["fst.txt", "24h"]);
407
408        assert_snapshot!(
409            "parse-err",
410            pretty_print_err(args.req::<Duration>("delay").unwrap_err())
411        );
412
413        let _ = args.req::<String>("filepath").unwrap();
414        let _ = args.req::<String>("delay length").unwrap();
415        assert_snapshot!(
416            "non-existent",
417            pretty_print_err(args.req::<String>("output").unwrap_err())
418        );
419    }
420
421    #[test]
422    fn error_printing_finish() {
423        let mut args = Args::from(vec!["fst.txt", "24h"]);
424
425        let _ = args.req::<String>("filepath").unwrap();
426        assert_snapshot!(pretty_print_err(args.finish().unwrap_err()));
427    }
428
429    #[test]
430    fn empty_args_no_panic() {
431        let mut args = Args::from(Vec::<String>::new());
432
433        assert!(args.req::<String>("").is_err());
434        assert!(args.opt::<String>("").unwrap().is_none());
435        assert!(args.peek::<String>().unwrap().is_none());
436        assert!(args.peek_str().is_none());
437        assert!(!args.has(|_| true));
438        args.move_front();
439        args.move_back();
440    }
441}