From 8d61e7baffa6970734ac23026acc7545767f39a1 Mon Sep 17 00:00:00 2001 From: LasterAlex Date: Fri, 15 Nov 2024 21:46:35 +0200 Subject: [PATCH] Added basic parse and stringify for new proc macro InlineButtons --- Cargo.lock | 24 +--- crates/teloxide-macros/src/button.rs | 29 ++++ crates/teloxide-macros/src/button_attr.rs | 113 +++++++++++++++ crates/teloxide-macros/src/button_enum.rs | 31 +++++ crates/teloxide-macros/src/command_enum.rs | 2 + .../teloxide-macros/src/fields_stringify.rs | 82 +++++++++++ crates/teloxide-macros/src/inline_buttons.rs | 116 ++++++++++++++++ crates/teloxide-macros/src/lib.rs | 13 ++ crates/teloxide-macros/src/unzip.rs | 21 +++ crates/teloxide/Cargo.toml | 3 +- crates/teloxide/src/utils.rs | 1 + crates/teloxide/src/utils/button.rs | 22 +++ crates/teloxide/tests/callback_data.rs | 130 ++++++++++++++++++ 13 files changed, 565 insertions(+), 22 deletions(-) create mode 100644 crates/teloxide-macros/src/button.rs create mode 100644 crates/teloxide-macros/src/button_attr.rs create mode 100644 crates/teloxide-macros/src/button_enum.rs create mode 100644 crates/teloxide-macros/src/fields_stringify.rs create mode 100644 crates/teloxide-macros/src/inline_buttons.rs create mode 100644 crates/teloxide/src/utils/button.rs create mode 100644 crates/teloxide/tests/callback_data.rs diff --git a/Cargo.lock b/Cargo.lock index d9dcf96f..21c8effd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -805,12 +805,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -2085,7 +2079,7 @@ checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -2281,7 +2275,7 @@ dependencies = [ "serde_json", "sqlx", "teloxide-core", - "teloxide-macros 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", + "teloxide-macros", "thiserror", "tokio", "tokio-stream", @@ -2333,19 +2327,7 @@ dependencies = [ name = "teloxide-macros" version = "0.8.0" dependencies = [ - "heck 0.5.0", - "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", + "heck", "proc-macro2", "quote", "syn 1.0.109", diff --git a/crates/teloxide-macros/src/button.rs b/crates/teloxide-macros/src/button.rs new file mode 100644 index 00000000..74e6393e --- /dev/null +++ b/crates/teloxide-macros/src/button.rs @@ -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 { + 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() }) + } +} diff --git a/crates/teloxide-macros/src/button_attr.rs b/crates/teloxide-macros/src/button_attr.rs new file mode 100644 index 00000000..779fb40b --- /dev/null +++ b/crates/teloxide-macros/src/button_attr.rs @@ -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 { + use ButtonAttrKind::*; + + fold_attrs( + attributes, + is_button_attribute, + ButtonAttr::parse, + Self { rename: None, fields_separator: None }, + |mut this, attr| { + fn insert(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 { + 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") +} diff --git a/crates/teloxide-macros/src/button_enum.rs b/crates/teloxide-macros/src/button_enum.rs new file mode 100644 index 00000000..8ebd7338 --- /dev/null +++ b/crates/teloxide-macros/src/button_enum.rs @@ -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 { + 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, + }) + } +} diff --git a/crates/teloxide-macros/src/command_enum.rs b/crates/teloxide-macros/src/command_enum.rs index 7c1fbf4f..9239824d 100644 --- a/crates/teloxide-macros/src/command_enum.rs +++ b/crates/teloxide-macros/src/command_enum.rs @@ -18,6 +18,8 @@ macro_rules! variants_only_attr { }; } +pub(crate) use variants_only_attr; + pub(crate) struct CommandEnum { pub prefix: String, /// The bool is true if the description contains a doc comment diff --git a/crates/teloxide-macros/src/fields_stringify.rs b/crates/teloxide-macros/src/fields_stringify.rs new file mode 100644 index 00000000..0c665408 --- /dev/null +++ b/crates/teloxide-macros/src/fields_stringify.rs @@ -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 +} diff --git a/crates/teloxide-macros/src/inline_buttons.rs b/crates/teloxide-macros/src/inline_buttons.rs new file mode 100644 index 00000000..b999dd34 --- /dev/null +++ b/crates/teloxide-macros/src/inline_buttons.rs @@ -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 { + 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::, 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 { + 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")), + } +} diff --git a/crates/teloxide-macros/src/lib.rs b/crates/teloxide-macros/src/lib.rs index 4d886239..8ffb4f1d 100644 --- a/crates/teloxide-macros/src/lib.rs +++ b/crates/teloxide-macros/src/lib.rs @@ -2,14 +2,20 @@ extern crate proc_macro; mod attr; mod bot_commands; +mod button; +mod button_attr; +mod button_enum; mod command; mod command_attr; mod command_enum; mod error; mod fields_parse; +mod fields_stringify; +mod inline_buttons; mod rename_rules; mod unzip; +use crate::inline_buttons::inline_buttons_impl; pub(crate) use error::{compile_error, Result}; 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() } + +#[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() +} diff --git a/crates/teloxide-macros/src/unzip.rs b/crates/teloxide-macros/src/unzip.rs index 936a4876..675a5623 100644 --- a/crates/teloxide-macros/src/unzip.rs +++ b/crates/teloxide-macros/src/unzip.rs @@ -16,3 +16,24 @@ where Unzip(a, b) } } + +pub(crate) struct Unzip3(pub A, pub B, pub C); + +impl FromIterator<(T, U, V)> for Unzip3 +where + A: Default + Extend, + B: Default + Extend, + C: Default + Extend, +{ + fn from_iter>(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) + } +} diff --git a/crates/teloxide/Cargo.toml b/crates/teloxide/Cargo.toml index a5d6efdb..12524401 100644 --- a/crates/teloxide/Cargo.toml +++ b/crates/teloxide/Cargo.toml @@ -79,7 +79,8 @@ full = [ [dependencies] # 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-macros = { version = "0.8", optional = true } +# me too +teloxide-macros = { path = "../../crates/teloxide-macros", optional = true } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/teloxide/src/utils.rs b/crates/teloxide/src/utils.rs index d9aeb979..b2d3eea4 100644 --- a/crates/teloxide/src/utils.rs +++ b/crates/teloxide/src/utils.rs @@ -1,5 +1,6 @@ //! Some useful utilities. +pub mod button; pub mod command; pub mod html; pub mod markdown; diff --git a/crates/teloxide/src/utils/button.rs b/crates/teloxide/src/utils/button.rs new file mode 100644 index 00000000..844c2959 --- /dev/null +++ b/crates/teloxide/src/utils/button.rs @@ -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; + + /// Stringifies the callback data. + fn stringify(self) -> Result; +} + +/// 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 }, +} diff --git a/crates/teloxide/tests/callback_data.rs b/crates/teloxide/tests/callback_data.rs new file mode 100644 index 00000000..be3ab75e --- /dev/null +++ b/crates/teloxide/tests/callback_data.rs @@ -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!"), + } +}