From 7928eda0d03ed293cd4491190a38dc0758ccb2c9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 27 Dec 2024 17:26:51 +0100 Subject: [PATCH] Add clap-based commands --- Cargo.toml | 1 + euphoxide-bot/Cargo.toml | 4 + euphoxide-bot/src/command.rs | 6 +- euphoxide-bot/src/command/clap.rs | 185 ++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 euphoxide-bot/src/command/clap.rs diff --git a/Cargo.toml b/Cargo.toml index be45ac0..b79053d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" [workspace.dependencies] async-trait = "0.1.83" caseless = "0.2.1" +clap = { version = "4.5.23", default-features = false, features = ["std"] } cookie = "0.18.1" futures-util = "0.3.31" jiff = { version = "0.1.15", default-features = false, features = ["std"] } diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml index 9f657c5..c54c22b 100644 --- a/euphoxide-bot/Cargo.toml +++ b/euphoxide-bot/Cargo.toml @@ -3,8 +3,12 @@ name = "euphoxide-bot" version = { workspace = true } edition = { workspace = true } +[features] +clap = ["dep:clap"] + [dependencies] async-trait = { workspace = true } +clap = { workspace = true, optional = true } cookie = { workspace = true } euphoxide = { path = "../euphoxide" } log = { workspace = true } diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs index a630b8d..e4521a4 100644 --- a/euphoxide-bot/src/command.rs +++ b/euphoxide-bot/src/command.rs @@ -1,4 +1,6 @@ -mod bang; +pub mod bang; +#[cfg(feature = "clap")] +pub mod clap; mod hidden; mod prefixed; @@ -13,7 +15,7 @@ use euphoxide::{ }, }; -pub use self::{bang::*, hidden::*, prefixed::*}; +pub use self::{hidden::*, prefixed::*}; #[non_exhaustive] pub struct Context { diff --git a/euphoxide-bot/src/command/clap.rs b/euphoxide-bot/src/command/clap.rs new file mode 100644 index 0000000..1a9180a --- /dev/null +++ b/euphoxide-bot/src/command/clap.rs @@ -0,0 +1,185 @@ +//! [`clap`]-based commands. + +use async_trait::async_trait; +use clap::{CommandFactory, Parser}; +use euphoxide::api::Message; + +use super::{Command, Context, Info, Propagate}; + +#[async_trait] +pub trait ClapCommand { + type Args; + + async fn execute( + &self, + args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result; +} + +/// Parse bash-like quoted arguments separated by whitespace. +/// +/// Outside of quotes, the backslash either escapes the next character or forms +/// an escape sequence. \n is a newline, \r a carriage return and \t a tab. +/// TODO Escape sequences +/// +/// Special characters like the backslash and whitespace can also be quoted +/// using double quotes. Within double quotes, \" escapes a double quote and \\ +/// escapes a backslash. Other occurrences of \ have no special meaning. +fn parse_quoted_args(text: &str) -> Result, &'static str> { + let mut args = vec![]; + let mut arg = String::new(); + let mut arg_exists = false; + + let mut quoted = false; + let mut escaped = false; + for c in text.chars() { + if quoted { + match c { + '\\' if escaped => { + arg.push('\\'); + escaped = false; + } + '"' if escaped => { + arg.push('"'); + escaped = false; + } + c if escaped => { + arg.push('\\'); + arg.push(c); + escaped = false; + } + '\\' => escaped = true, + '"' => quoted = false, + c => arg.push(c), + } + } else { + match c { + c if escaped => { + arg.push(c); + arg_exists = true; + escaped = false; + } + c if c.is_whitespace() => { + if arg_exists { + args.push(arg); + arg = String::new(); + arg_exists = false; + } + } + '\\' => escaped = true, + '"' => { + quoted = true; + arg_exists = true; + } + c => { + arg.push(c); + arg_exists = true; + } + } + } + } + + if quoted { + return Err("Unclosed trailing quote"); + } + if escaped { + return Err("Unfinished trailing escape"); + } + + if arg_exists { + args.push(arg); + } + + Ok(args) +} + +pub struct Clap(pub C); + +#[async_trait] +impl Command for Clap +where + B: Send, + E: From, + C: ClapCommand + Send + Sync, + C::Args: Parser + Send, +{ + fn info(&self, _ctx: &Context) -> Info { + Info { + description: C::Args::command().get_about().map(|s| s.to_string()), + ..Info::default() + } + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + let mut args = match parse_quoted_args(arg) { + Ok(args) => args, + Err(err) => { + ctx.reply_only(msg.id, err).await?; + return Ok(Propagate::No); + } + }; + + // Hacky, but it should work fine in most cases + let usage = msg.content.strip_suffix(arg).unwrap_or("").trim(); + args.insert(0, usage.to_string()); + + let args = match C::Args::try_parse_from(args) { + Ok(args) => args, + Err(err) => { + ctx.reply_only(msg.id, format!("{}", err.render())).await?; + return Ok(Propagate::No); + } + }; + + self.0.execute(args, msg, ctx, bot).await + } +} + +#[cfg(test)] +mod test { + use super::parse_quoted_args; + + fn assert_quoted(raw: &str, parsed: &[&str]) { + let parsed = parsed.iter().map(|s| s.to_string()).collect(); + assert_eq!(parse_quoted_args(raw), Ok(parsed)) + } + + #[test] + fn test_parse_quoted_args() { + assert_quoted("foo bar baz", &["foo", "bar", "baz"]); + assert_quoted(" foo bar baz ", &["foo", "bar", "baz"]); + assert_quoted("foo\\ ba\"r ba\"z", &["foo bar baz"]); + assert_quoted( + "It's a nice day, isn't it?", + &["It's", "a", "nice", "day,", "isn't", "it?"], + ); + + // Trailing whitespace + assert_quoted("a ", &["a"]); + assert_quoted("a\\ ", &["a "]); + assert_quoted("a\\ ", &["a "]); + + // Zero-length arguments + assert_quoted("a \"\" b \"\"", &["a", "", "b", ""]); + assert_quoted("a \"\" b \"\" ", &["a", "", "b", ""]); + + // Backslashes in quotes + assert_quoted("\"a \\b \\\" \\\\\"", &["a \\b \" \\"]); + + // Unclosed quotes and unfinished escapes + assert!(parse_quoted_args("foo 'bar \"baz").is_err()); + assert!(parse_quoted_args("foo \"bar baz").is_err()); + assert!(parse_quoted_args("foo \"bar 'baz").is_err()); + assert!(parse_quoted_args("foo \\").is_err()); + assert!(parse_quoted_args("foo 'bar\\").is_err()); + } +}