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 47642f0..afcb7c4 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" } jiff = { workspace = true } diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs index c16f837..72fb4e9 100644 --- a/euphoxide-bot/src/command.rs +++ b/euphoxide-bot/src/command.rs @@ -1,5 +1,7 @@ pub mod bang; pub mod basic; +#[cfg(feature = "clap")] +pub mod clap; use std::future::Future; diff --git a/euphoxide-bot/src/command/clap.rs b/euphoxide-bot/src/command/clap.rs new file mode 100644 index 0000000..fb03974 --- /dev/null +++ b/euphoxide-bot/src/command/clap.rs @@ -0,0 +1,187 @@ +//! [`clap`]-based commands. + +use async_trait::async_trait; +use clap::{CommandFactory, Parser}; +use euphoxide::api::Message; + +use crate::bot::Bot; + +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: &Bot, + ) -> 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 + S: Send + Sync, + E: From, + C: ClapCommand + 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: &Bot, + ) -> 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()); + } +}