Skip to main content

ashlar_command/
suggest.rs

1//! "Did you mean …?": the names closest to a mistyped one (a command id, a parameter key), and a
2//! one-line summary of a command's parameters, so an error can say what to call instead.
3
4use serde_json::Value;
5
6use crate::{CommandInfo, fuzzy_match};
7
8/// Names longer than this are not compared (bounded work on hostile input).
9const MAX_NAME: usize = 128;
10/// Longest parameter summary [`params_summary`] returns.
11const MAX_SUMMARY: usize = 240;
12
13/// Up to `max` of `candidates` closest to `name`, best first: a different case, then a small
14/// spelling distance (at most a third of the name's length, at least 2), then names that share
15/// the last dotted segment (`layer.delete` → `edit.delete`) or contain the name, then names that
16/// hold its letters in order. Empty when nothing is close.
17pub fn closest<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>, max: usize) -> Vec<&'a str> {
18    let wanted = name.trim();
19    if wanted.is_empty() || wanted.chars().count() > MAX_NAME || max == 0 {
20        return Vec::new();
21    }
22    let lower = wanted.to_lowercase();
23    let len = lower.chars().count();
24    let limit = (len / 3).max(2);
25    let last = lower.rsplit('.').next().unwrap_or(&lower).to_string();
26    let mut ranked: Vec<(u8, usize, i64, &'a str)> = Vec::new();
27    for c in candidates {
28        if c == wanted || c.chars().count() > MAX_NAME {
29            continue;
30        }
31        let cl = c.to_lowercase();
32        let d = distance(&lower, &cl);
33        let tier_score = if d <= limit {
34            Some((0, d, 0))
35        } else if (last.chars().count() >= 3 && cl.rsplit('.').next() == Some(last.as_str()))
36            || (len >= 3 && (cl.contains(&lower) || (cl.chars().count() >= 3 && lower.contains(&cl))))
37        {
38            Some((1, d, 0))
39        } else if len >= 3 {
40            fuzzy_match(&lower, &cl).map(|m| (2, d, -i64::from(m.score)))
41        } else {
42            None
43        };
44        if let Some((tier, d, score)) = tier_score {
45            ranked.push((tier, d, score, c));
46        }
47    }
48    ranked.sort();
49    ranked.dedup_by(|a, b| a.3 == b.3);
50    // a near-certain match (one or two edits) is not diluted by names that are merely similar
51    if let Some(&(0, best, _, _)) = ranked.first()
52        && best <= 2
53    {
54        ranked.retain(|r| r.0 == 0 && r.1 <= best.saturating_add(1));
55    }
56    ranked.into_iter().take(max).map(|r| r.3).collect()
57}
58
59/// Edit distance (insertions, deletions, substitutions; a swap of two neighbours counts once).
60fn distance(a: &str, b: &str) -> usize {
61    let a: Vec<char> = a.chars().take(MAX_NAME).collect();
62    let b: Vec<char> = b.chars().take(MAX_NAME).collect();
63    // three rows of the optimal-string-alignment table
64    let mut before: Vec<usize> = Vec::new();
65    let mut prev: Vec<usize> = (0..=b.len()).collect();
66    for (i, ca) in a.iter().enumerate() {
67        let mut row = vec![i.saturating_add(1); b.len().saturating_add(1)];
68        for (j, cb) in b.iter().enumerate() {
69            let cost = usize::from(ca != cb);
70            let del = prev.get(j.saturating_add(1)).copied().unwrap_or(usize::MAX).saturating_add(1);
71            let ins = row.get(j).copied().unwrap_or(usize::MAX).saturating_add(1);
72            let sub = prev.get(j).copied().unwrap_or(usize::MAX).saturating_add(cost);
73            let mut best = del.min(ins).min(sub);
74            let swapped = i > 0 && j > 0 && a.get(i - 1) == Some(cb) && b.get(j - 1) == Some(ca);
75            if swapped && let Some(t) = before.get(j - 1) {
76                best = best.min(t.saturating_add(1));
77            }
78            if let Some(cell) = row.get_mut(j.saturating_add(1)) {
79                *cell = best;
80            }
81        }
82        before = std::mem::replace(&mut prev, row);
83    }
84    prev.last().copied().unwrap_or(0)
85}
86
87/// A command's parameters on one line: its text doc as written, or the keys of its schema
88/// (`{clip, time?, seconds?}`, optional ones marked `?`); `{}` when it takes none. Long docs are
89/// cut at about 240 characters.
90pub fn params_summary(info: &CommandInfo) -> String {
91    if let Some(text) = info.params_text.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
92        return cut(text);
93    }
94    let Some(props) = info.params_schema.get("properties").and_then(Value::as_object) else { return "{}".into() };
95    let required: Vec<&str> = info.params_schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
96    let keys: Vec<String> = props.keys().map(|k| if required.contains(&k.as_str()) { k.clone() } else { format!("{k}?") }).collect();
97    cut(&format!("{{{}}}", keys.join(", ")))
98}
99
100fn cut(text: &str) -> String {
101    match text.char_indices().nth(MAX_SUMMARY) {
102        Some((i, _)) => format!("{}…", text.get(..i).unwrap_or_default()),
103        None => text.to_string(),
104    }
105}
106
107/// `did you mean `a` {params}, `b` {params}?` for an unknown command id, from the commands an app
108/// lists; `None` when no command is close.
109pub fn did_you_mean_command(id: &str, commands: &[CommandInfo]) -> Option<String> {
110    let hits = closest(id, commands.iter().map(|c| c.id.as_str()), 3);
111    if hits.is_empty() {
112        return None;
113    }
114    let named: Vec<String> = hits
115        .iter()
116        .filter_map(|h| commands.iter().find(|c| c.id == *h))
117        .map(|c| {
118            let p = params_summary(c);
119            if p == "{}" { format!("`{}`", c.id) } else { format!("`{}` {p}", c.id) }
120        })
121        .collect();
122    Some(format!("did you mean {}?", named.join(", ")))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use serde_json::json;
129
130    #[test]
131    fn close_names_come_first() {
132        let ids = ["timeline.razor", "timeline.roll", "timeline.move", "edit.clear", "layer.addMask", "layer.addTextAnimator"];
133        assert_eq!(closest("timeline.razr", ids, 3).first(), Some(&"timeline.razor"));
134        assert_eq!(closest("Timeline.Razor", ids, 1), vec!["timeline.razor"]);
135        assert_eq!(closest("timeline.mvoe", ids, 1), vec!["timeline.move"], "a swap counts once");
136        assert!(closest("layer.adx", ids, 3).iter().all(|c| c.starts_with("layer.add")), "{:?}", closest("layer.adx", ids, 3));
137        assert_eq!(closest("clear", ids, 3), vec!["edit.clear"]);
138        assert!(closest("zzzzzz", ids, 3).is_empty());
139        assert!(closest("", ids, 3).is_empty());
140        assert!(closest("timeline.razor", ids, 3).iter().all(|c| *c != "timeline.razor"), "the name itself is not a suggestion");
141        let long = "x".repeat(10_000);
142        assert!(closest(&long, ids, 3).is_empty());
143        assert_eq!(closest("sekonds", ["seconds", "frame", "timecode"], 1), vec!["seconds"]);
144    }
145
146    fn info(id: &str, schema: Value, text: Option<&str>) -> CommandInfo {
147        CommandInfo {
148            id: id.into(),
149            label: id.into(),
150            menu: vec![],
151            shortcut: None,
152            params_schema: schema,
153            params_text: text.map(String::from),
154            enabled: true,
155            disabled_reason: None,
156            mutates: true,
157        }
158    }
159
160    #[test]
161    fn summaries_and_suggestions() {
162        let typed = info("count.add", json!({"type": "object", "properties": {"by": {}, "to": {}}, "required": ["to"]}), None);
163        assert_eq!(params_summary(&typed), "{by?, to}");
164        let text = info("timeline.razor", json!({}), Some(r#"{"time":ticks,"track":"V1"|id?}"#));
165        assert_eq!(params_summary(&text), r#"{"time":ticks,"track":"V1"|id?}"#);
166        let none = info("edit.undo", Value::Null, None);
167        assert_eq!(params_summary(&none), "{}");
168        let long = info("x.y", json!({}), Some(&"a".repeat(1000)));
169        assert!(params_summary(&long).chars().count() <= MAX_SUMMARY + 1);
170        let all = [typed, text, none];
171        assert_eq!(did_you_mean_command("timeline.razr", &all).as_deref(), Some(r#"did you mean `timeline.razor` {"time":ticks,"track":"V1"|id?}?"#));
172        assert_eq!(did_you_mean_command("edit.undoo", &all).as_deref(), Some("did you mean `edit.undo`?"));
173        assert_eq!(did_you_mean_command("qqqqqq", &all), None);
174    }
175}