bpaf/
meta_youmean.rs

1use crate::{
2    item::ShortLong,
3    meta_help::{HelpItem, HelpItems},
4    Meta, State,
5};
6
7#[derive(Debug, Copy, Clone)]
8pub(crate) enum Variant {
9    CommandLong(&'static str),
10    Flag(ShortLong),
11}
12
13#[derive(Debug)]
14pub(crate) enum Suggestion {
15    Variant(Variant),
16    /// expected --foo, actual -foo
17    MissingDash(&'static str),
18    /// expected -f, actual --f
19    ExtraDash(char),
20    Nested(String, Variant),
21}
22
23/// Looks for potential typos
24#[inline(never)]
25pub(crate) fn suggest(args: &State, meta: &Meta) -> Option<(usize, Suggestion)> {
26    let (ix, arg) = args.items_iter().next()?;
27
28    // suggesting typos for parts of group of short names (-vvv, typo in third v) would be strange
29    if arg.os_str().is_empty() {
30        return None;
31    }
32    // don't try to suggest fixes for typos in strictly positional items
33    if matches!(arg, crate::args::Arg::PosWord(_)) {
34        return None;
35    }
36    // it also should be a printable name
37    let actual = arg.to_string();
38
39    // all the help items one level deep
40    let mut hi = HelpItems::default();
41    hi.append_meta(meta);
42
43    // this will be used to avoid reallocations on scannign
44    let mut nested = HelpItems::default();
45
46    // while scanning keep the closest match
47    let mut best_match = None;
48    let mut best_dist = usize::MAX;
49    let mut improve = |dist, val| {
50        if best_dist > dist && dist > 0 && dist < 4 {
51            best_dist = dist;
52            best_match = Some(val);
53        }
54    };
55
56    let mut nest = None;
57
58    for item in &hi.items {
59        match item {
60            HelpItem::Command { name, meta, .. } => {
61                // command can result in 2 types of suggestions:
62                // - typo in a short or a long name
63                // - there is a nested command that matches perfectly - try using that
64                let distance = damerau_levenshtein(&actual, name);
65                improve(distance, Variant::CommandLong(name));
66
67                // scan nested items and look for exact matches only
68                nested.items.clear();
69                nested.append_meta(meta);
70                for item in &nested.items {
71                    match item {
72                        HelpItem::Command { name: nname, .. } => {
73                            if *nname == actual {
74                                nest = Some((name, Variant::CommandLong(nname)));
75                            }
76                        }
77                        HelpItem::Flag { name: nname, .. }
78                        | HelpItem::Argument { name: nname, .. } => {
79                            if *nname == &actual {
80                                nest = Some((name, Variant::Flag(*nname)));
81                            }
82                        }
83                        HelpItem::DecorSuffix { .. }
84                        | HelpItem::GroupStart { .. }
85                        | HelpItem::GroupEnd { .. }
86                        | HelpItem::Positional { .. }
87                        | HelpItem::AnywhereStart { .. }
88                        | HelpItem::AnywhereStop { .. }
89                        | HelpItem::Any { .. } => {}
90                    }
91                }
92            }
93            HelpItem::Flag { name, .. } | HelpItem::Argument { name, .. } => {
94                if let Some(long) = name.as_long() {
95                    let distance = damerau_levenshtein(&actual, &format!("--{}", long));
96                    improve(distance, Variant::Flag(*name));
97                }
98                if let Some(short) = name.as_short() {
99                    if let Some(act) = actual.strip_prefix("--") {
100                        let mut tmp = [0u8; 4];
101                        if act == short.encode_utf8(&mut tmp) {
102                            return Some((ix, Suggestion::ExtraDash(short)));
103                        }
104                    }
105                }
106            }
107            HelpItem::Positional { .. }
108            | HelpItem::DecorSuffix { .. }
109            | HelpItem::GroupStart { .. }
110            | HelpItem::GroupEnd { .. }
111            | HelpItem::AnywhereStart { .. }
112            | HelpItem::AnywhereStop { .. }
113            | HelpItem::Any { .. } => {}
114        }
115    }
116
117    if let Some((&name, variant)) = nest {
118        Some((ix, Suggestion::Nested(name.to_string(), variant)))
119    } else {
120        // skip confusing errors
121        if best_dist == usize::MAX {
122            return None;
123        }
124        let best_match = best_match?;
125
126        // handle missing single dash typos separately
127        if let Variant::Flag(n) = best_match {
128            if let Some(long) = n.as_long() {
129                if actual.strip_prefix('-') == Some(long) {
130                    return Some((ix, Suggestion::MissingDash(long)));
131                }
132            }
133        }
134        Some((ix, Suggestion::Variant(best_match)))
135    }
136}
137
138/// Damerau-Levenshtein distance function
139///
140/// returns `usize::MAX` if there's no common characters at all mostly to avoid
141/// confusing error messages - "you typed 'foo', maybe you ment 'bar'" where
142/// 'foo' and 'bar' don't have anything in common
143fn damerau_levenshtein(a: &str, b: &str) -> usize {
144    #![allow(clippy::many_single_char_names)]
145    let a_len = a.chars().count();
146    let b_len = b.chars().count();
147    let mut d = vec![0; (a_len + 1) * (b_len + 1)];
148
149    let ix = |ib, ia| a_len * ia + ib;
150
151    for i in 0..=a_len {
152        d[ix(i, 0)] = i;
153    }
154
155    for j in 0..=b_len {
156        d[ix(0, j)] = j;
157    }
158
159    let mut pa = '\0';
160    let mut pb = '\0';
161    for (i, ca) in a.chars().enumerate() {
162        let i = i + 1;
163        for (j, cb) in b.chars().enumerate() {
164            let j = j + 1;
165            let cost = usize::from(ca != cb);
166            d[ix(i, j)] = (d[ix(i - 1, j)] + 1)
167                .min(d[ix(i, j - 1)] + 1)
168                .min(d[ix(i - 1, j - 1)] + cost);
169            if i > 1 && j > 1 && ca == pb && cb == pa {
170                d[ix(i, j)] = d[ix(i, j)].min(d[ix(i - 2, j - 2)] + 1);
171            }
172            pb = cb;
173        }
174        pa = ca;
175    }
176
177    let diff = d[ix(a_len, b_len)];
178
179    if diff >= a_len.min(b_len) {
180        usize::MAX
181    } else {
182        diff
183    }
184}