Skip to main content

rainier_console/
arguments.rs

1//! Parsing a command line into [`Arguments`].
2//!
3//! A deliberately small grammar, chosen so parsing is unambiguous without a
4//! per-command specification:
5//!
6//! | Form | Meaning |
7//! |---|---|
8//! | `word` | a positional argument |
9//! | `--name=value` | an option |
10//! | `--name` | a flag |
11//! | `-abc` | the flags `a`, `b` and `c` |
12//! | `--` | everything after this is positional |
13//!
14//! `--name value` is **not** an option, because whether `value` belongs to
15//! `--name` or stands alone cannot be known without knowing the command's
16//! signature — and getting it wrong silently swallows an argument.
17
18use std::collections::{HashMap, HashSet};
19
20/// A parsed command line.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct Arguments {
23    command: String,
24    positional: Vec<String>,
25    options: HashMap<String, String>,
26    flags: HashSet<String>,
27}
28
29impl Arguments {
30    /// Parse an argument list, **without** the program name.
31    ///
32    /// The first positional word is the command; the rest belong to it.
33    pub fn parse<I, S>(argv: I) -> Self
34    where
35        I: IntoIterator<Item = S>,
36        S: Into<String>,
37    {
38        let mut parsed = Arguments::default();
39        let mut only_positional = false;
40
41        for raw in argv {
42            let argument: String = raw.into();
43
44            if only_positional {
45                parsed.push_positional(argument);
46                continue;
47            }
48
49            if argument == "--" {
50                only_positional = true;
51                continue;
52            }
53
54            if let Some(rest) = argument.strip_prefix("--") {
55                match rest.split_once('=') {
56                    Some((name, value)) => {
57                        parsed.options.insert(name.to_string(), value.to_string());
58                    }
59                    None => {
60                        parsed.flags.insert(rest.to_string());
61                    }
62                }
63                continue;
64            }
65
66            if let Some(rest) = argument.strip_prefix('-') {
67                if !rest.is_empty() && !rest.starts_with(|c: char| c.is_ascii_digit()) {
68                    // `-abc` is three flags, the convention every POSIX tool
69                    // follows. A leading digit means a negative number.
70                    for flag in rest.chars() {
71                        parsed.flags.insert(flag.to_string());
72                    }
73                    continue;
74                }
75            }
76
77            parsed.push_positional(argument);
78        }
79
80        parsed
81    }
82
83    fn push_positional(&mut self, argument: String) {
84        if self.command.is_empty() {
85            self.command = argument;
86        } else {
87            self.positional.push(argument);
88        }
89    }
90
91    /// The command name, or `""` if none was given.
92    pub fn command(&self) -> &str {
93        &self.command
94    }
95
96    /// Whether no command was given.
97    pub fn is_empty(&self) -> bool {
98        self.command.is_empty()
99    }
100
101    /// The positional arguments after the command.
102    pub fn positional(&self) -> &[String] {
103        &self.positional
104    }
105
106    /// The positional argument at `index`, after the command.
107    pub fn argument(&self, index: usize) -> Option<&str> {
108        self.positional.get(index).map(String::as_str)
109    }
110
111    /// An option's value.
112    ///
113    /// Options are `--name=value`. A bare `--name` is a [flag](Self::flag), so
114    /// `--port 8000` gives a flag called `port` and a stray positional
115    /// `8000` — and the value goes nowhere.
116    ///
117    /// That form cannot be supported without knowing which options take a
118    /// value (`--verbose migrate` is a flag and a command, not an option), so
119    /// instead it is **reported**: asking for an option that was given as a
120    /// bare flag warns and says what to write. Serving on the wrong port
121    /// because a `=` was missing is a bad afternoon.
122    pub fn option(&self, name: &str) -> Option<&str> {
123        let value = self.options.get(name).map(String::as_str);
124
125        if value.is_none() && self.flags.contains(name) {
126            tracing::warn!(
127                "`--{name}` was given with no value; write `--{name}=value`. Ignoring it."
128            );
129        }
130        value
131    }
132
133    /// An option's value, or `default`.
134    pub fn option_or<'a>(&'a self, name: &str, default: &'a str) -> &'a str {
135        self.option(name).unwrap_or(default)
136    }
137
138    /// An option parsed into `T`, or `default` if absent or unparsable.
139    pub fn parsed_or<T: std::str::FromStr>(&self, name: &str, default: T) -> T {
140        self.option(name).and_then(|value| value.parse().ok()).unwrap_or(default)
141    }
142
143    /// Whether a flag was given.
144    pub fn flag(&self, name: &str) -> bool {
145        self.flags.contains(name)
146    }
147
148    /// Whether `name` was given as a bare flag when a value was wanted.
149    ///
150    /// What [`option`](Self::option) warns about, exposed so a command can
151    /// refuse rather than carry on with a default.
152    pub fn is_valueless(&self, name: &str) -> bool {
153        self.flags.contains(name) && !self.options.contains_key(name)
154    }
155
156    /// Whether help was asked for, in any of its usual spellings.
157    pub fn wants_help(&self) -> bool {
158        self.flag("help") || self.flag("h") || self.command == "help"
159    }
160
161    /// Every option, for diagnostics.
162    pub fn options(&self) -> &HashMap<String, String> {
163        &self.options
164    }
165
166    /// Every flag, for diagnostics.
167    pub fn flags(&self) -> &HashSet<String> {
168        &self.flags
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn parse(line: &str) -> Arguments {
177        Arguments::parse(line.split_whitespace())
178    }
179
180    #[test]
181    fn the_first_word_is_the_command() {
182        let args = parse("route:list");
183        assert_eq!(args.command(), "route:list");
184        assert!(args.positional().is_empty());
185        assert!(!args.is_empty());
186    }
187
188    #[test]
189    fn an_empty_line_has_no_command() {
190        assert!(Arguments::parse(Vec::<String>::new()).is_empty());
191    }
192
193    #[test]
194    fn later_words_are_positional() {
195        let args = parse("make:controller PostController extra");
196        assert_eq!(args.command(), "make:controller");
197        assert_eq!(args.argument(0), Some("PostController"));
198        assert_eq!(args.argument(1), Some("extra"));
199        assert_eq!(args.argument(2), None);
200    }
201
202    #[test]
203    fn long_options_carry_a_value() {
204        let args = parse("serve --port=3000 --host=0.0.0.0");
205        assert_eq!(args.option("port"), Some("3000"));
206        assert_eq!(args.option("host"), Some("0.0.0.0"));
207        assert_eq!(args.parsed_or("port", 8000u16), 3000);
208    }
209
210    #[test]
211    fn a_missing_or_unparsable_option_falls_back() {
212        let args = parse("serve --port=nonsense");
213        assert_eq!(args.parsed_or("port", 8000u16), 8000);
214        assert_eq!(args.parsed_or("timeout", 30u32), 30);
215        assert_eq!(args.option_or("host", "127.0.0.1"), "127.0.0.1");
216    }
217
218    #[test]
219    fn a_valueless_long_argument_is_a_flag() {
220        let args = parse("queue:work --once --quiet");
221        assert!(args.flag("once"));
222        assert!(args.flag("quiet"));
223        assert!(!args.flag("verbose"));
224        assert!(args.option("once").is_none());
225    }
226
227    #[test]
228    fn short_flags_can_be_bundled() {
229        let args = parse("migrate -vf");
230        assert!(args.flag("v"));
231        assert!(args.flag("f"));
232        assert_eq!(args.flags().len(), 2);
233    }
234
235    #[test]
236    fn a_separated_value_is_positional_not_an_option() {
237        // `--port 3000` is ambiguous without a per-command signature, so
238        // `3000` stays positional rather than being silently swallowed.
239        let args = parse("serve --port 3000");
240        assert!(args.flag("port"));
241        assert_eq!(args.option("port"), None);
242        assert_eq!(args.argument(0), Some("3000"));
243    }
244
245    #[test]
246    fn a_double_dash_ends_option_parsing() {
247        let args = parse("run -- --not-a-flag positional");
248        assert_eq!(args.command(), "run");
249        assert_eq!(args.positional(), ["--not-a-flag", "positional"]);
250        assert!(args.flags().is_empty());
251    }
252
253    #[test]
254    fn a_negative_number_is_not_a_flag_bundle() {
255        let args = parse("offset -5");
256        assert_eq!(args.argument(0), Some("-5"));
257        assert!(args.flags().is_empty());
258    }
259
260    #[test]
261    fn an_empty_option_value_is_kept() {
262        let args = parse("serve --host=");
263        assert_eq!(args.option("host"), Some(""));
264    }
265
266    #[test]
267    fn help_is_recognised_however_it_is_spelled() {
268        assert!(parse("serve --help").wants_help());
269        assert!(parse("serve -h").wants_help());
270        assert!(parse("help").wants_help());
271        assert!(!parse("serve").wants_help());
272    }
273
274    #[test]
275    fn an_option_given_without_a_value_is_not_silently_a_flag() {
276        // `--port 8091` is how most CLIs are invoked, and here it parses as a
277        // flag plus a positional. It cannot be *made* to work — `--verbose
278        // migrate` is a flag and a command — so it has to be visible instead.
279        let args = Arguments::parse(["serve", "--port", "8091"]);
280
281        assert!(args.is_valueless("port"));
282        assert_eq!(args.option("port"), None);
283        assert_eq!(args.argument(0), Some("8091"), "the value became a positional");
284
285        // The documented form still works, and reports nothing.
286        let args = Arguments::parse(["serve", "--port=8091"]);
287        assert_eq!(args.option("port"), Some("8091"));
288        assert!(!args.is_valueless("port"));
289    }
290}