1use serde_json::Value;
5
6use crate::{CommandInfo, fuzzy_match};
7
8const MAX_NAME: usize = 128;
10const MAX_SUMMARY: usize = 240;
12
13pub 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 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
59fn 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 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
87pub 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
107pub 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}