diff --git a/crates/teloxide-macros/CHANGELOG.md b/crates/teloxide-macros/CHANGELOG.md index f5ced9e1..d9a191ef 100644 --- a/crates/teloxide-macros/CHANGELOG.md +++ b/crates/teloxide-macros/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix `split` parser for tuple variants with len < 2 ([issue #834](https://github.com/teloxide/teloxide/issues/834)) ### Added - +- Now you can use `/// doc comment` for the command help message ([PR #861](https://github.com/teloxide/teloxide/pull/861)). - Now you can use `#[command(hide)]` to hide a command from the help message ([PR #862](https://github.com/teloxide/teloxide/pull/862)) ### Deprecated diff --git a/crates/teloxide-macros/src/attr.rs b/crates/teloxide-macros/src/attr.rs index a52aff01..bc3e7dd3 100644 --- a/crates/teloxide-macros/src/attr.rs +++ b/crates/teloxide-macros/src/attr.rs @@ -1,8 +1,8 @@ use crate::{error::compile_error_at, Result}; -use proc_macro2::Span; +use proc_macro2::{Delimiter, Span}; use syn::{ - parse::{Parse, ParseBuffer, ParseStream}, + parse::{Parse, ParseStream, Parser}, spanned::Spanned, Attribute, Ident, Lit, Path, Token, }; @@ -18,19 +18,42 @@ pub(crate) fn fold_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())], + let Some(key) = attribute.path.get_ident().cloned() else { + return vec![Err(compile_error_at( + "expected an ident", + attribute.path.span(), + ))]; }; - attrs.into_iter().map(&parse).collect() + match (|input: ParseStream<'_>| Attrs::parse_with_key(input, key)) + .parse(attribute.tokens.clone().into()) + { + Ok(ok) => ok.0.into_iter().map(&parse).collect(), + Err(err) => vec![Err(err.into())], + } }) - .try_fold(init, |acc, r| r.and_then(|r| f(acc, r))) + .try_fold(init, |acc, r| f(acc, r?)) } +/// A helper to parse a set of attributes. +/// +/// For example: +/// ```text +/// #[blahblah(key = "puff", value = 12, nope, inner(what = some::path))] +/// ``` +/// +/// The code above will produce +/// ```test +/// [ +/// Attr { key: [key, blahblah], value: "puff" }, +/// Attr { key: [value, blahblah], value: 12 }, +/// Attr { key: [nope, blahblah], value: none }, +/// Attr { key: [what, inner, blahblah], value: some::path }, +/// ] +/// ``` +#[derive(Default, Debug)] +struct Attrs(Vec); + /// An attribute key-value pair. /// /// For example: @@ -38,8 +61,17 @@ pub(crate) fn fold_attrs( /// #[blahblah(key = "puff", value = 12, nope)] /// ^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^ /// ``` +#[derive(Debug)] pub(crate) struct Attr { - pub key: Ident, + /// The key captures the full "path" in the reverse order, for example here: + /// + /// ```text + /// #[blahblah(key = "puff")] + /// ^^^^^^^^^^^^ + /// ``` + /// + /// The `key` will be `[key, blahblah]`. See [Attrs] for more examples. + pub key: Vec, pub value: AttrValue, } @@ -50,16 +82,53 @@ pub(crate) struct Attr { /// #[blahblah(key = "puff", value = 12, nope)] /// ^^^^^^ ^^ ^-- (None pseudo-value) /// ``` +#[derive(Debug)] pub(crate) enum AttrValue { Path(Path), Lit(Lit), None(Span), } -impl Parse for Attr { - fn parse(input: ParseStream) -> syn::Result { +impl Parse for Attrs { + fn parse(input: ParseStream) -> syn::Result { let key = input.parse::()?; + Attrs::parse_with_key(input, key) + } +} + +impl Attrs { + fn parse_with_key(input: ParseStream, key: Ident) -> syn::Result { + // Parse an attribute group + let attrs = input.step(|cursor| { + if let Some((group, _sp, next_cursor)) = cursor.group(Delimiter::Parenthesis) { + if !next_cursor.eof() { + return Err(syn::Error::new(next_cursor.span(), "unexpected tokens")); + } + + let mut attrs = + (|input: ParseStream<'_>| input.parse_terminated::<_, Token![,]>(Attrs::parse)) + .parse(group.token_stream().into())? + .into_iter() + .reduce(|mut l, r| { + l.0.extend(r.0); + l + }) + .unwrap_or_default(); + + attrs.0.iter_mut().for_each(|attr| attr.key.push(key.clone())); + + Ok((Some(attrs), next_cursor)) + } else { + Ok((None, *cursor)) + } + })?; + + if let Some(attrs) = attrs { + return Ok(attrs); + } + + // Parse a single attribute let value = match input.peek(Token![=]) { true => { input.parse::()?; @@ -68,13 +137,18 @@ impl Parse for Attr { false => AttrValue::None(input.span()), }; - Ok(Self { key, value }) + Ok(Attrs(vec![Attr { key: vec![key], value }])) } } impl Attr { pub(crate) fn span(&self) -> Span { - self.key.span().join(self.value.span()).unwrap_or_else(|| self.key.span()) + self.key().span().join(self.value.span()).unwrap_or_else(|| self.key().span()) + } + + fn key(&self) -> &Ident { + // It's an invariant of the type that `self.key` is non-empty + self.key.first().unwrap() } } diff --git a/crates/teloxide-macros/src/bot_commands.rs b/crates/teloxide-macros/src/bot_commands.rs index 960184f6..e081d680 100644 --- a/crates/teloxide-macros/src/bot_commands.rs +++ b/crates/teloxide-macros/src/bot_commands.rs @@ -76,7 +76,7 @@ fn impl_descriptions(infos: &[Command], global: &CommandEnum) -> proc_macro2::To } }); - let global_description = match global.description.as_deref() { + let global_description = match global.description.as_ref().map(|(d, _)| d) { Some(gd) => quote! { .global_description(#gd) }, None => quote! {}, }; diff --git a/crates/teloxide-macros/src/command.rs b/crates/teloxide-macros/src/command.rs index d26c85f7..a36f91eb 100644 --- a/crates/teloxide-macros/src/command.rs +++ b/crates/teloxide-macros/src/command.rs @@ -9,7 +9,8 @@ pub(crate) struct Command { /// Prefix of this command, for example "/". pub prefix: String, /// Description for the command. - pub description: Option<(String, Span)>, + /// The bool is true if the description contains a doc comment. + pub description: Option<(String, bool, Span)>, /// Name of the command, with all renames already applied. pub name: String, /// Parser for arguments of this command. @@ -61,15 +62,22 @@ impl Command { } pub fn description(&self) -> Option<&str> { - self.description.as_ref().map(|(d, _span)| &**d) + self.description.as_ref().map(|(d, ..)| &**d) + } + + pub fn contains_doc_comment(&self) -> bool { + self.description.as_ref().map(|(_, is_doc, ..)| *is_doc).unwrap_or(false) } pub(crate) fn description_is_enabled(&self) -> bool { // FIXME: remove the first, `== "off"`, check eventually - self.description() != Some("off") && !self.hidden + !((self.description() == Some("off") && !self.contains_doc_comment()) || self.hidden) } pub(crate) fn deprecated_description_off_span(&self) -> Option { - self.description.as_ref().filter(|(d, _)| d == "off").map(|&(_, span)| span) + self.description + .as_ref() + .filter(|(d, ..)| d == "off" && !self.contains_doc_comment()) + .map(|&(.., span)| span) } } diff --git a/crates/teloxide-macros/src/command_attr.rs b/crates/teloxide-macros/src/command_attr.rs index 1e01aed1..b9ce1727 100644 --- a/crates/teloxide-macros/src/command_attr.rs +++ b/crates/teloxide-macros/src/command_attr.rs @@ -6,13 +6,18 @@ use crate::{ Result, }; +use proc_macro::TokenStream; use proc_macro2::Span; -use syn::Attribute; +use syn::{ + parse::{ParseStream, Peek}, + Attribute, Token, +}; /// All attributes that can be used for `derive(BotCommands)` pub(crate) struct CommandAttrs { pub prefix: Option<(String, Span)>, - pub description: Option<(String, Span)>, + /// The bool is true if the description contains a doc comment + pub description: Option<(String, bool, Span)>, pub rename_rule: Option<(RenameRule, Span)>, pub rename: Option<(String, Span)>, pub parser: Option<(ParserType, Span)>, @@ -37,7 +42,8 @@ struct CommandAttr { /// Kind of [`CommandAttr`]. enum CommandAttrKind { Prefix(String), - Description(String), + /// Description of the command. and if its doc comment or not + Description(String, bool), RenameRule(RenameRule), Rename(String), ParseWith(ParserType), @@ -51,7 +57,7 @@ impl CommandAttrs { fold_attrs( attributes, - is_command_attribute, + |attr| is_command_attribute(attr) || is_doc_comment(attr), CommandAttr::parse, Self { prefix: None, @@ -73,9 +79,33 @@ impl CommandAttrs { } } + fn join_string(opt: &mut Option<(String, bool, Span)>, new_str: &str, sp: Span) { + match opt { + slot @ None => { + *slot = Some((new_str.to_owned(), false, sp)); + } + Some((old_str, ..)) => { + *old_str = format!("{old_str}\n{new_str}"); + } + } + } + match attr.kind { Prefix(p) => insert(&mut this.prefix, p, attr.sp), - Description(d) => insert(&mut this.description, d, attr.sp), + Description(d, is_doc) => { + join_string( + &mut this.description, + // Sometimes doc comments include a space before them, this removes it + d.strip_prefix(' ').unwrap_or(&d), + attr.sp, + ); + if is_doc { + if let Some((_, is_doc, _)) = &mut this.description { + *is_doc = true; + } + } + Ok(()) + } 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), @@ -94,22 +124,62 @@ impl CommandAttr { 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))?) + 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() { + "doc" => { + if let Some(unexpected_key) = key.last() { + return Err(compile_error_at( + "`doc` can't have nested attributes", + unexpected_key.span(), + )); + } + + Description(value.expect_string()?, true) } - "rename" => Rename(value.expect_string()?), - "parse_with" => ParseWith(ParserType::parse(value)?), - "separator" => Separator(value.expect_string()?), - "hide" => value.expect_none("hide").map(|_| Hide)?, + + "command" => { + 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() { + "prefix" => Prefix(value.expect_string()?), + "description" => Description(value.expect_string()?, false), + "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()?), + "hide" => value.expect_none("hide").map(|_| Hide)?, + _ => { + return Err(compile_error_at( + "unexpected attribute name (expected one of `prefix`, `description`, \ + `rename`, `parse_with`, `separator` and `hide`", + attr.span(), + )) + } + } + } + _ => { return Err(compile_error_at( - "unexpected attribute name (expected one of `prefix`, `description`, \ - `rename`, `parse_with`, `separator` and `hide`", - key.span(), + "unexpected attribute (expected `command` or `doc`)", + outermost_key.span(), )) } }; @@ -119,8 +189,21 @@ impl CommandAttr { } fn is_command_attribute(a: &Attribute) -> bool { - match a.path.get_ident() { - Some(ident) => ident == "command", - _ => false, - } + matches!(a.path.get_ident(), Some(ident) if ident == "command") +} + +fn is_doc_comment(a: &Attribute) -> bool { + matches!(a.path.get_ident(), Some(ident) if ident == "doc" && peek_at_token_stream(a.tokens.clone().into(), Token![=])) +} + +fn peek_at_token_stream(s: TokenStream, p: impl Peek) -> bool { + // syn be fr challenge 2023 (impossible) + use syn::parse::Parser; + (|input: ParseStream<'_>| { + let r = input.peek(p); + _ = input.step(|_| Ok(((), syn::buffer::Cursor::empty()))); + Ok(r) + }) + .parse(s) + .unwrap() } diff --git a/crates/teloxide-macros/src/command_enum.rs b/crates/teloxide-macros/src/command_enum.rs index 3648d3ae..d17247cc 100644 --- a/crates/teloxide-macros/src/command_enum.rs +++ b/crates/teloxide-macros/src/command_enum.rs @@ -5,7 +5,8 @@ use crate::{ pub(crate) struct CommandEnum { pub prefix: String, - pub description: Option, + /// The bool is true if the description contains a doc comment + pub description: Option<(String, bool)>, pub rename_rule: RenameRule, pub parser_type: ParserType, } @@ -37,7 +38,7 @@ impl CommandEnum { Ok(Self { prefix: prefix.map(|(p, _)| p).unwrap_or_else(|| "/".to_owned()), - description: description.map(|(d, _)| d), + description: description.map(|(d, is_doc, _)| (d, is_doc)), rename_rule: rename_rule.map(|(rr, _)| rr).unwrap_or(RenameRule::Identity), parser_type: parser, }) diff --git a/crates/teloxide/examples/admin.rs b/crates/teloxide/examples/admin.rs index 113d6f07..8612e5de 100644 --- a/crates/teloxide/examples/admin.rs +++ b/crates/teloxide/examples/admin.rs @@ -12,21 +12,19 @@ use teloxide::{prelude::*, types::ChatPermissions, utils::command::BotCommands}; // your commands in this format: // %GENERAL-DESCRIPTION% // %PREFIX%%COMMAND% - %DESCRIPTION% + +/// Use commands in format /%command% %num% %unit% #[derive(BotCommands, Clone)] -#[command( - rename_rule = "lowercase", - description = "Use commands in format /%command% %num% %unit%", - parse_with = "split" -)] +#[command(rename_rule = "lowercase", parse_with = "split")] enum Command { - #[command(description = "kick user from chat.")] + /// Kick user from chat. Kick, - #[command(description = "ban user in chat.")] + /// Ban user in chat. Ban { time: u64, unit: UnitOfTime, }, - #[command(description = "mute user in chat.")] + /// Mute user in chat. Mute { time: u64, unit: UnitOfTime, diff --git a/crates/teloxide/examples/buttons.rs b/crates/teloxide/examples/buttons.rs index 595189e6..c640d708 100644 --- a/crates/teloxide/examples/buttons.rs +++ b/crates/teloxide/examples/buttons.rs @@ -9,12 +9,13 @@ use teloxide::{ utils::command::BotCommands, }; +/// These commands are supported: #[derive(BotCommands)] -#[command(rename_rule = "lowercase", description = "These commands are supported:")] +#[command(rename_rule = "lowercase")] enum Command { - #[command(description = "Display this text")] + /// Display this text Help, - #[command(description = "Start")] + /// Start Start, } diff --git a/crates/teloxide/examples/command.rs b/crates/teloxide/examples/command.rs index 26848015..c1dc2d94 100644 --- a/crates/teloxide/examples/command.rs +++ b/crates/teloxide/examples/command.rs @@ -10,14 +10,16 @@ async fn main() { Command::repl(bot, answer).await; } +/// These commands are supported: #[derive(BotCommands, Clone)] -#[command(rename_rule = "lowercase", description = "These commands are supported:")] +#[command(rename_rule = "lowercase")] enum Command { - #[command(description = "display this text.")] + /// Display this text. Help, - #[command(description = "handle a username.")] + /// Handle a username. Username(String), - #[command(description = "handle a username and an age.", parse_with = "split")] + /// Handle a username and an age. + #[command(parse_with = "split")] UsernameAndAge { username: String, age: u8 }, } diff --git a/crates/teloxide/examples/db_remember.rs b/crates/teloxide/examples/db_remember.rs index 980d1357..02a9cd19 100644 --- a/crates/teloxide/examples/db_remember.rs +++ b/crates/teloxide/examples/db_remember.rs @@ -21,12 +21,13 @@ pub enum State { GotNumber(i32), } +/// These commands are supported: #[derive(Clone, BotCommands)] -#[command(rename_rule = "lowercase", description = "These commands are supported:")] +#[command(rename_rule = "lowercase")] pub enum Command { - #[command(description = "get your number.")] + /// Get your number. Get, - #[command(description = "reset your number.")] + /// Reset your number. Reset, } diff --git a/crates/teloxide/examples/dispatching_features.rs b/crates/teloxide/examples/dispatching_features.rs index 83efc6e9..8e4c52a4 100644 --- a/crates/teloxide/examples/dispatching_features.rs +++ b/crates/teloxide/examples/dispatching_features.rs @@ -95,21 +95,24 @@ struct ConfigParameters { maintainer_username: Option, } +/// Simple commands #[derive(BotCommands, Clone)] -#[command(rename_rule = "lowercase", description = "Simple commands")] +#[command(rename_rule = "lowercase")] enum SimpleCommand { - #[command(description = "shows this message.")] + /// Shows this message. Help, - #[command(description = "shows maintainer info.")] + /// Shows maintainer info. Maintainer, - #[command(description = "shows your ID.")] + /// Shows your ID. MyId, } +/// Maintainer commands #[derive(BotCommands, Clone)] -#[command(rename_rule = "lowercase", description = "Maintainer commands")] +#[command(rename_rule = "lowercase")] enum MaintainerCommands { - #[command(parse_with = "split", description = "generate a number within range")] + /// Generate a number within range + #[command(parse_with = "split")] Rand { from: u64, to: u64 }, } diff --git a/crates/teloxide/examples/purchase.rs b/crates/teloxide/examples/purchase.rs index daf27cdd..480cec5f 100644 --- a/crates/teloxide/examples/purchase.rs +++ b/crates/teloxide/examples/purchase.rs @@ -32,14 +32,15 @@ pub enum State { }, } +/// These commands are supported: #[derive(BotCommands, Clone)] -#[command(rename_rule = "lowercase", description = "These commands are supported:")] +#[command(rename_rule = "lowercase")] enum Command { - #[command(description = "display this text.")] + /// Display this text. Help, - #[command(description = "start the purchase procedure.")] + /// Start the purchase procedure. Start, - #[command(description = "cancel the purchase procedure.")] + /// Cancel the purchase procedure. Cancel, } diff --git a/crates/teloxide/src/utils/command.rs b/crates/teloxide/src/utils/command.rs index 78a4df7c..d3413e9d 100644 --- a/crates/teloxide/src/utils/command.rs +++ b/crates/teloxide/src/utils/command.rs @@ -90,7 +90,7 @@ pub use teloxide_macros::BotCommands; /// 2. `#[command(prefix = "prefix")]` /// Change a prefix for all commands (the default is `/`). /// -/// 3. `#[command(description = "description")]` +/// 3. `#[command(description = "description")]` and `/// description` /// Add a summary description of commands before all commands. /// /// 4. `#[command(parse_with = "parser")]` @@ -167,7 +167,7 @@ pub use teloxide_macros::BotCommands; /// Rename one command to `name` (literal renaming; do not confuse with /// `rename_rule`). /// -/// 3. `#[command(description = "description")]` +/// 3. `#[command(description = "description")]` and `/// description` /// Give your command a description. It will be shown in the help message. /// /// 4. `#[command(parse_with = "parser")]` diff --git a/crates/teloxide/tests/command.rs b/crates/teloxide/tests/command.rs index c80e71bc..765cd969 100644 --- a/crates/teloxide/tests/command.rs +++ b/crates/teloxide/tests/command.rs @@ -228,18 +228,66 @@ fn parse_named_fields() { #[test] #[cfg(feature = "macros")] +#[allow(deprecated)] fn descriptions_off() { #[derive(BotCommands, Debug, PartialEq)] #[command(rename_rule = "lowercase")] enum DefaultCommands { #[command(hide)] Start, - #[command(hide)] + #[command(description = "off")] Username, + /// off Help, } - assert_eq!(DefaultCommands::descriptions().to_string(), "/help".to_owned()); + assert_eq!(DefaultCommands::descriptions().to_string(), "/help — off".to_owned()); +} + +#[test] +#[cfg(feature = "macros")] +fn description_with_doc_attr() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + /// Start command + Start, + /// Help command\nwithout replace the `\n` + Help, + /// Foo command + /// with new line + Foo, + } + + assert_eq!( + DefaultCommands::descriptions().to_string(), + "/start — Start command\n/help — Help command\\nwithout replace the `\\n`\n/foo — Foo \ + command\nwith new line" + ); +} + +#[test] +#[cfg(feature = "macros")] +fn description_with_doc_attr_and_command() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename_rule = "lowercase")] + enum DefaultCommands { + /// Start command + #[command(description = "Start command")] + Start, + #[command(description = "Help command\nwith new line")] + Help, + /// Foo command + /// with new line + #[command(description = "Foo command\nwith new line")] + Foo, + } + + assert_eq!( + DefaultCommands::descriptions().to_string(), + "/start — Start command\nStart command\n/help — Help command\nwith new line\n/foo — Foo \ + command\nwith new line\nFoo command\nwith new line" + ); } #[test]