Skip to main content

rich_ext/cli_doc/
error.rs

1//! Command-line errors as diagnostics (#407), with "did you mean"
2//! suggestions from Jaro-Winkler similarity.
3
4use super::spec::CommandSpec;
5use crate::diagnostic::Diagnostic;
6use crate::event::EventView;
7
8/// What went wrong on the command line.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum CliErrorKind {
11    UnknownArgument,
12    MissingValue,
13    InvalidValue,
14    MissingRequired,
15    UnexpectedValue,
16    Conflict,
17    UnknownSubcommand,
18    Other,
19}
20
21/// A command-line error. Render it with [`CliError::to_diagnostic`].
22///
23/// ```
24/// use rich::Console;
25/// use rich_ext::cli_doc::CliError;
26///
27/// let error = CliError::unknown_argument("--colr", ["--color", "--width"]).usage("rich [OPTIONS]");
28/// let out = Console::builder().width(60).build().render_to_string(&error.to_diagnostic());
29/// assert_eq!(
30///     out,
31///     "error: unexpected argument '--colr'\n\
32///      note: usage: rich [OPTIONS]\n\
33///      help: a similar argument exists: '--color'"
34/// );
35/// ```
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct CliError {
38    pub kind: CliErrorKind,
39    /// The headline; generated from the kind and fields when empty.
40    pub message: String,
41    /// The argument at fault, as typed (`--colr`) or as named (`--width <N>`).
42    pub argument: Option<String>,
43    /// The value at fault, or for a conflict the other argument.
44    pub value: Option<String>,
45    /// Similar spellings, best first.
46    pub suggestions: Vec<String>,
47    /// The allowed values, for an invalid value.
48    pub possible_values: Vec<String>,
49    /// A usage line shown as a note.
50    pub usage: Option<String>,
51    /// The help switch to point at (`--help`), shown as a closing tip.
52    pub help_flag: Option<String>,
53}
54
55impl CliError {
56    pub fn new(kind: CliErrorKind, message: impl Into<String>) -> Self {
57        CliError {
58            kind,
59            message: message.into(),
60            argument: None,
61            value: None,
62            suggestions: Vec::new(),
63            possible_values: Vec::new(),
64            usage: None,
65            help_flag: None,
66        }
67    }
68
69    /// An unknown option, with suggestions drawn from `candidates`.
70    pub fn unknown_argument<S: AsRef<str>>(
71        argument: impl Into<String>,
72        candidates: impl IntoIterator<Item = S>,
73    ) -> Self {
74        let argument = argument.into();
75        let suggestions = suggest(&argument, candidates);
76        CliError::new(CliErrorKind::UnknownArgument, "")
77            .argument(argument)
78            .suggestions(suggestions)
79    }
80
81    /// An unknown subcommand, with suggestions drawn from `candidates`.
82    pub fn unknown_subcommand<S: AsRef<str>>(
83        name: impl Into<String>,
84        candidates: impl IntoIterator<Item = S>,
85    ) -> Self {
86        let name = name.into();
87        let suggestions = suggest(&name, candidates);
88        CliError::new(CliErrorKind::UnknownSubcommand, "")
89            .argument(name)
90            .suggestions(suggestions)
91    }
92
93    /// An unknown option or subcommand in `spec`: an argument starting with
94    /// `-` is checked against the switches, anything else against the
95    /// subcommands. The usage comes from the spec.
96    pub fn unknown_in(spec: &CommandSpec, argument: &str) -> Self {
97        let error = if argument.starts_with('-') {
98            CliError::unknown_argument(argument, spec.switch_names())
99        } else {
100            CliError::unknown_subcommand(argument, spec.subcommand_names())
101        };
102        error.usage(spec.usage_lines().join("\n"))
103    }
104
105    /// A value that is not allowed, suggesting the closest `possible` ones.
106    pub fn invalid_value<S: AsRef<str>>(
107        argument: impl Into<String>,
108        value: impl Into<String>,
109        possible: impl IntoIterator<Item = S>,
110    ) -> Self {
111        let value = value.into();
112        let possible: Vec<String> = possible
113            .into_iter()
114            .map(|s| s.as_ref().to_string())
115            .collect();
116        let mut error = CliError::new(CliErrorKind::InvalidValue, "")
117            .argument(argument)
118            .suggestions(suggest(&value, &possible));
119        error.value = Some(value);
120        error.possible_values = possible;
121        error
122    }
123
124    /// An option given without its value.
125    pub fn missing_value(argument: impl Into<String>) -> Self {
126        CliError::new(CliErrorKind::MissingValue, "").argument(argument)
127    }
128
129    /// Required arguments that were not given.
130    pub fn missing_required<S: AsRef<str>>(arguments: impl IntoIterator<Item = S>) -> Self {
131        let joined: Vec<String> = arguments
132            .into_iter()
133            .map(|s| s.as_ref().to_string())
134            .collect();
135        CliError::new(CliErrorKind::MissingRequired, "").argument(joined.join(", "))
136    }
137
138    pub fn argument(mut self, argument: impl Into<String>) -> Self {
139        self.argument = Some(argument.into());
140        self
141    }
142    pub fn value(mut self, value: impl Into<String>) -> Self {
143        self.value = Some(value.into());
144        self
145    }
146    pub fn suggestion(mut self, suggestion: impl Into<String>) -> Self {
147        self.suggestions.push(suggestion.into());
148        self
149    }
150    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = String>) -> Self {
151        self.suggestions.extend(suggestions);
152        self
153    }
154    pub fn usage(mut self, usage: impl Into<String>) -> Self {
155        self.usage = Some(usage.into());
156        self
157    }
158    /// Close with "for more information, try '`flag`'".
159    pub fn help_flag(mut self, flag: impl Into<String>) -> Self {
160        self.help_flag = Some(flag.into());
161        self
162    }
163
164    /// The exit status for a usage error, as clap and BSD `EX_USAGE`-style
165    /// tools use: 2.
166    pub fn exit_code(&self) -> i32 {
167        2
168    }
169
170    /// The headline: the message, else one generated from the kind.
171    pub fn headline(&self) -> String {
172        if !self.message.is_empty() {
173            return self.message.clone();
174        }
175        let arg = self.argument.as_deref().unwrap_or("");
176        let value = self.value.as_deref().unwrap_or("");
177        match self.kind {
178            CliErrorKind::UnknownArgument => format!("unexpected argument '{arg}'"),
179            CliErrorKind::MissingValue => {
180                format!("a value is required for '{arg}' but none was supplied")
181            }
182            CliErrorKind::InvalidValue => format!("invalid value '{value}' for '{arg}'"),
183            CliErrorKind::MissingRequired => {
184                format!("the following required arguments were not provided: {arg}")
185            }
186            CliErrorKind::UnexpectedValue => format!("unexpected value '{value}' for '{arg}'"),
187            CliErrorKind::Conflict if !value.is_empty() => {
188                format!("the argument '{arg}' cannot be used with '{value}'")
189            }
190            CliErrorKind::Conflict => format!("the argument '{arg}' cannot be used here"),
191            CliErrorKind::UnknownSubcommand => format!("unrecognized subcommand '{arg}'"),
192            CliErrorKind::Other => "invalid command line".to_string(),
193        }
194    }
195
196    /// The error as an expanded, error-level [`Diagnostic`]: the headline,
197    /// the usage as a note, the possible values as a note, then a help line
198    /// for the suggestions and the help flag. Control characters (from the
199    /// command line, say) are escaped, `\u{1b}`-style.
200    pub fn to_diagnostic(&self) -> Diagnostic {
201        let mut diagnostic =
202            Diagnostic::error(escape_controls(&self.headline())).view(EventView::Expanded);
203        if let Some(usage) = &self.usage {
204            for line in usage.lines() {
205                diagnostic = diagnostic.note(escape_controls(&format!("usage: {line}")));
206            }
207        }
208        if !self.possible_values.is_empty() {
209            diagnostic = diagnostic.note(escape_controls(&format!(
210                "possible values: {}",
211                self.possible_values.join(", ")
212            )));
213        }
214        if !self.suggestions.is_empty() {
215            let noun = match self.kind {
216                CliErrorKind::UnknownSubcommand => ("subcommand", "subcommands"),
217                CliErrorKind::InvalidValue => ("value", "values"),
218                _ => ("argument", "arguments"),
219            };
220            let quoted: Vec<String> = self.suggestions.iter().map(|s| format!("'{s}'")).collect();
221            diagnostic = diagnostic.help(escape_controls(&match quoted.len() {
222                1 => format!("a similar {} exists: {}", noun.0, quoted[0]),
223                _ => format!("similar {} exist: {}", noun.1, quoted.join(", ")),
224            }));
225        }
226        if let Some(flag) = &self.help_flag {
227            diagnostic = diagnostic.help(escape_controls(&format!(
228                "for more information, try '{flag}'"
229            )));
230        }
231        diagnostic
232    }
233}
234
235/// `text` with control characters escaped as `\u{…}` (newline, tab and CR
236/// as `\n`, `\t`, `\r`), so hostile arguments cannot drive the terminal.
237fn escape_controls(text: &str) -> String {
238    if !text.chars().any(char::is_control) {
239        return text.to_string();
240    }
241    text.chars()
242        .map(|c| match c {
243            '\n' => r"\n".to_string(),
244            '\t' => r"\t".to_string(),
245            '\r' => r"\r".to_string(),
246            c if c.is_control() => format!("\\u{{{:x}}}", c as u32),
247            c => c.to_string(),
248        })
249        .collect()
250}
251
252impl std::fmt::Display for CliError {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.write_str(&escape_controls(&self.headline()))
255    }
256}
257
258impl std::error::Error for CliError {}
259
260/// The candidates similar to `input`, best first: Jaro-Winkler similarity
261/// above 0.7, compared without leading dashes (clap's measure and cutoff).
262/// At most three are returned; an exact match is never suggested.
263///
264/// Like is compared with like: a `--long` input only against `--long`
265/// candidates, a `-s` input only against `-s` short flags, and a bare word (a
266/// subcommand, key or value) only against bare words. A long-flag typo so
267/// never draws a one-letter short flag, which clap never offers either.
268///
269/// ```
270/// use rich_ext::cli_doc::suggest;
271///
272/// assert_eq!(suggest("--colr", ["--color", "--width", "--colour"]), ["--color", "--colour"]);
273/// assert!(suggest("--zzz", ["--color"]).is_empty());
274/// assert_eq!(suggest("--paralel", ["--parallel", "-r"]), ["--parallel"]);
275/// ```
276pub fn suggest<S: AsRef<str>>(input: &str, candidates: impl IntoIterator<Item = S>) -> Vec<String> {
277    let needle = input.trim_start_matches('-');
278    let kind = dashes(input);
279    let mut scored: Vec<(f64, String)> = candidates
280        .into_iter()
281        .map(|c| c.as_ref().to_string())
282        .filter(|c| c != input && dashes(c) == kind)
283        .map(|c| (jaro_winkler(needle, c.trim_start_matches('-')), c))
284        .filter(|(score, _)| *score > 0.7)
285        .collect();
286    // Stable: equal scores keep the candidates' order.
287    scored.sort_by(|a, b| b.0.total_cmp(&a.0));
288    scored.dedup_by(|a, b| a.1 == b.1);
289    scored.into_iter().take(3).map(|(_, c)| c).collect()
290}
291
292/// Leading dashes, capped at two: a bare word, a `-s` short flag or a
293/// `--long` flag.
294fn dashes(word: &str) -> usize {
295    word.bytes().take(2).take_while(|&b| b == b'-').count()
296}
297
298/// Jaro-Winkler similarity in `0.0..=1.0`, over chars.
299fn jaro_winkler(a: &str, b: &str) -> f64 {
300    let a: Vec<char> = a.chars().collect();
301    let b: Vec<char> = b.chars().collect();
302    if a.is_empty() && b.is_empty() {
303        return 1.0;
304    }
305    if a.is_empty() || b.is_empty() {
306        return 0.0;
307    }
308    let window = (a.len().max(b.len()) / 2).saturating_sub(1);
309    let mut a_hit = vec![false; a.len()];
310    let mut b_hit = vec![false; b.len()];
311    let mut matches = 0usize;
312    for (i, ca) in a.iter().enumerate() {
313        let lo = i.saturating_sub(window);
314        let hi = (i + window + 1).min(b.len());
315        for j in lo..hi {
316            if !b_hit[j] && b[j] == *ca {
317                a_hit[i] = true;
318                b_hit[j] = true;
319                matches += 1;
320                break;
321            }
322        }
323    }
324    if matches == 0 {
325        return 0.0;
326    }
327    let a_matched = a
328        .iter()
329        .zip(&a_hit)
330        .filter(|(_, hit)| **hit)
331        .map(|(c, _)| c);
332    let b_matched = b
333        .iter()
334        .zip(&b_hit)
335        .filter(|(_, hit)| **hit)
336        .map(|(c, _)| c);
337    let transpositions = a_matched.zip(b_matched).filter(|(x, y)| x != y).count() / 2;
338    let m = matches as f64;
339    let jaro = (m / a.len() as f64 + m / b.len() as f64 + (m - transpositions as f64) / m) / 3.0;
340    let prefix = a.iter().zip(&b).take(4).take_while(|(x, y)| x == y).count();
341    jaro + prefix as f64 * 0.1 * (1.0 - jaro)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn jaro_winkler_reference_values() {
350        // Classic reference pairs.
351        assert!((jaro_winkler("MARTHA", "MARHTA") - 0.9611).abs() < 1e-3);
352        assert!((jaro_winkler("DIXON", "DICKSONX") - 0.8133).abs() < 1e-3);
353        assert_eq!(jaro_winkler("same", "same"), 1.0);
354        assert_eq!(jaro_winkler("abc", "xyz"), 0.0);
355    }
356
357    #[test]
358    fn subcommand_suggestions() {
359        assert_eq!(
360            suggest("confg", ["config", "print", "markdown"]),
361            ["config"]
362        );
363    }
364
365    #[test]
366    fn suggestions_compare_like_with_like() {
367        let names = ["--parallel", "-r", "--dry-run", "-n", "report", "-p"];
368        assert_eq!(suggest("--paralel", names), ["--parallel"]);
369        assert_eq!(suggest("--dry-rn", names), ["--dry-run"]);
370        assert!(!suggest("--r", names).contains(&"-r".to_string()));
371        assert!(suggest("-x", ["--x-ray", "--xx"]).is_empty());
372        assert_eq!(suggest("reprt", names), ["report"]);
373        assert!(suggest("paralel", names).is_empty());
374    }
375
376    #[test]
377    fn suggestions_keep_the_top_three_and_the_cutoff() {
378        let names = ["--color", "--colour", "--colors", "--colored", "--width"];
379        assert_eq!(suggest("--colr", names).len(), 3);
380        assert!(suggest("--zzz", names).is_empty());
381    }
382}