Skip to main content

balise_cli/
args.rs

1//! Reading a command's arguments from what its declaration allows.
2//!
3//! Every mistake has a `CLI` code: an unknown option with the closest one, a missing value, a
4//! value outside the allowed ones, two options of a group that excludes the others.
5
6use balise_core::error_codes::{CLI0002, CLI0003, CLI0004, CLI0005};
7use balise_core::text::closest;
8
9use crate::Failure;
10
11/// An option of a command: `--no-example`, or `--front <framework>`.
12pub struct Opt {
13    pub name: &'static str,
14    /// What the value is called in help (`framework`), for an option that takes one.
15    pub value: Option<&'static str>,
16    /// The values accepted, when they are a fixed list.
17    pub values: &'static [&'static str],
18    pub help: &'static str,
19}
20
21impl Opt {
22    pub const fn flag(name: &'static str, help: &'static str) -> Opt {
23        Opt {
24            name,
25            value: None,
26            values: &[],
27            help,
28        }
29    }
30
31    pub const fn value(
32        name: &'static str,
33        value: &'static str,
34        values: &'static [&'static str],
35        help: &'static str,
36    ) -> Opt {
37        Opt {
38            name,
39            value: Some(value),
40            values,
41            help,
42        }
43    }
44}
45
46/// Options shared by every command.
47pub const GLOBAL: &[Opt] = &[
48    Opt::flag("--json", "print the result as JSON, for programs"),
49    Opt::flag("--no-input", "never ask a question, even in a terminal"),
50    Opt::flag("--help", "describe the command"),
51];
52
53/// What a command was given.
54#[derive(Debug, Default, PartialEq, Eq)]
55pub struct Parsed {
56    pub positionals: Vec<String>,
57    pub flags: Vec<&'static str>,
58    pub values: Vec<(&'static str, String)>,
59}
60
61impl Parsed {
62    pub fn has(&self, name: &str) -> bool {
63        self.flags.contains(&name)
64    }
65
66    pub fn value(&self, name: &str) -> Option<&str> {
67        self.values
68            .iter()
69            .find(|(n, _)| *n == name)
70            .map(|(_, v)| v.as_str())
71    }
72
73    /// Whether the option was given, with or without a value.
74    pub fn given(&self, name: &str) -> bool {
75        self.has(name) || self.value(name).is_some()
76    }
77}
78
79/// Reads `args` for `command`, whose options are `options` and the global ones. `exclusive`
80/// lists groups of options of which at most one may be given.
81pub fn parse(
82    command: &str,
83    args: &[String],
84    options: &'static [Opt],
85    exclusive: &[&[&str]],
86) -> Result<Parsed, Failure> {
87    let mut parsed = Parsed::default();
88    let all = || options.iter().chain(GLOBAL);
89    let mut i = 0;
90    while i < args.len() {
91        let arg = &args[i];
92        i += 1;
93        if !arg.starts_with("--") || arg == "--" {
94            parsed.positionals.push(arg.clone());
95            continue;
96        }
97        let (name, inline) = match arg.split_once('=') {
98            Some((name, value)) => (name, Some(value.to_string())),
99            None => (arg.as_str(), None),
100        };
101        let Some(opt) = all().find(|opt| opt.name == name) else {
102            let names: Vec<&str> = all().map(|opt| opt.name).collect();
103            let suggestion = closest(name, &names)
104                .map(|name| format!("; did you mean `{name}`?"))
105                .unwrap_or_default();
106            return Err(Failure::usage(
107                CLI0002,
108                &[
109                    ("option", name),
110                    ("command", command),
111                    ("suggestion", &suggestion),
112                ],
113                &[("command", command)],
114            ));
115        };
116        match opt.value {
117            None => parsed.flags.push(opt.name),
118            Some(placeholder) => {
119                let value = match inline {
120                    Some(value) => value,
121                    None if i < args.len() && !args[i].starts_with("--") => {
122                        i += 1;
123                        args[i - 1].clone()
124                    }
125                    None => {
126                        let missing = format!("a value after `{}`", opt.name);
127                        let example = format!("balise {command} {} <{placeholder}>", opt.name);
128                        return Err(Failure::usage(
129                            CLI0003,
130                            &[("command", command), ("missing", &missing)],
131                            &[("example", &example)],
132                        ));
133                    }
134                };
135                if !opt.values.is_empty() && !opt.values.contains(&value.as_str()) {
136                    let expected = opt
137                        .values
138                        .iter()
139                        .map(|v| format!("`{v}`"))
140                        .collect::<Vec<_>>()
141                        .join(", ");
142                    return Err(Failure::usage(
143                        CLI0004,
144                        &[
145                            ("value", &value),
146                            ("option", opt.name),
147                            ("expected", &expected),
148                        ],
149                        &[],
150                    ));
151                }
152                parsed.values.push((opt.name, value));
153            }
154        }
155    }
156    for group in exclusive {
157        let given: Vec<&&str> = group.iter().filter(|name| parsed.given(name)).collect();
158        if let [first, second, ..] = given.as_slice() {
159            return Err(Failure::usage(
160                CLI0005,
161                &[("first", first), ("second", second)],
162                &[],
163            ));
164        }
165    }
166    Ok(parsed)
167}
168
169/// `--front <framework>` as help shows it.
170pub fn usage(opt: &Opt) -> String {
171    match opt.value {
172        Some(value) => format!("{} <{value}>", opt.name),
173        None => opt.name.to_string(),
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    const OPTIONS: &[Opt] = &[
182        Opt::value("--front", "framework", &["react", "vue"], "the front end"),
183        Opt::flag("--no-front", "no front end"),
184        Opt::flag("--no-example", "no example"),
185    ];
186
187    fn run(args: &[&str]) -> Result<Parsed, String> {
188        let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
189        parse("new", &args, OPTIONS, &[&["--front", "--no-front"]])
190            .map_err(|failure| failure.report())
191    }
192
193    #[test]
194    fn options_and_positionals_are_read() {
195        let parsed = run(&["shop", "--front", "react", "--no-example"]).unwrap();
196        assert_eq!(parsed.positionals, ["shop"]);
197        assert_eq!(parsed.value("--front"), Some("react"));
198        assert!(parsed.has("--no-example"));
199        assert_eq!(run(&["--front=vue"]).unwrap().value("--front"), Some("vue"));
200    }
201
202    #[test]
203    fn mistakes_have_their_codes() {
204        assert_eq!(
205            run(&["--no-exemple"]).unwrap_err(),
206            "CLI0002: `--no-exemple` is not an option of `balise new`; did you mean `--no-example`?\n  fix: Run `balise help new` to list its options.\n  more: balise explain CLI0002"
207        );
208        assert!(run(&["--front"]).unwrap_err().starts_with("CLI0003"));
209        assert!(run(&["--front", "angular"]).unwrap_err().starts_with(
210            "CLI0004: `angular` is not a valid value for `--front`; expected `react`, `vue`"
211        ));
212        assert!(
213            run(&["--front", "react", "--no-front"])
214                .unwrap_err()
215                .starts_with("CLI0005: `--front` and `--no-front` cannot be used together")
216        );
217    }
218}