From 9122e64a414dd501fc3c9d407dcd2217b5deffc8 Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Mon, 19 Jan 2026 00:35:01 +0300 Subject: [PATCH] fix command suggestions breaking if specifying a parameter --- crates/command_parser/src/lib.rs | 43 +++++++++++++++++-- crates/command_parser/tests/ranking.rs | 57 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 crates/command_parser/tests/ranking.rs diff --git a/crates/command_parser/src/lib.rs b/crates/command_parser/src/lib.rs index c6322ddd..4310ce77 100644 --- a/crates/command_parser/src/lib.rs +++ b/crates/command_parser/src/lib.rs @@ -52,6 +52,14 @@ pub fn parse_command( let mut filtered_tokens: Vec = Vec::new(); let mut last_optional_param_error: Option<(SmolStr, SmolStr)> = None; + // track the best attempt at parsing (deepest matched tokens) + // so we can use it for error messages/suggestions even if we backtrack later + let mut best_attempt: Option<( + Tree, + Vec<(Tree, (Token, TokenMatchResult, usize), usize)>, + usize, + )> = None; + loop { let mut possible_tokens = local_tree .possible_tokens() @@ -115,6 +123,17 @@ pub fn parse_command( (found_token.clone(), result.clone(), *new_pos), current_pos, )); + + // update best attempt if we're deeper + if best_attempt.as_ref().map(|x| x.1.len()).unwrap_or(0) < matched_tokens.len() + { + best_attempt = Some(( + next_tree.clone(), + matched_tokens.clone(), + *new_pos, + )); + } + filtered_tokens.clear(); // new branch, new tokens local_tree = next_tree.clone(); } else { @@ -145,10 +164,29 @@ pub fn parse_command( )); } + // restore best attempt if it's deeper than current state + // this helps when we backtracked out of the correct path because of a later error + if let Some((best_tree, best_matched, best_pos)) = best_attempt { + if best_matched.len() > matched_tokens.len() { + local_tree = best_tree; + matched_tokens = best_matched; + current_pos = best_pos; + } + } + let mut error = format!("Unknown command `{prefix}{input}`."); - let possible_commands = - rank_possible_commands(&input, local_tree.possible_commands(usize::MAX)); + // normalize input by replacing parameters with placeholders + let mut normalized_input = String::new(); + for (_, (token, _, _), _) in &matched_tokens { + write!(&mut normalized_input, "{token} ").unwrap(); + } + normalized_input.push_str(&input[current_pos..].trim_start()); + + let possible_commands = rank_possible_commands( + &normalized_input, + local_tree.possible_commands(usize::MAX), + ); if possible_commands.is_empty().not() { error.push_str(" Perhaps you meant one of the following commands:\n"); fmt_commands_list(&mut error, &prefix, possible_commands); @@ -339,7 +377,6 @@ fn next_token<'a>( } // todo: should probably move this somewhere else -/// returns true if wrote possible commands, false if not fn rank_possible_commands( input: &str, possible_commands: impl IntoIterator, diff --git a/crates/command_parser/tests/ranking.rs b/crates/command_parser/tests/ranking.rs new file mode 100644 index 00000000..0f185fc7 --- /dev/null +++ b/crates/command_parser/tests/ranking.rs @@ -0,0 +1,57 @@ +use command_parser::{parse_command, Tree, command::Command, parameter::*, tokens}; + +#[test] +fn test_typoed_command_with_parameter() { + let message_token = ("message", ["msg", "messageinfo"]); + let author_token = ("author", ["sender", "a"]); + + // message author + let cmd = Command::new( + tokens!(message_token, Optional(MESSAGE_REF), author_token), + "message_author" + ).help("Shows the author of a proxied message"); + + let mut tree = Tree::default(); + tree.register_command(cmd); + + let input = "message 1 auth"; + let result = parse_command(tree, "pk;".to_string(), input.to_string()); + + match result { + Ok(_) => panic!("Should have failed to parse"), + Err(msg) => { + println!("Error: {}", msg); + assert!(msg.contains("Perhaps you meant one of the following commands")); + assert!(msg.contains("message author")); + } + } +} + +#[test] +fn test_typoed_command_with_flags() { + let message_token = ("message", ["msg", "messageinfo"]); + let author_token = ("author", ["sender", "a"]); + + let cmd = Command::new( + tokens!(message_token, author_token), + "message_author" + ) + .flag(("flag", ["f"])) + .flag(("flag2", ["f2"])) + .help("Shows the author of a proxied message"); + + let mut tree = Tree::default(); + tree.register_command(cmd); + + let input = "message auth -f -flag2"; + let result = parse_command(tree, "pk;".to_string(), input.to_string()); + + match result { + Ok(_) => panic!("Should have failed to parse"), + Err(msg) => { + println!("Error: {}", msg); + assert!(msg.contains("Perhaps you meant one of the following commands")); + assert!(msg.contains("message author")); + } + } +} \ No newline at end of file