Merge pull request #4060 from dotCooCoo/feat/unknown-command-suggestion

Commands: an unknown command id is answered with the nearest command id
This commit is contained in:
Brandon Thomas
2026-10-25 10:37:54 -04:00
committed by GitHub
29 changed files with 101 additions and 5 deletions
+7 -1
View File
@@ -56,7 +56,13 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "batches cannot nest"));
}
if find_command(id).is_none() {
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
return Err(bad(
C,
match super::super::params::nearest_command(id) {
Some(near) => format!("commands[{i}]: unknown command `{id}`; did you mean `{near}`?"),
None => format!("commands[{i}]: unknown command `{id}`"),
},
));
}
}
let source = str_param(p, "source").unwrap_or("folder");
+22 -3
View File
@@ -20,7 +20,7 @@
use serde_json::Value;
use super::{bad, find_command};
use super::{bad, command_specs, find_command};
use crate::Result;
/// What a params description says about the keys of the params object.
@@ -208,8 +208,12 @@ fn distance(a: &str, b: &str) -> usize {
/// The key of `keys` a misspelt `key` most likely meant: the nearest one, when it is near.
fn nearest<'a>(key: &str, keys: &[&'a str]) -> Option<&'a str> {
let near = keys.iter().map(|k| (distance(key, k), *k)).min()?;
(near.0 <= (key.chars().count() / 3).max(1)).then_some(near.1)
let len = key.chars().count();
let most = (len / 3).max(1);
// A key whose length differs by more than `most` is more than `most` edits away and isn't
// measured.
let near = keys.iter().filter(|k| k.chars().count().abs_diff(len) <= most).map(|k| (distance(key, k), *k)).min()?;
(near.0 <= most).then_some(near.1)
}
/// `params` has only keys the description `doc` of command `id` names, or the parameter error
@@ -480,6 +484,21 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
Ok(())
}
/// The command id a misspelled `id`, which names no command, most likely meant: the nearest one,
/// when it is near (as [`check_keys`] matches a misspelled key).
pub(crate) fn nearest_command(id: &str) -> Option<&'static str> {
let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).collect();
nearest(id, &ids)
}
/// The error for `id`, which names no command: with the nearest command id, when one is near.
pub(crate) fn unknown_command(id: &str) -> crate::EngineError {
match nearest_command(id) {
Some(near) => crate::EngineError::UnknownCommandNear(id.to_string(), near.to_string()),
None => crate::EngineError::UnknownCommand(id.to_string()),
}
}
impl crate::Session {
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
/// this session's document (not as a step of a `command.batch`: what it edits is only there
+4 -1
View File
@@ -44,6 +44,9 @@ pub use vectorcraft_tools as tools;
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
/// An unknown command id, with the command id it most likely misspells.
#[error("unknown command `{0}`; did you mean `{1}`?")]
UnknownCommandNear(String, String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
@@ -1784,7 +1787,7 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
+36
View File
@@ -90,6 +90,42 @@ fn unknown_and_disabled() {
assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn unknown_command_names_the_nearest_id() {
let mut s = session();
for typo in ["select.al", "Select.All", "selcet.all"] {
let e = s.execute(typo, &json!({})).unwrap_err();
assert!(matches!(&e, EngineError::UnknownCommandNear(id, near) if id == typo && near == "select.all"), "{typo}: {e:?}");
assert_eq!(e.to_string(), format!("unknown command `{typo}`; did you mean `select.all`?"));
}
let e = s.execute("nope", &json!({})).unwrap_err();
assert!(matches!(&e, EngineError::UnknownCommand(id) if id == "nope"), "{e:?}");
assert_eq!(e.to_string(), "unknown command `nope`");
let e = s.execute("command.batch", &json!({"commands": [{"command": "select.al", "params": {}}]})).unwrap_err();
assert!(e.to_string().contains("did you mean `select.all`?"), "{e}");
}
#[test]
fn a_long_unknown_command_id_is_answered_at_once() {
let mut s = session();
let long = "x".repeat(200_000);
let start = std::time::Instant::now();
assert!(matches!(s.execute(&long, &json!({})), Err(EngineError::UnknownCommand(_))));
let e = crate::check_params("file.new", &json!({ long.as_str(): 1 })).unwrap_err();
assert!(e.to_string().contains("the command takes:"), "{e}");
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn action_batch_names_the_nearest_command_id() {
let mut s = session();
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "select.al"}]})).unwrap_err();
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `select.al`; did you mean `select.all`?");
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "nope"}]})).unwrap_err();
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `nope`");
}
#[test]
fn group_ungroup() {
let mut s = session();