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}