1use balise_core::error_codes::{CLI0002, CLI0003, CLI0004, CLI0005};
7use balise_core::text::closest;
8
9use crate::Failure;
10
11pub struct Opt {
13 pub name: &'static str,
14 pub value: Option<&'static str>,
16 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
46pub 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#[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 pub fn given(&self, name: &str) -> bool {
75 self.has(name) || self.value(name).is_some()
76 }
77}
78
79pub 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
169pub 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}