Added basic parse and stringify for new proc macro InlineButtons

This commit is contained in:
LasterAlex 2024-11-15 21:46:35 +02:00
parent 94db1757dc
commit 8d61e7baff
No known key found for this signature in database
13 changed files with 565 additions and 22 deletions

24
Cargo.lock generated
View file

@ -805,12 +805,6 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -2085,7 +2079,7 @@ checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
"heck 0.5.0", "heck",
"hex", "hex",
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
@ -2281,7 +2275,7 @@ dependencies = [
"serde_json", "serde_json",
"sqlx", "sqlx",
"teloxide-core", "teloxide-core",
"teloxide-macros 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "teloxide-macros",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@ -2333,19 +2327,7 @@ dependencies = [
name = "teloxide-macros" name = "teloxide-macros"
version = "0.8.0" version = "0.8.0"
dependencies = [ dependencies = [
"heck 0.5.0", "heck",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "teloxide-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e2d33d809c3e7161a9ab18bedddf98821245014f0a78fa4d2c9430b2ec018c1"
dependencies = [
"heck 0.4.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 1.0.109",

View file

@ -0,0 +1,29 @@
use crate::{button_attr::ButtonAttrs, button_enum::ButtonEnum, fields_parse::ParserType, Result};
pub(crate) struct Button {
/// Callback data name, with all renames already applied.
pub data_name: String,
pub parser: ParserType,
}
impl Button {
pub fn new(
data_name: &str,
attributes: &[syn::Attribute],
global_options: &ButtonEnum,
) -> Result<Self> {
let attrs = ButtonAttrs::from_attributes(attributes)?;
let ButtonAttrs {
rename,
// FIXME: error on/do not ignore separator
fields_separator: _,
} = attrs;
let data_name = match rename {
Some((rename, _)) => rename,
None => String::from(data_name),
};
Ok(Self { data_name, parser: global_options.parser_type.clone() })
}
}

View file

@ -0,0 +1,113 @@
use crate::{
attr::{fold_attrs, Attr},
error::compile_error_at,
Result,
};
use proc_macro2::Span;
use syn::Attribute;
/// All attributes that can be used for `derive(InlineButtons)`
pub(crate) struct ButtonAttrs {
pub rename: Option<(String, Span)>,
pub fields_separator: Option<(String, Span)>,
}
/// A single k/v attribute for `InlineButtons` derive macro.
///
/// Similar to [`crate::command_attr::CommandAttr`]
struct ButtonAttr {
kind: ButtonAttrKind,
sp: Span,
}
/// Kind of [`ButtonAttr`].
enum ButtonAttrKind {
Rename(String),
FieldsSeparator(String),
}
impl ButtonAttrs {
pub fn from_attributes(attributes: &[Attribute]) -> Result<Self> {
use ButtonAttrKind::*;
fold_attrs(
attributes,
is_button_attribute,
ButtonAttr::parse,
Self { rename: None, fields_separator: None },
|mut this, attr| {
fn insert<T>(opt: &mut Option<(T, Span)>, x: T, sp: Span) -> Result<()> {
match opt {
slot @ None => {
*slot = Some((x, sp));
Ok(())
}
Some(_) => Err(compile_error_at("duplicate attribute", sp)),
}
}
match attr.kind {
Rename(r) => insert(&mut this.rename, r, attr.sp),
FieldsSeparator(s) => insert(&mut this.fields_separator, s, attr.sp),
}?;
Ok(this)
},
)
}
}
impl ButtonAttr {
fn parse(attr: Attr) -> Result<Self> {
use ButtonAttrKind::*;
let sp = attr.span();
let Attr { mut key, value } = attr;
let outermost_key = key.pop().unwrap(); // `Attr`'s invariants ensure `key.len() > 0`
let kind = match &*outermost_key.to_string() {
"button" => {
let Some(attr) = key.pop() else {
return Err(compile_error_at(
"expected an attribute name",
outermost_key.span(),
));
};
if let Some(unexpected_key) = key.last() {
return Err(compile_error_at(
&format!("{attr} can't have nested attributes"),
unexpected_key.span(),
));
}
match &*attr.to_string() {
"rename" => Rename(value.expect_string()?),
"fields_separator" => FieldsSeparator(value.expect_string()?),
_ => {
return Err(compile_error_at(
"unexpected attribute name (expected one of `rename` or \
`fields_separator`",
attr.span(),
))
}
}
}
_ => {
return Err(compile_error_at(
"unexpected attribute (expected `button`)",
outermost_key.span(),
))
}
};
Ok(Self { kind, sp })
}
}
fn is_button_attribute(a: &Attribute) -> bool {
matches!(a.path.get_ident(), Some(ident) if ident == "button")
}

View file

@ -0,0 +1,31 @@
use crate::{
button_attr::ButtonAttrs, command_enum::variants_only_attr, error::compile_error_at,
fields_parse::ParserType, Result,
};
const DEFAULT_CALLBACK_DATA_SEPARATOR: &str = ";";
pub(crate) struct ButtonEnum {
pub parser_type: ParserType,
pub fields_separator: String,
}
impl ButtonEnum {
pub fn from_attributes(attributes: &[syn::Attribute]) -> Result<Self> {
let attrs = ButtonAttrs::from_attributes(attributes)?;
let ButtonAttrs { rename, fields_separator } = attrs;
variants_only_attr![rename];
let separator = fields_separator
.map(|(s, _)| s)
.unwrap_or_else(|| String::from(DEFAULT_CALLBACK_DATA_SEPARATOR));
// We can just always use a separator parser, since the user won't ever interact
// with that
Ok(Self {
parser_type: ParserType::Split { separator: Some(separator.clone()) },
fields_separator: separator,
})
}
}

View file

@ -18,6 +18,8 @@ macro_rules! variants_only_attr {
}; };
} }
pub(crate) use variants_only_attr;
pub(crate) struct CommandEnum { pub(crate) struct CommandEnum {
pub prefix: String, pub prefix: String,
/// The bool is true if the description contains a doc comment /// The bool is true if the description contains a doc comment

View file

@ -0,0 +1,82 @@
use quote::quote;
use syn::{spanned::Spanned, Fields, FieldsNamed, FieldsUnnamed};
pub(crate) fn impl_stringify_args(
fields: &Fields,
self_variant: proc_macro2::TokenStream,
self_string_name: String,
self_string_variant: String,
) -> proc_macro2::TokenStream {
match fields {
Fields::Unit => {
quote! { #self_variant => ::std::result::Result::Ok(#self_string_variant.to_owned()), }
}
Fields::Unnamed(fields) => {
impl_stringify_args_unnamed(fields, self_variant, self_string_name, self_string_variant)
}
Fields::Named(named) => {
impl_stringify_args_named(named, self_variant, self_string_name, self_string_variant)
}
}
}
pub(crate) fn impl_stringify_args_unnamed(
data: &FieldsUnnamed,
variant: proc_macro2::TokenStream,
self_string_name: String,
string_variant: String,
) -> proc_macro2::TokenStream {
let names =
(0..data.unnamed.len()).map(|i| syn::Ident::new(&format!("field_{}", i), variant.span()));
let types = data.unnamed.iter().map(|f| &f.ty);
let mut all_fields = quote! {};
for ((name, ty), i) in names.clone().zip(types).zip(0..data.unnamed.len()) {
all_fields.extend(quote! { {
let stringified = #ty::to_string(&#name);
if stringified.contains(fields_separator) {
return ::std::result::Result::Err(StringifyError::SeparatorInUnnamedArgument {
enum_variant: std::concat!(#self_string_name, "::", #string_variant).to_owned(),
field: #i
});
}
stringified
}, })
}
let all_names = quote! { #(#names),* };
let res = quote! {
#variant(#all_names) => ::std::result::Result::Ok(
::std::vec![#string_variant.to_owned(), #all_fields].join(fields_separator)
),
};
res
}
pub(crate) fn impl_stringify_args_named(
data: &FieldsNamed,
variant: proc_macro2::TokenStream,
self_string_name: String,
string_variant: String,
) -> proc_macro2::TokenStream {
let names = data.named.iter().map(|f| f.ident.as_ref().unwrap());
let types = data.named.iter().map(|f| &f.ty);
let mut all_fields = quote! {};
for (name, ty) in names.clone().zip(types) {
all_fields.extend(quote! { {
let stringified = #ty::to_string(&#name);
if stringified.contains(fields_separator) {
return ::std::result::Result::Err(StringifyError::SeparatorInNamedArgument {
enum_variant: ::std::concat!(#self_string_name, "::", #string_variant).to_owned(),
argument: ::std::stringify!(#name).to_string()
});
}
stringified
}, })
}
let all_names = quote! { #(#names),* };
let res = quote! {
#variant { #all_names } => ::std::result::Result::Ok(
::std::vec![#string_variant.to_owned(), #all_fields].join(fields_separator)
),
};
res
}

View file

@ -0,0 +1,116 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{spanned::Spanned, DeriveInput};
use crate::{
button::Button, button_enum::ButtonEnum, compile_error, error::compile_error_at,
fields_parse::impl_parse_args, fields_stringify::impl_stringify_args, unzip::Unzip3, Result,
};
pub(crate) fn inline_buttons_impl(input: DeriveInput) -> Result<TokenStream> {
let data_enum = get_enum_data(&input)?;
let button_enum = ButtonEnum::from_attributes(&input.attrs)?;
let type_name = &input.ident;
let Unzip3(var_init, var_info, var_stringify) = data_enum
.variants
.iter()
.map(|variant| {
let button = Button::new(&variant.ident.to_string(), &variant.attrs, &button_enum)?;
if button.data_name.len() > 64 {
return Err(compile_error_at(
// Limit of the TBA
//
// "64 chars" and not "64 bytes" because enum variants usually don't contain
// non-ascii characters, and it's more understandable.
"Enum variant is too long (64 chars max), please consider using \
`#[button(rename=\"...\")` to reduce the size",
variant.span(),
));
}
let variant_name = &variant.ident;
let self_variant = quote! { Self::#variant_name };
let button_data_name = button.data_name.clone();
let self_string_variant = button_data_name.to_owned();
let self_string_name = type_name.to_string();
let parse = impl_parse_args(&variant.fields, self_variant.clone(), &button.parser);
let stringify = impl_stringify_args(
&variant.fields,
self_variant,
self_string_name,
self_string_variant,
);
Ok((parse, button, stringify))
})
.collect::<Result<Unzip3<Vec<_>, Vec<_>, Vec<_>>>>()?;
let fn_parse = impl_parse(&var_info, &var_init, &button_enum.fields_separator);
let fn_stringify = impl_stringify(&var_stringify, &button_enum.fields_separator);
let trait_impl = quote! {
impl teloxide::utils::button::InlineButtons for #type_name {
#fn_parse
#fn_stringify
}
};
Ok(trait_impl)
}
fn impl_parse(
infos: &[Button],
variants_initialization: &[proc_macro2::TokenStream],
fields_separator: &str,
) -> proc_macro2::TokenStream {
let matching_values = infos.iter().map(|c| c.data_name.clone());
quote! {
fn parse(s: &str) -> ::std::result::Result<Self, teloxide::utils::command::ParseError> {
use std::str::FromStr;
use teloxide::utils::command::ParseError;
// 2 is used to only split once (=> in two parts),
// we only need to split the enum variant and the rest of arguments.
let mut words = s.splitn(2, #fields_separator);
let enum_variant = words.next().unwrap();
let args = words.next().unwrap_or("").to_owned();
match enum_variant {
#(
#matching_values => Ok(#variants_initialization),
)*
_ => ::std::result::Result::Err(ParseError::UnknownCommand(enum_variant.to_owned())),
}
}
}
}
fn impl_stringify(
stringify_return: &[proc_macro2::TokenStream],
fields_separator: &str,
) -> proc_macro2::TokenStream {
quote! {
fn stringify(self) -> ::std::result::Result<::std::string::String, teloxide::utils::button::StringifyError> {
use std::string::ToString;
use teloxide::utils::button::StringifyError;
let fields_separator = #fields_separator;
match self {
#(#stringify_return)*
}
}
}
}
fn get_enum_data(input: &DeriveInput) -> Result<&syn::DataEnum> {
match &input.data {
syn::Data::Enum(data) => Ok(data),
_ => Err(compile_error("`InlineButtons` is only allowed for enums")),
}
}

View file

@ -2,14 +2,20 @@ extern crate proc_macro;
mod attr; mod attr;
mod bot_commands; mod bot_commands;
mod button;
mod button_attr;
mod button_enum;
mod command; mod command;
mod command_attr; mod command_attr;
mod command_enum; mod command_enum;
mod error; mod error;
mod fields_parse; mod fields_parse;
mod fields_stringify;
mod inline_buttons;
mod rename_rules; mod rename_rules;
mod unzip; mod unzip;
use crate::inline_buttons::inline_buttons_impl;
pub(crate) use error::{compile_error, Result}; pub(crate) use error::{compile_error, Result};
use syn::{parse_macro_input, DeriveInput}; use syn::{parse_macro_input, DeriveInput};
@ -22,3 +28,10 @@ pub fn bot_commands_derive(tokens: TokenStream) -> TokenStream {
bot_commands_impl(input).unwrap_or_else(<_>::into).into() bot_commands_impl(input).unwrap_or_else(<_>::into).into()
} }
#[proc_macro_derive(InlineButtons, attributes(button))]
pub fn callback_data_derive(tokens: TokenStream) -> TokenStream {
let input = parse_macro_input!(tokens as DeriveInput);
inline_buttons_impl(input).unwrap_or_else(<_>::into).into()
}

View file

@ -16,3 +16,24 @@ where
Unzip(a, b) Unzip(a, b)
} }
} }
pub(crate) struct Unzip3<A, B, C>(pub A, pub B, pub C);
impl<A, B, C, T, U, V> FromIterator<(T, U, V)> for Unzip3<A, B, C>
where
A: Default + Extend<T>,
B: Default + Extend<U>,
C: Default + Extend<V>,
{
fn from_iter<I: IntoIterator<Item = (T, U, V)>>(iter: I) -> Self {
let (mut a, mut b, mut c): (A, B, C) = Default::default();
for (t, u, v) in iter {
a.extend([t]);
b.extend([u]);
c.extend([v]);
}
Unzip3(a, b, c)
}
}

View file

@ -79,7 +79,8 @@ full = [
[dependencies] [dependencies]
# replace me by the actual version when release, and return path when it's time to make 0-day fixes # replace me by the actual version when release, and return path when it's time to make 0-day fixes
teloxide-core = { path = "../../crates/teloxide-core", default-features = false } teloxide-core = { path = "../../crates/teloxide-core", default-features = false }
teloxide-macros = { version = "0.8", optional = true } # me too
teloxide-macros = { path = "../../crates/teloxide-macros", optional = true }
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -1,5 +1,6 @@
//! Some useful utilities. //! Some useful utilities.
pub mod button;
pub mod command; pub mod command;
pub mod html; pub mod html;
pub mod markdown; pub mod markdown;

View file

@ -0,0 +1,22 @@
//! Docs later
use super::command::ParseError;
#[cfg(feature = "macros")]
pub use teloxide_macros::InlineButtons;
/// Docs later
pub trait InlineButtons: Sized {
/// Parses the callback data.
fn parse(s: &str) -> Result<Self, ParseError>;
/// Stringifies the callback data.
fn stringify(self) -> Result<String, StringifyError>;
}
/// Errors returned from [`InlineButtons::stringify`].
///
/// [`InlineButtons::stringify`]: InlineButtons::stringify
#[derive(Debug)]
pub enum StringifyError {
SeparatorInNamedArgument { enum_variant: String, argument: String },
SeparatorInUnnamedArgument { enum_variant: String, field: usize },
}

View file

@ -0,0 +1,130 @@
#[cfg(feature = "macros")]
use teloxide::utils::button::InlineButtons;
// We put tests here because macro expand in unit tests in module
// teloxide::utils::command was a failure
#[test]
#[cfg(feature = "macros")]
fn parse_and_stringify_button_with_args() {
#[derive(InlineButtons, Debug, PartialEq)]
enum DefaultData {
Fruit(String),
Other,
}
let data = "Fruit;apple";
let expected = DefaultData::Fruit("apple".to_string());
let actual = DefaultData::parse(data).unwrap();
assert_eq!(actual, expected);
assert_eq!(actual.stringify().unwrap(), data.to_owned())
}
#[test]
#[cfg(feature = "macros")]
fn parse_and_stringify_button_with_non_string_arg() {
#[derive(InlineButtons, Debug, PartialEq)]
enum DefaultData {
Fruit(i32),
Other,
}
let data = "Fruit;-50";
let expected = DefaultData::Fruit("-50".parse().unwrap());
let actual = DefaultData::parse(data).unwrap();
assert_eq!(actual, expected);
assert_eq!(actual.stringify().unwrap(), data.to_owned())
}
#[test]
#[cfg(feature = "macros")]
fn stringify_button_error() {
use teloxide::utils::button::StringifyError;
#[derive(InlineButtons, Debug, PartialEq)]
#[button(fields_separator = ";")]
enum DefaultData {
Fruit(String),
Other,
}
let button = DefaultData::Fruit("test;test2".to_string());
match button.stringify() {
Err(StringifyError::SeparatorInUnnamedArgument { enum_variant, field }) => {
assert_eq!(field, 0);
assert_eq!(enum_variant, "DefaultData::Fruit")
}
_ => panic!("Expected an error!"),
}
}
#[test]
#[cfg(feature = "macros")]
fn parse_and_stringify_with_fields_separator1() {
#[derive(InlineButtons, Debug, PartialEq)]
#[button(fields_separator = ":")]
enum DefaultData {
Other,
}
let data = "Other";
let expected = DefaultData::Other;
let actual = DefaultData::parse(data).unwrap();
assert_eq!(actual, expected);
assert_eq!(actual.stringify().unwrap(), data.to_owned())
}
#[test]
#[cfg(feature = "macros")]
fn parse_and_stringify_with_fields_separator2() {
#[derive(InlineButtons, Debug, PartialEq)]
#[button(fields_separator = ":")]
enum DefaultData {
Fruit(u8),
Other,
}
let data = "Fruit:10";
let expected = DefaultData::Fruit(10);
let actual = DefaultData::parse("Fruit:10").unwrap();
assert_eq!(actual, expected);
assert_eq!(actual.stringify().unwrap(), data.to_owned())
}
#[test]
#[cfg(feature = "macros")]
fn parse_and_stringify_named_fields() {
#[derive(InlineButtons, Debug, PartialEq)]
enum DefaultData {
Fruit { num: u8, data: String },
Other,
}
let data = "Fruit;10;hello";
let expected = DefaultData::Fruit { num: 10, data: "hello".to_string() };
let actual = DefaultData::parse(data).unwrap();
assert_eq!(actual, expected);
assert_eq!(actual.stringify().unwrap(), data.to_owned())
}
#[test]
#[cfg(feature = "macros")]
fn stringify_button_named_fields_error() {
use teloxide::utils::button::StringifyError;
#[derive(InlineButtons, Debug, PartialEq)]
#[button(fields_separator = ";")]
enum DefaultData {
Fruit { num: u8, data: String },
Other,
}
let button = DefaultData::Fruit { num: 9, data: "test;test2".to_owned() };
match button.stringify() {
Err(StringifyError::SeparatorInNamedArgument { enum_variant, argument }) => {
assert_eq!(argument, "data".to_owned());
assert_eq!(enum_variant, "DefaultData::Fruit")
}
_ => panic!("Expected an error!"),
}
}