diff --git a/crates/teloxide-macros/.github/workflows/rust.yml b/crates/teloxide-macros/.github/workflows/rust.yml new file mode 100644 index 00000000..0ec2a488 --- /dev/null +++ b/crates/teloxide-macros/.github/workflows/rust.yml @@ -0,0 +1,72 @@ +on: [push, pull_request] + +name: Continuous integration + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + rust: + - stable + - beta + - nightly + + steps: + - uses: actions/checkout@v1 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + override: true + components: rustfmt, clippy + + - name: stable/beta build + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: build + args: --verbose --features "" + + - name: nightly build + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: build + args: --verbose --all-features + + - name: stable/beta test + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: test + args: --verbose --features "" + + - name: nightly test + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: test + args: --verbose --all-features + + - name: fmt + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: fmt + args: --all -- --check + + - name: stable/beta clippy + uses: actions-rs/cargo@v1 + if: matrix.rust == 'stable' || matrix.rust == 'beta' + with: + command: clippy + args: --all-targets --features "" -- -D warnings + + - name: nightly clippy + uses: actions-rs/cargo@v1 + if: matrix.rust == 'nightly' + with: + command: clippy + args: --all-targets --all-features -- -D warnings diff --git a/crates/teloxide-macros/CHANGELOG.md b/crates/teloxide-macros/CHANGELOG.md new file mode 100644 index 00000000..34b61e81 --- /dev/null +++ b/crates/teloxide-macros/CHANGELOG.md @@ -0,0 +1,144 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## unreleased + +## 0.7.0 - 2022-10-06 + +### Removed + +- `derive(DialogueState)` macro + +### Changed + +- `#[command(rename = "...")]` now always renames to `"..."`; to rename multiple commands using the same pattern, use `#[command(rename_rule = "snake_case")]` and the like. +- `#[command(parse_with = ...)]` now requires a path, instead of a string, when specifying custom parsers. + +### Fixed + +- `#[derive(BotCommands)]` even if the trait is not imported ([issue #717](https://github.com/teloxide/teloxide/issues/717)). + +## 0.6.3 - 2022-07-19 + +### Fixed + + - Allow specifying a path to a command handler in `parse_with` ([PR #27](https://github.com/teloxide/teloxide-macros/pull/27)). + +## 0.6.2 - 2022-05-27 + +### Fixed + + - Fix `#[command(rename = "...")]` for custom command names ([issue 633](https://github.com/teloxide/teloxide/issues/633)). + +## 0.6.1 - 2022-04-26 + +### Fixed + + - Fix `#[derive(DialogueState)]` (function return type `dptree::Handler`). + +## 0.6.0 - 2022-04-09 + +### Removed + + - Support for the old dispatching: `#[teloxide(subtransition)]` [**BC**]. + +### Deprecated + + - `#[derive(DialogueState)]` in favour of `teloxide::handler!`. + +## 0.5.1 - 2022-03-23 + +### Fixed + + - Make bot name check case-insensitive ([PR #16](https://github.com/teloxide/teloxide-macros/pull/16)). + +### Added + + - More command rename rules: `UPPERCASE`, `PascalCase`, `camelCase`, `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case`, and `SCREAMING-KEBAB-CASE` ([PR #18](https://github.com/teloxide/teloxide-macros/pull/18)). + +## 0.5.0 - 2022-02-05 + +### Added + +- The `BotCommand::bot_commands()` method that returns `Vec` ([PR #13](https://github.com/teloxide/teloxide-macros/pull/13)). +- `#[derive(DialogueState)]`, `#[handler_out(...)]`, `#[handler(...)]`. + +## 0.4.1 - 2021-07-11 + +### Fixed + + - Fix generics support for a variant's arguments ([PR #8](https://github.com/teloxide/teloxide-macros/issues/8)). + +## 0.4.0 - 2021-03-19 + +### Changed + + - Adjust dialogues with the latest teloxide (v0.4.0). + +## 0.3.2 - 2020-07-27 + +### Added + - `#[derive(Transition)]` with `#[teloxide(subtransition)]`. + +### Removed + - The `dev` branch. + +## 0.3.1 - 2020-07-04 + +### Added + - Now you can remove command from showing in descriptions by defining `description` attribute as `"off"`. + +## 0.3.0 - 2020-07-03 + +### Changed + - The description in `Cargo.toml` was changed to from "The teloxide's macros for internal usage" to "The teloxide's procedural macros". + - Now parsing of arguments happens using special function. There are 3 possible variants: + - Using `default` parser, which only put all text in one String field. + - Using `split` parser, which split all text by `separator` (by default is whitespace) and then use FromStr::from_str to construct value. + - Using custom separator. + - Now function `parse` return Result instead of Option. + +### Added + - This `CHANGELOG.md`. + - `.gitignore`. + - `#[parse_with]` attribute. + - `#[separator='%sep%']` attribute. + +## 0.2.1 - 2020-02-25 + +### Changed + - The description in `Cargo.toml` was changed to from "The teloxide's macros for internal usage" to "The teloxide's procedural macros". + +### Added + - This `CHANGELOG.md`. + - `.gitignore`. + - The functionality to parse commands only with a correct bot's name (breaks backwards compatibility). + +## 0.1.2 - 2020-02-24 + +### Changed + - The same as v0.1.1, but fixes [the issue](https://github.com/teloxide/teloxide/issues/176) about backwards compatibility. + + +## 0.2.0 - YANKED + +### Changed + - Fixes [the issue](https://github.com/teloxide/teloxide/issues/176) about backwards compatibility, but fairly soon I realised that semver recommends to use v0.1.2 instead. + + +## 0.1.1 - 2020-02-23 + +### Added + - The `LICENSE` file. + +### Changed + - Backwards compatibility is broken and was fixed in v0.1.2. + + +## 0.1.0 - 2020-02-19 + +### Added + - This project. diff --git a/crates/teloxide-macros/Cargo.toml b/crates/teloxide-macros/Cargo.toml new file mode 100644 index 00000000..e2cb3d1c --- /dev/null +++ b/crates/teloxide-macros/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "teloxide-macros" +version = "0.7.0" +description = "The teloxide's procedural macros" +license = "MIT" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0.7" +proc-macro2 = "1.0.19" +syn = { version = "1.0.13", features = ["full"] } +heck = "0.4.0" + +[dev-dependencies] +# XXX: Do not enable `macros` feature +teloxide = { git = "https://github.com/teloxide/teloxide.git", rev = "b5e237a8a22f9f987b6e4245b9b6c3ca1f804c19" } diff --git a/crates/teloxide-macros/LICENSE b/crates/teloxide-macros/LICENSE new file mode 100644 index 00000000..8e5427af --- /dev/null +++ b/crates/teloxide-macros/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-2022 teloxide + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/teloxide-macros/rustfmt.toml b/crates/teloxide-macros/rustfmt.toml new file mode 100644 index 00000000..aba42ed2 --- /dev/null +++ b/crates/teloxide-macros/rustfmt.toml @@ -0,0 +1,7 @@ +format_code_in_doc_comments = true +wrap_comments = true +format_strings = true +max_width = 80 +imports_granularity = "Crate" +use_small_heuristics = "Max" +use_field_init_shorthand = true diff --git a/crates/teloxide-macros/src/attr.rs b/crates/teloxide-macros/src/attr.rs new file mode 100644 index 00000000..04a070ed --- /dev/null +++ b/crates/teloxide-macros/src/attr.rs @@ -0,0 +1,156 @@ +use crate::{error::compile_error_at, Result}; + +use proc_macro2::Span; +use syn::{ + parse::{Parse, ParseBuffer, ParseStream}, + spanned::Spanned, + Attribute, Ident, Lit, Path, Token, +}; + +pub(crate) fn fold_attrs( + attrs: &[Attribute], + filter: fn(&Attribute) -> bool, + parse: impl Fn(Attr) -> Result, + init: A, + f: impl Fn(A, R) -> Result, +) -> Result { + attrs + .iter() + .filter(|&a| filter(a)) + .flat_map(|attribute| { + // FIXME: don't allocate here + let attrs = + match attribute.parse_args_with(|input: &ParseBuffer| { + input.parse_terminated::<_, Token![,]>(Attr::parse) + }) { + Ok(ok) => ok, + Err(err) => return vec![Err(err.into())], + }; + + attrs.into_iter().map(&parse).collect() + }) + .try_fold(init, |acc, r| r.and_then(|r| f(acc, r))) +} + +/// An attribute key-value pair. +/// +/// For example: +/// ```text +/// #[blahblah(key = "puff", value = 12, nope)] +/// ^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^ +/// ``` +pub(crate) struct Attr { + pub key: Ident, + pub value: AttrValue, +} + +/// Value of an attribute. +/// +/// For example: +/// ```text +/// #[blahblah(key = "puff", value = 12, nope)] +/// ^^^^^^ ^^ ^-- (None pseudo-value) +/// ``` +pub(crate) enum AttrValue { + Path(Path), + Lit(Lit), + None(Span), +} + +impl Parse for Attr { + fn parse(input: ParseStream) -> syn::Result { + let key = input.parse::()?; + + let value = match input.peek(Token![=]) { + true => { + input.parse::()?; + input.parse::()? + } + false => AttrValue::None(input.span()), + }; + + Ok(Self { key, value }) + } +} + +impl Attr { + pub(crate) fn span(&self) -> Span { + self.key + .span() + .join(self.value.span()) + .unwrap_or_else(|| self.key.span()) + } +} + +impl AttrValue { + /// Unwraps this value if it's a string literal. + pub fn expect_string(self) -> Result { + self.expect("a string", |this| match this { + AttrValue::Lit(Lit::Str(s)) => Ok(s.value()), + _ => Err(this), + }) + } + + // /// Unwraps this value if it's a path. + // pub fn expect_path(self) -> Result { + // self.expect("a path", |this| match this { + // AttrValue::Path(p) => Ok(p), + // _ => Err(this), + // }) + // } + + pub fn expect( + self, + expected: &str, + f: impl FnOnce(Self) -> Result, + ) -> Result { + f(self).map_err(|this| { + compile_error_at( + &format!("expected {expected}, found {}", this.descr()), + this.span(), + ) + }) + } + + fn descr(&self) -> &'static str { + use Lit::*; + + match self { + Self::None(_) => "nothing", + Self::Lit(l) => match l { + Str(_) | ByteStr(_) => "a string", + Char(_) => "a character", + Byte(_) | Int(_) => "an integer", + Float(_) => "a floating point integer", + Bool(_) => "a boolean", + Verbatim(_) => ":shrug:", + }, + Self::Path(_) => "a path", + } + } + + /// Returns span of the value + /// + /// ```text + /// #[blahblah(key = "puff", value = 12, nope )] + /// ^^^^^^ ^^ ^ + /// ``` + fn span(&self) -> Span { + match self { + Self::Path(p) => p.span(), + Self::Lit(l) => l.span(), + Self::None(sp) => *sp, + } + } +} + +impl Parse for AttrValue { + fn parse(input: ParseStream) -> syn::Result { + let this = match input.peek(Lit) { + true => Self::Lit(input.parse()?), + false => Self::Path(input.parse()?), + }; + + Ok(this) + } +} diff --git a/crates/teloxide-macros/src/bot_commands.rs b/crates/teloxide-macros/src/bot_commands.rs new file mode 100644 index 00000000..7d7337e6 --- /dev/null +++ b/crates/teloxide-macros/src/bot_commands.rs @@ -0,0 +1,141 @@ +use crate::{ + command::Command, command_enum::CommandEnum, compile_error, + fields_parse::impl_parse_args, unzip::Unzip, Result, +}; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::DeriveInput; + +pub(crate) fn bot_commands_impl(input: DeriveInput) -> Result { + let data_enum = get_enum_data(&input)?; + let command_enum = CommandEnum::from_attributes(&input.attrs)?; + + let Unzip(var_init, var_info) = data_enum + .variants + .iter() + .map(|variant| { + let command = Command::new( + &variant.ident.to_string(), + &variant.attrs, + &command_enum, + )?; + + let variant_name = &variant.ident; + let self_variant = quote! { Self::#variant_name }; + + let parse = + impl_parse_args(&variant.fields, self_variant, &command.parser); + + Ok((parse, command)) + }) + .collect::, Vec<_>>>>()?; + + let type_name = &input.ident; + let fn_descriptions = impl_descriptions(&var_info, &command_enum); + let fn_parse = impl_parse(&var_info, &var_init); + let fn_commands = impl_commands(&var_info); + + let trait_impl = quote! { + impl teloxide::utils::command::BotCommands for #type_name { + #fn_descriptions + #fn_parse + #fn_commands + } + }; + + Ok(trait_impl) +} + +fn impl_commands(infos: &[Command]) -> proc_macro2::TokenStream { + let commands = infos + .iter() + .filter(|command| command.description_is_enabled()) + .map(|command| { + let c = command.get_prefixed_command(); + let d = command.description.as_deref().unwrap_or_default(); + quote! { BotCommand::new(#c,#d) } + }); + + quote! { + fn bot_commands() -> Vec { + use teloxide::types::BotCommand; + vec![#(#commands),*] + } + } +} + +fn impl_descriptions( + infos: &[Command], + global: &CommandEnum, +) -> proc_macro2::TokenStream { + let command_descriptions = infos + .iter() + .filter(|command| command.description_is_enabled()) + .map(|Command { prefix, name, description, ..}| { + let description = description.clone().unwrap_or_default(); + quote! { CommandDescription { prefix: #prefix, command: #name, description: #description } } + }); + + let global_description = match global.description.as_deref() { + Some(gd) => quote! { .global_description(#gd) }, + None => quote! {}, + }; + + quote! { + fn descriptions() -> teloxide::utils::command::CommandDescriptions<'static> { + use teloxide::utils::command::{CommandDescriptions, CommandDescription}; + use std::borrow::Cow; + + CommandDescriptions::new(&[ + #(#command_descriptions),* + ]) + #global_description + } + } +} + +fn impl_parse( + infos: &[Command], + variants_initialization: &[proc_macro2::TokenStream], +) -> proc_macro2::TokenStream { + let matching_values = infos.iter().map(|c| c.get_prefixed_command()); + + quote! { + fn parse(s: &str, bot_name: &str) -> Result { + // FIXME: we should probably just call a helper function from `teloxide`, instead of parsing command syntax ourselves + 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 command and the rest of arguments. + let mut words = s.splitn(2, ' '); + + // Unwrap: split iterators always have at least one item + let mut full_command = words.next().unwrap().split('@'); + let command = full_command.next().unwrap(); + + let bot_username = full_command.next(); + match bot_username { + None => {} + Some(username) if username.eq_ignore_ascii_case(bot_name) => {} + Some(n) => return Err(ParseError::WrongBotName(n.to_owned())), + } + + let args = words.next().unwrap_or("").to_owned(); + match command { + #( + #matching_values => Ok(#variants_initialization), + )* + _ => Err(ParseError::UnknownCommand(command.to_owned())), + } + } + } +} + +fn get_enum_data(input: &DeriveInput) -> Result<&syn::DataEnum> { + match &input.data { + syn::Data::Enum(data) => Ok(data), + _ => Err(compile_error("`BotCommands` is only allowed for enums")), + } +} diff --git a/crates/teloxide-macros/src/command.rs b/crates/teloxide-macros/src/command.rs new file mode 100644 index 00000000..72b05f3e --- /dev/null +++ b/crates/teloxide-macros/src/command.rs @@ -0,0 +1,65 @@ +use crate::{ + command_attr::CommandAttrs, command_enum::CommandEnum, + error::compile_error_at, fields_parse::ParserType, Result, +}; + +pub(crate) struct Command { + /// Prefix of this command, for example "/". + pub prefix: String, + /// Description for the command. + pub description: Option, + /// Name of the command, with all renames already applied. + pub name: String, + /// Parser for arguments of this command. + pub parser: ParserType, +} + +impl Command { + pub fn new( + name: &str, + attributes: &[syn::Attribute], + global_options: &CommandEnum, + ) -> Result { + let attrs = CommandAttrs::from_attributes(attributes)?; + let CommandAttrs { + prefix, + description, + rename_rule, + rename, + parser, + // FIXME: error on/do not ignore separator + separator: _, + } = attrs; + + let name = match (rename, rename_rule) { + (Some((rename, _)), None) => rename, + (Some(_), Some((_, sp))) => { + return Err(compile_error_at( + "`rename_rule` can't be applied to `rename`-d variant", + sp, + )) + } + (None, Some((rule, _))) => rule.apply(name), + (None, None) => global_options.rename_rule.apply(name), + }; + + let prefix = prefix + .map(|(p, _)| p) + .unwrap_or_else(|| global_options.prefix.clone()); + let description = description.map(|(d, _)| d); + let parser = parser + .map(|(p, _)| p) + .unwrap_or_else(|| global_options.parser_type.clone()); + + Ok(Self { prefix, description, parser, name }) + } + + pub fn get_prefixed_command(&self) -> String { + let Self { prefix, name, .. } = self; + format!("{prefix}{name}") + } + + pub(crate) fn description_is_enabled(&self) -> bool { + self.description != Some("off".to_owned()) + } +} diff --git a/crates/teloxide-macros/src/command_attr.rs b/crates/teloxide-macros/src/command_attr.rs new file mode 100644 index 00000000..e735b7bb --- /dev/null +++ b/crates/teloxide-macros/src/command_attr.rs @@ -0,0 +1,129 @@ +use crate::{ + attr::{fold_attrs, Attr}, + error::compile_error_at, + fields_parse::ParserType, + rename_rules::RenameRule, + Result, +}; + +use proc_macro2::Span; +use syn::Attribute; + +/// All attributes that can be used for `derive(BotCommands)` +pub(crate) struct CommandAttrs { + pub prefix: Option<(String, Span)>, + pub description: Option<(String, Span)>, + pub rename_rule: Option<(RenameRule, Span)>, + pub rename: Option<(String, Span)>, + pub parser: Option<(ParserType, Span)>, + pub separator: Option<(String, Span)>, +} + +/// A single k/v attribute for `BotCommands` derive macro. +/// +/// For example: +/// ```text +/// #[command(prefix = "!", rename_rule = "snake_case")] +/// /^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^---- CommandAttr { kind: RenameRule(SnakeCase) } +/// | +/// CommandAttr { kind: Prefix("!") } +/// ``` +struct CommandAttr { + kind: CommandAttrKind, + sp: Span, +} + +/// Kind of [`CommandAttr`]. +enum CommandAttrKind { + Prefix(String), + Description(String), + RenameRule(RenameRule), + Rename(String), + ParseWith(ParserType), + Separator(String), +} + +impl CommandAttrs { + pub fn from_attributes(attributes: &[Attribute]) -> Result { + use CommandAttrKind::*; + + fold_attrs( + attributes, + is_command_attribute, + CommandAttr::parse, + Self { + prefix: None, + description: None, + rename_rule: None, + rename: None, + parser: None, + 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 { + Prefix(p) => insert(&mut this.prefix, p, attr.sp), + Description(d) => insert(&mut this.description, d, attr.sp), + RenameRule(r) => insert(&mut this.rename_rule, r, attr.sp), + Rename(r) => insert(&mut this.rename, r, attr.sp), + ParseWith(p) => insert(&mut this.parser, p, attr.sp), + Separator(s) => insert(&mut this.separator, s, attr.sp), + }?; + + Ok(this) + }, + ) + } +} + +impl CommandAttr { + fn parse(attr: Attr) -> Result { + use CommandAttrKind::*; + + let sp = attr.span(); + let Attr { key, value } = attr; + let kind = match &*key.to_string() { + "prefix" => Prefix(value.expect_string()?), + "description" => Description(value.expect_string()?), + "rename_rule" => RenameRule( + value + .expect_string() + .and_then(|r| self::RenameRule::parse(&r))?, + ), + "rename" => Rename(value.expect_string()?), + "parse_with" => ParseWith(ParserType::parse(value)?), + "separator" => Separator(value.expect_string()?), + _ => { + return Err(compile_error_at( + "unexpected attribute name (expected one of `prefix`, \ + `description`, `rename`, `parse_with` and `separator`", + key.span(), + )) + } + }; + + Ok(Self { kind, sp }) + } +} + +fn is_command_attribute(a: &Attribute) -> bool { + match a.path.get_ident() { + Some(ident) => ident == "command", + _ => false, + } +} diff --git a/crates/teloxide-macros/src/command_enum.rs b/crates/teloxide-macros/src/command_enum.rs new file mode 100644 index 00000000..55f37610 --- /dev/null +++ b/crates/teloxide-macros/src/command_enum.rs @@ -0,0 +1,50 @@ +use crate::{ + command_attr::CommandAttrs, error::compile_error_at, + fields_parse::ParserType, rename_rules::RenameRule, Result, +}; + +pub(crate) struct CommandEnum { + pub prefix: String, + pub description: Option, + pub rename_rule: RenameRule, + pub parser_type: ParserType, +} + +impl CommandEnum { + pub fn from_attributes(attributes: &[syn::Attribute]) -> Result { + let attrs = CommandAttrs::from_attributes(attributes)?; + let CommandAttrs { + prefix, + description, + rename_rule, + rename, + parser, + separator, + } = attrs; + + if let Some((_rename, sp)) = rename { + return Err(compile_error_at( + "`rename` attribute can only be applied to enums *variants*", + sp, + )); + } + + let mut parser = parser.map(|(p, _)| p).unwrap_or(ParserType::Default); + + // FIXME: Error on unused separator + if let (ParserType::Split { separator }, Some((s, _))) = + (&mut parser, &separator) + { + *separator = Some(s.clone()) + } + + Ok(Self { + prefix: prefix.map(|(p, _)| p).unwrap_or_else(|| "/".to_owned()), + description: description.map(|(d, _)| d), + rename_rule: rename_rule + .map(|(rr, _)| rr) + .unwrap_or(RenameRule::Identity), + parser_type: parser, + }) + } +} diff --git a/crates/teloxide-macros/src/error.rs b/crates/teloxide-macros/src/error.rs new file mode 100644 index 00000000..0d27c475 --- /dev/null +++ b/crates/teloxide-macros/src/error.rs @@ -0,0 +1,54 @@ +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug)] +pub(crate) struct Error(TokenStream); + +pub(crate) fn compile_error(data: T) -> Error +where + T: ToTokens, +{ + Error(quote! { compile_error! { #data } }) +} + +pub(crate) fn compile_error_at(msg: &str, sp: Span) -> Error { + use proc_macro2::{ + Delimiter, Group, Ident, Literal, Punct, Spacing, TokenTree, + }; + // compile_error! { $msg } + let ts = TokenStream::from_iter(vec![ + TokenTree::Ident(Ident::new("compile_error", sp)), + TokenTree::Punct({ + let mut punct = Punct::new('!', Spacing::Alone); + punct.set_span(sp); + punct + }), + TokenTree::Group({ + let mut group = Group::new(Delimiter::Brace, { + TokenStream::from_iter(vec![TokenTree::Literal({ + let mut string = Literal::string(msg); + string.set_span(sp); + string + })]) + }); + group.set_span(sp); + group + }), + ]); + + Error(ts) +} + +impl From for proc_macro2::TokenStream { + fn from(Error(e): Error) -> Self { + e + } +} + +impl From for Error { + fn from(e: syn::Error) -> Self { + Self(e.to_compile_error()) + } +} diff --git a/crates/teloxide-macros/src/fields_parse.rs b/crates/teloxide-macros/src/fields_parse.rs new file mode 100644 index 00000000..caf53127 --- /dev/null +++ b/crates/teloxide-macros/src/fields_parse.rs @@ -0,0 +1,164 @@ +use quote::quote; +use syn::{Fields, FieldsNamed, FieldsUnnamed, Type}; + +use crate::{attr::AttrValue, error::Result}; + +#[derive(Clone)] +pub(crate) enum ParserType { + Default, + Split { separator: Option }, + Custom(syn::Path), +} + +impl ParserType { + pub fn parse(value: AttrValue) -> Result { + value.expect( + r#""default", "split", or a path to a custom parser function"#, + |v| match v { + AttrValue::Path(p) => Ok(ParserType::Custom(p)), + AttrValue::Lit(syn::Lit::Str(ref l)) => match &*l.value() { + "default" => Ok(ParserType::Default), + "split" => Ok(ParserType::Split { separator: None }), + _ => Err(v), + }, + _ => Err(v), + }, + ) + } +} + +pub(crate) fn impl_parse_args( + fields: &Fields, + self_variant: proc_macro2::TokenStream, + parser: &ParserType, +) -> proc_macro2::TokenStream { + match fields { + Fields::Unit => self_variant, + Fields::Unnamed(fields) => { + impl_parse_args_unnamed(fields, self_variant, parser) + } + Fields::Named(named) => { + impl_parse_args_named(named, self_variant, parser) + } + } +} + +pub(crate) fn impl_parse_args_unnamed( + data: &FieldsUnnamed, + variant: proc_macro2::TokenStream, + parser_type: &ParserType, +) -> proc_macro2::TokenStream { + let get_arguments = + create_parser(parser_type, data.unnamed.iter().map(|f| &f.ty)); + let iter = (0..data.unnamed.len()).map(syn::Index::from); + let mut initialization = quote! {}; + for i in iter { + initialization.extend(quote! { arguments.#i, }) + } + let res = quote! { + { + #get_arguments + #variant(#initialization) + } + }; + res +} + +pub(crate) fn impl_parse_args_named( + data: &FieldsNamed, + variant: proc_macro2::TokenStream, + parser_type: &ParserType, +) -> proc_macro2::TokenStream { + let get_arguments = + create_parser(parser_type, data.named.iter().map(|f| &f.ty)); + let i = (0..).map(syn::Index::from); + let name = data.named.iter().map(|f| f.ident.as_ref().unwrap()); + let res = quote! { + { + #get_arguments + #variant { #(#name: arguments.#i),* } + } + }; + res +} + +fn create_parser<'a>( + parser_type: &ParserType, + mut types: impl ExactSizeIterator, +) -> proc_macro2::TokenStream { + let function_to_parse = match parser_type { + ParserType::Default => match types.len() { + 1 => { + let ty = types.next().unwrap(); + quote! { + ( + |s: String| { + let res = <#ty>::from_str(&s) + .map_err(|e| ParseError::IncorrectFormat(e.into()))?; + + Ok((res,)) + } + ) + } + } + _ => { + quote! { compile_error!("Default parser works only with exactly 1 field") } + } + }, + ParserType::Split { separator } => parser_with_separator( + &separator.clone().unwrap_or_else(|| " ".to_owned()), + types, + ), + ParserType::Custom(path) => quote! { #path }, + }; + + quote! { + let arguments = #function_to_parse(args)?; + } +} + +fn parser_with_separator<'a>( + separator: &str, + types: impl ExactSizeIterator, +) -> proc_macro2::TokenStream { + let expected = types.len(); + let res = { + let found = 0usize..; + quote! { + ( + #( + { + let s = splitted.next().ok_or(ParseError::TooFewArguments { + expected: #expected, + found: #found, + message: format!("Expected but not found arg number {}", #found + 1), + })?; + + <#types>::from_str(s).map_err(|e| ParseError::IncorrectFormat(e.into()))? + } + ),* + ) + } + }; + + let res = quote! { + ( + |s: String| { + let mut splitted = s.split(#separator); + + let res = #res; + + match splitted.next() { + Some(d) => Err(ParseError::TooManyArguments { + expected: #expected, + found: #expected + 1, + message: format!("Excess argument: {}", d), + }), + None => Ok(res) + } + } + ) + }; + + res +} diff --git a/crates/teloxide-macros/src/lib.rs b/crates/teloxide-macros/src/lib.rs new file mode 100644 index 00000000..4d886239 --- /dev/null +++ b/crates/teloxide-macros/src/lib.rs @@ -0,0 +1,24 @@ +extern crate proc_macro; + +mod attr; +mod bot_commands; +mod command; +mod command_attr; +mod command_enum; +mod error; +mod fields_parse; +mod rename_rules; +mod unzip; + +pub(crate) use error::{compile_error, Result}; +use syn::{parse_macro_input, DeriveInput}; + +use crate::bot_commands::bot_commands_impl; +use proc_macro::TokenStream; + +#[proc_macro_derive(BotCommands, attributes(command))] +pub fn bot_commands_derive(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + + bot_commands_impl(input).unwrap_or_else(<_>::into).into() +} diff --git a/crates/teloxide-macros/src/rename_rules.rs b/crates/teloxide-macros/src/rename_rules.rs new file mode 100644 index 00000000..55280617 --- /dev/null +++ b/crates/teloxide-macros/src/rename_rules.rs @@ -0,0 +1,171 @@ +// Some concepts are from Serde. + +use crate::error::{compile_error, Result}; + +use heck::{ + ToKebabCase, ToLowerCamelCase, ToPascalCase, ToShoutyKebabCase, + ToShoutySnakeCase, ToSnakeCase, +}; + +#[derive(Copy, Clone, Debug)] +pub(crate) enum RenameRule { + /// -> `lowercase` + LowerCase, + /// -> `UPPERCASE` + UpperCase, + /// -> `PascalCase` + PascalCase, + /// -> `camelCase` + CamelCase, + /// -> `snake_case` + SnakeCase, + /// -> `SCREAMING_SNAKE_CASE` + ScreamingSnakeCase, + /// -> `kebab-case` + KebabCase, + /// -> `SCREAMING-KEBAB-CASE` + ScreamingKebabCase, + /// Leaves input as-is + Identity, +} + +impl RenameRule { + /// Apply a renaming rule to a string, returning the version expected in the + /// source. + /// + /// See tests for the details how it will work. + pub fn apply(self, input: &str) -> String { + use RenameRule::*; + + match self { + LowerCase => input.to_lowercase(), + UpperCase => input.to_uppercase(), + PascalCase => input.to_pascal_case(), + CamelCase => input.to_lower_camel_case(), + SnakeCase => input.to_snake_case(), + ScreamingSnakeCase => input.to_shouty_snake_case(), + KebabCase => input.to_kebab_case(), + ScreamingKebabCase => input.to_shouty_kebab_case(), + Identity => input.to_owned(), + } + } + + pub fn parse(rule: &str) -> Result { + use RenameRule::*; + + let rule = match rule { + "lowercase" => LowerCase, + "UPPERCASE" => UpperCase, + "PascalCase" => PascalCase, + "camelCase" => CamelCase, + "snake_case" => SnakeCase, + "SCREAMING_SNAKE_CASE" => ScreamingSnakeCase, + "kebab-case" => KebabCase, + "SCREAMING-KEBAB-CASE" => ScreamingKebabCase, + "identity" => Identity, + invalid => { + return Err(compile_error(format!( + "invalid rename rule `{invalid}` (supported rules: \ + `lowercase`, `UPPERCASE`, `PascalCase`, `camelCase`, \ + `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case`, \ + `SCREAMING-KEBAB-CASE` and `identity`)" + ))) + } + }; + + Ok(rule) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_eq { + ($input:expr => $output:expr) => { + let rule = RenameRule::parse(TYPE).unwrap(); + + assert_eq!(rule.apply($input), $output); + }; + } + + #[test] + fn test_lowercase() { + const TYPE: &str = "lowercase"; + + test_eq!("HelloWorld" => "helloworld"); + test_eq!("Hello_World" => "hello_world"); + test_eq!("Hello-World" => "hello-world"); + test_eq!("helloWorld" => "helloworld"); + } + + #[test] + fn test_uppercase() { + const TYPE: &str = "UPPERCASE"; + + test_eq!("HelloWorld" => "HELLOWORLD"); + test_eq!("Hello_World" => "HELLO_WORLD"); + test_eq!("Hello-World" => "HELLO-WORLD"); + test_eq!("helloWorld" => "HELLOWORLD"); + } + + #[test] + fn test_pascalcase() { + const TYPE: &str = "PascalCase"; + + test_eq!("HelloWorld" => "HelloWorld"); + test_eq!("Hello_World" => "HelloWorld"); + test_eq!("Hello-World" => "HelloWorld"); + test_eq!("helloWorld" => "HelloWorld"); + } + + #[test] + fn test_camelcase() { + const TYPE: &str = "camelCase"; + + test_eq!("HelloWorld" => "helloWorld"); + test_eq!("Hello_World" => "helloWorld"); + test_eq!("Hello-World" => "helloWorld"); + test_eq!("helloWorld" => "helloWorld"); + } + + #[test] + fn test_snakecase() { + const TYPE: &str = "snake_case"; + + test_eq!("HelloWorld" => "hello_world"); + test_eq!("Hello_World" => "hello_world"); + test_eq!("Hello-World" => "hello_world"); + test_eq!("helloWorld" => "hello_world"); + } + + #[test] + fn test_screaming_snakecase() { + const TYPE: &str = "SCREAMING_SNAKE_CASE"; + + test_eq!("HelloWorld" => "HELLO_WORLD"); + test_eq!("Hello_World" => "HELLO_WORLD"); + test_eq!("Hello-World" => "HELLO_WORLD"); + test_eq!("helloWorld" => "HELLO_WORLD"); + } + + #[test] + fn test_kebabcase() { + const TYPE: &str = "kebab-case"; + + test_eq!("HelloWorld" => "hello-world"); + test_eq!("Hello_World" => "hello-world"); + test_eq!("Hello-World" => "hello-world"); + test_eq!("helloWorld" => "hello-world"); + } + + #[test] + fn test_screaming_kebabcase() { + const TYPE: &str = "SCREAMING-KEBAB-CASE"; + + test_eq!("HelloWorld" => "HELLO-WORLD"); + test_eq!("Hello_World" => "HELLO-WORLD"); + test_eq!("Hello-World" => "HELLO-WORLD"); + test_eq!("helloWorld" => "HELLO-WORLD"); + } +} diff --git a/crates/teloxide-macros/src/unzip.rs b/crates/teloxide-macros/src/unzip.rs new file mode 100644 index 00000000..372ad2e2 --- /dev/null +++ b/crates/teloxide-macros/src/unzip.rs @@ -0,0 +1,20 @@ +use std::iter::FromIterator; + +pub(crate) struct Unzip(pub A, pub B); + +impl FromIterator<(T, U)> for Unzip +where + A: Default + Extend, + B: Default + Extend, +{ + fn from_iter>(iter: I) -> Self { + let (mut a, mut b): (A, B) = Default::default(); + + for (t, u) in iter { + a.extend([t]); + b.extend([u]); + } + + Unzip(a, b) + } +} diff --git a/crates/teloxide-macros/tests/command.rs b/crates/teloxide-macros/tests/command.rs new file mode 100644 index 00000000..65aea651 --- /dev/null +++ b/crates/teloxide-macros/tests/command.rs @@ -0,0 +1,298 @@ +//! Test for `teloxide-macros` + +use teloxide_macros::BotCommands; + +// Import only trait _methods_, such that we can call `parse`, but we also test +// that proc macros work without the trait being imported. +use teloxide::utils::command::BotCommands as _; + +#[test] +fn parse_command_with_args() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + Start(String), + Help, + } + + let data = "/start arg1 arg2"; + let expected = DefaultCommands::Start("arg1 arg2".to_string()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn parse_command_with_non_string_arg() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + Start(i32), + Help, + } + + let data = "/start -50"; + let expected = DefaultCommands::Start("-50".parse().unwrap()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn attribute_prefix() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + #[command(prefix = "!")] + Start(String), + Help, + } + + let data = "!start arg1 arg2"; + let expected = DefaultCommands::Start("arg1 arg2".to_string()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn many_attributes() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + #[command(prefix = "!", description = "desc")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("!start", "").unwrap() + ); + assert_eq!( + DefaultCommands::descriptions().to_string(), + "!start — desc\n/help" + ); +} + +#[test] +fn global_attributes() { + #[derive(BotCommands, Debug, PartialEq)] + #[command( + prefix = "!", + rename_rule = "lowercase", + description = "Bot commands" + )] + enum DefaultCommands { + #[command(prefix = "/")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("/start", "MyNameBot").unwrap() + ); + assert_eq!( + DefaultCommands::Help, + DefaultCommands::parse("!help", "MyNameBot").unwrap() + ); + assert_eq!( + DefaultCommands::descriptions().to_string(), + "Bot commands\n\n/start\n!help" + ); +} + +#[test] +fn parse_command_with_bot_name() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + #[command(prefix = "/")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("/start@MyNameBot", "MyNameBot").unwrap() + ); +} + +#[test] +fn parse_with_split() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + #[command(parse_with = "split")] + enum DefaultCommands { + Start(u8, String), + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); +} + +#[test] +fn parse_with_split2() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + #[command(parse_with = "split", separator = "|")] + enum DefaultCommands { + Start(u8, String), + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10|hello", "").unwrap() + ); +} + +#[test] +fn parse_custom_parser() { + mod parser { + use teloxide::utils::command::ParseError; + + pub fn custom_parse_function( + s: String, + ) -> Result<(u8, String), ParseError> { + let vec = s.split_whitespace().collect::>(); + let (left, right) = match vec.as_slice() { + [l, r] => (l, r), + _ => { + return Err(ParseError::IncorrectFormat( + "might be 2 arguments!".into(), + )) + } + }; + left.parse::().map(|res| (res, (*right).to_string())).map_err( + |_| { + ParseError::Custom( + "First argument must be a integer!".to_owned().into(), + ) + }, + ) + } + } + + use parser::custom_parse_function; + + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + #[command(parse_with = custom_parse_function)] + Start(u8, String), + + // Test . + #[command(parse_with = parser::custom_parse_function)] + TestPath(u8, String), + + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); + assert_eq!( + DefaultCommands::TestPath(10, "hello".to_string()), + DefaultCommands::parse("/testpath 10 hello", "").unwrap() + ); +} + +#[test] +fn parse_named_fields() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + #[command(parse_with = "split")] + enum DefaultCommands { + Start { num: u8, data: String }, + Help, + } + + assert_eq!( + DefaultCommands::Start { num: 10, data: "hello".to_string() }, + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); +} + +#[test] +fn descriptions_off() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + #[command(description = "off")] + Start, + Help, + } + + assert_eq!(DefaultCommands::descriptions().to_string(), "/help".to_owned()); +} + +#[test] +fn rename_rules() { + #[derive(BotCommands, Debug, PartialEq)] + enum DefaultCommands { + #[command(rename_rule = "lowercase")] + AaaAaa, + #[command(rename_rule = "UPPERCASE")] + BbbBbb, + #[command(rename_rule = "PascalCase")] + CccCcc, + #[command(rename_rule = "camelCase")] + DddDdd, + #[command(rename_rule = "snake_case")] + EeeEee, + #[command(rename_rule = "SCREAMING_SNAKE_CASE")] + FffFff, + #[command(rename_rule = "kebab-case")] + GggGgg, + #[command(rename_rule = "SCREAMING-KEBAB-CASE")] + HhhHhh, + #[command(rename = "Bar")] + Foo, + } + + assert_eq!( + DefaultCommands::AaaAaa, + DefaultCommands::parse("/aaaaaa", "").unwrap() + ); + assert_eq!( + DefaultCommands::BbbBbb, + DefaultCommands::parse("/BBBBBB", "").unwrap() + ); + assert_eq!( + DefaultCommands::CccCcc, + DefaultCommands::parse("/CccCcc", "").unwrap() + ); + assert_eq!( + DefaultCommands::DddDdd, + DefaultCommands::parse("/dddDdd", "").unwrap() + ); + assert_eq!( + DefaultCommands::EeeEee, + DefaultCommands::parse("/eee_eee", "").unwrap() + ); + assert_eq!( + DefaultCommands::FffFff, + DefaultCommands::parse("/FFF_FFF", "").unwrap() + ); + assert_eq!( + DefaultCommands::GggGgg, + DefaultCommands::parse("/ggg-ggg", "").unwrap() + ); + assert_eq!( + DefaultCommands::HhhHhh, + DefaultCommands::parse("/HHH-HHH", "").unwrap() + ); + assert_eq!( + DefaultCommands::Foo, + DefaultCommands::parse("/Bar", "").unwrap() + ); + + assert_eq!( + "/aaaaaa\n/BBBBBB\n/CccCcc\n/dddDdd\n/eee_eee\n/FFF_FFF\n/ggg-ggg\n/\ + HHH-HHH\n/Bar", + DefaultCommands::descriptions().to_string() + ); +}