Add clap-based commands

This commit is contained in:
Joscha 2024-12-27 17:26:51 +01:00
parent 48f75a1efd
commit 7928eda0d0
4 changed files with 194 additions and 2 deletions

View file

@ -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"] }

View file

@ -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 }

View file

@ -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 {

View file

@ -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<B, E> {
type Args;
async fn execute(
&self,
args: Self::Args,
msg: &Message,
ctx: &Context,
bot: &mut B,
) -> Result<Propagate, E>;
}
/// 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<Vec<String>, &'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<C>(pub C);
#[async_trait]
impl<B, E, C> Command<B, E> for Clap<C>
where
B: Send,
E: From<euphoxide::Error>,
C: ClapCommand<B, E> + 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<Propagate, E> {
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("<command>").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());
}
}