use serde_json::Value;
use crate::{CommandInfo, fuzzy_match};
const MAX_NAME: usize = 128;
const MAX_SUMMARY: usize = 240;
pub fn closest<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>, max: usize) -> Vec<&'a str> {
let wanted = name.trim();
if wanted.is_empty() || wanted.chars().count() > MAX_NAME || max == 0 {
return Vec::new();
}
let lower = wanted.to_lowercase();
let len = lower.chars().count();
let limit = (len / 3).max(2);
let last = lower.rsplit('.').next().unwrap_or(&lower).to_string();
let mut ranked: Vec<(u8, usize, i64, &'a str)> = Vec::new();
for c in candidates {
if c == wanted || c.chars().count() > MAX_NAME {
continue;
}
let cl = c.to_lowercase();
let d = distance(&lower, &cl);
let tier_score = if d <= limit {
Some((0, d, 0))
} else if (last.chars().count() >= 3 && cl.rsplit('.').next() == Some(last.as_str()))
|| (len >= 3 && (cl.contains(&lower) || (cl.chars().count() >= 3 && lower.contains(&cl))))
{
Some((1, d, 0))
} else if len >= 3 {
fuzzy_match(&lower, &cl).map(|m| (2, d, -i64::from(m.score)))
} else {
None
};
if let Some((tier, d, score)) = tier_score {
ranked.push((tier, d, score, c));
}
}
ranked.sort();
ranked.dedup_by(|a, b| a.3 == b.3);
if let Some(&(0, best, _, _)) = ranked.first()
&& best <= 2
{
ranked.retain(|r| r.0 == 0 && r.1 <= best.saturating_add(1));
}
ranked.into_iter().take(max).map(|r| r.3).collect()
}
fn distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().take(MAX_NAME).collect();
let b: Vec<char> = b.chars().take(MAX_NAME).collect();
let mut before: Vec<usize> = Vec::new();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut row = vec![i.saturating_add(1); b.len().saturating_add(1)];
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
let del = prev.get(j.saturating_add(1)).copied().unwrap_or(usize::MAX).saturating_add(1);
let ins = row.get(j).copied().unwrap_or(usize::MAX).saturating_add(1);
let sub = prev.get(j).copied().unwrap_or(usize::MAX).saturating_add(cost);
let mut best = del.min(ins).min(sub);
let swapped = i > 0 && j > 0 && a.get(i - 1) == Some(cb) && b.get(j - 1) == Some(ca);
if swapped && let Some(t) = before.get(j - 1) {
best = best.min(t.saturating_add(1));
}
if let Some(cell) = row.get_mut(j.saturating_add(1)) {
*cell = best;
}
}
before = std::mem::replace(&mut prev, row);
}
prev.last().copied().unwrap_or(0)
}
pub fn params_summary(info: &CommandInfo) -> String {
if let Some(text) = info.params_text.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
return cut(text);
}
let Some(props) = info.params_schema.get("properties").and_then(Value::as_object) else { return "{}".into() };
let required: Vec<&str> = info.params_schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
let keys: Vec<String> = props.keys().map(|k| if required.contains(&k.as_str()) { k.clone() } else { format!("{k}?") }).collect();
cut(&format!("{{{}}}", keys.join(", ")))
}
fn cut(text: &str) -> String {
match text.char_indices().nth(MAX_SUMMARY) {
Some((i, _)) => format!("{}…", text.get(..i).unwrap_or_default()),
None => text.to_string(),
}
}
pub fn did_you_mean_command(id: &str, commands: &[CommandInfo]) -> Option<String> {
let hits = closest(id, commands.iter().map(|c| c.id.as_str()), 3);
if hits.is_empty() {
return None;
}
let named: Vec<String> = hits
.iter()
.filter_map(|h| commands.iter().find(|c| c.id == *h))
.map(|c| {
let p = params_summary(c);
if p == "{}" { format!("`{}`", c.id) } else { format!("`{}` {p}", c.id) }
})
.collect();
Some(format!("did you mean {}?", named.join(", ")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn close_names_come_first() {
let ids = ["timeline.razor", "timeline.roll", "timeline.move", "edit.clear", "layer.addMask", "layer.addTextAnimator"];
assert_eq!(closest("timeline.razr", ids, 3).first(), Some(&"timeline.razor"));
assert_eq!(closest("Timeline.Razor", ids, 1), vec!["timeline.razor"]);
assert_eq!(closest("timeline.mvoe", ids, 1), vec!["timeline.move"], "a swap counts once");
assert!(closest("layer.adx", ids, 3).iter().all(|c| c.starts_with("layer.add")), "{:?}", closest("layer.adx", ids, 3));
assert_eq!(closest("clear", ids, 3), vec!["edit.clear"]);
assert!(closest("zzzzzz", ids, 3).is_empty());
assert!(closest("", ids, 3).is_empty());
assert!(closest("timeline.razor", ids, 3).iter().all(|c| *c != "timeline.razor"), "the name itself is not a suggestion");
let long = "x".repeat(10_000);
assert!(closest(&long, ids, 3).is_empty());
assert_eq!(closest("sekonds", ["seconds", "frame", "timecode"], 1), vec!["seconds"]);
}
fn info(id: &str, schema: Value, text: Option<&str>) -> CommandInfo {
CommandInfo {
id: id.into(),
label: id.into(),
menu: vec![],
shortcut: None,
params_schema: schema,
params_text: text.map(String::from),
enabled: true,
disabled_reason: None,
mutates: true,
}
}
#[test]
fn summaries_and_suggestions() {
let typed = info("count.add", json!({"type": "object", "properties": {"by": {}, "to": {}}, "required": ["to"]}), None);
assert_eq!(params_summary(&typed), "{by?, to}");
let text = info("timeline.razor", json!({}), Some(r#"{"time":ticks,"track":"V1"|id?}"#));
assert_eq!(params_summary(&text), r#"{"time":ticks,"track":"V1"|id?}"#);
let none = info("edit.undo", Value::Null, None);
assert_eq!(params_summary(&none), "{}");
let long = info("x.y", json!({}), Some(&"a".repeat(1000)));
assert!(params_summary(&long).chars().count() <= MAX_SUMMARY + 1);
let all = [typed, text, none];
assert_eq!(did_you_mean_command("timeline.razr", &all).as_deref(), Some(r#"did you mean `timeline.razor` {"time":ticks,"track":"V1"|id?}?"#));
assert_eq!(did_you_mean_command("edit.undoo", &all).as_deref(), Some("did you mean `edit.undo`?"));
assert_eq!(did_you_mean_command("qqqqqq", &all), None);
}
}