Merge pull request #861 from TheAwiteb/support-doc-attr

Support setting the help message of commands with `/// ...`
This commit is contained in:
Tima Kinsart 2023-09-22 22:29:28 +00:00 committed by GitHub
commit f670a88c67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 297 additions and 77 deletions

View file

@ -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)) - Fix `split` parser for tuple variants with len < 2 ([issue #834](https://github.com/teloxide/teloxide/issues/834))
### Added ### 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)) - Now you can use `#[command(hide)]` to hide a command from the help message ([PR #862](https://github.com/teloxide/teloxide/pull/862))
### Deprecated ### Deprecated

View file

@ -1,8 +1,8 @@
use crate::{error::compile_error_at, Result}; use crate::{error::compile_error_at, Result};
use proc_macro2::Span; use proc_macro2::{Delimiter, Span};
use syn::{ use syn::{
parse::{Parse, ParseBuffer, ParseStream}, parse::{Parse, ParseStream, Parser},
spanned::Spanned, spanned::Spanned,
Attribute, Ident, Lit, Path, Token, Attribute, Ident, Lit, Path, Token,
}; };
@ -18,18 +18,41 @@ pub(crate) fn fold_attrs<A, R>(
.iter() .iter()
.filter(|&a| filter(a)) .filter(|&a| filter(a))
.flat_map(|attribute| { .flat_map(|attribute| {
// FIXME: don't allocate here let Some(key) = attribute.path.get_ident().cloned() else {
let attrs = match attribute.parse_args_with(|input: &ParseBuffer| { return vec![Err(compile_error_at(
input.parse_terminated::<_, Token![,]>(Attr::parse) "expected an ident",
}) { attribute.path.span(),
Ok(ok) => ok, ))];
Err(err) => return vec![Err(err.into())],
}; };
attrs.into_iter().map(&parse).collect() match (|input: ParseStream<'_>| Attrs::parse_with_key(input, key))
}) .parse(attribute.tokens.clone().into())
.try_fold(init, |acc, r| r.and_then(|r| f(acc, r))) {
Ok(ok) => ok.0.into_iter().map(&parse).collect(),
Err(err) => vec![Err(err.into())],
} }
})
.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<Attr>);
/// An attribute key-value pair. /// An attribute key-value pair.
/// ///
@ -38,8 +61,17 @@ pub(crate) fn fold_attrs<A, R>(
/// #[blahblah(key = "puff", value = 12, nope)] /// #[blahblah(key = "puff", value = 12, nope)]
/// ^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^ /// ^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^
/// ``` /// ```
#[derive(Debug)]
pub(crate) struct Attr { 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<Ident>,
pub value: AttrValue, pub value: AttrValue,
} }
@ -50,16 +82,53 @@ pub(crate) struct Attr {
/// #[blahblah(key = "puff", value = 12, nope)] /// #[blahblah(key = "puff", value = 12, nope)]
/// ^^^^^^ ^^ ^-- (None pseudo-value) /// ^^^^^^ ^^ ^-- (None pseudo-value)
/// ``` /// ```
#[derive(Debug)]
pub(crate) enum AttrValue { pub(crate) enum AttrValue {
Path(Path), Path(Path),
Lit(Lit), Lit(Lit),
None(Span), None(Span),
} }
impl Parse for Attr { impl Parse for Attrs {
fn parse(input: ParseStream) -> syn::Result<Self> { fn parse(input: ParseStream) -> syn::Result<Attrs> {
let key = input.parse::<Ident>()?; let key = input.parse::<Ident>()?;
Attrs::parse_with_key(input, key)
}
}
impl Attrs {
fn parse_with_key(input: ParseStream, key: Ident) -> syn::Result<Attrs> {
// 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![=]) { let value = match input.peek(Token![=]) {
true => { true => {
input.parse::<Token![=]>()?; input.parse::<Token![=]>()?;
@ -68,13 +137,18 @@ impl Parse for Attr {
false => AttrValue::None(input.span()), false => AttrValue::None(input.span()),
}; };
Ok(Self { key, value }) Ok(Attrs(vec![Attr { key: vec![key], value }]))
} }
} }
impl Attr { impl Attr {
pub(crate) fn span(&self) -> Span { 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()
} }
} }

View file

@ -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) }, Some(gd) => quote! { .global_description(#gd) },
None => quote! {}, None => quote! {},
}; };

View file

@ -9,7 +9,8 @@ pub(crate) struct Command {
/// Prefix of this command, for example "/". /// Prefix of this command, for example "/".
pub prefix: String, pub prefix: String,
/// Description for the command. /// 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. /// Name of the command, with all renames already applied.
pub name: String, pub name: String,
/// Parser for arguments of this command. /// Parser for arguments of this command.
@ -61,15 +62,22 @@ impl Command {
} }
pub fn description(&self) -> Option<&str> { 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 { pub(crate) fn description_is_enabled(&self) -> bool {
// FIXME: remove the first, `== "off"`, check eventually // 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<Span> { pub(crate) fn deprecated_description_off_span(&self) -> Option<Span> {
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)
} }
} }

View file

@ -6,13 +6,18 @@ use crate::{
Result, Result,
}; };
use proc_macro::TokenStream;
use proc_macro2::Span; use proc_macro2::Span;
use syn::Attribute; use syn::{
parse::{ParseStream, Peek},
Attribute, Token,
};
/// All attributes that can be used for `derive(BotCommands)` /// All attributes that can be used for `derive(BotCommands)`
pub(crate) struct CommandAttrs { pub(crate) struct CommandAttrs {
pub prefix: Option<(String, Span)>, 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_rule: Option<(RenameRule, Span)>,
pub rename: Option<(String, Span)>, pub rename: Option<(String, Span)>,
pub parser: Option<(ParserType, Span)>, pub parser: Option<(ParserType, Span)>,
@ -37,7 +42,8 @@ struct CommandAttr {
/// Kind of [`CommandAttr`]. /// Kind of [`CommandAttr`].
enum CommandAttrKind { enum CommandAttrKind {
Prefix(String), Prefix(String),
Description(String), /// Description of the command. and if its doc comment or not
Description(String, bool),
RenameRule(RenameRule), RenameRule(RenameRule),
Rename(String), Rename(String),
ParseWith(ParserType), ParseWith(ParserType),
@ -51,7 +57,7 @@ impl CommandAttrs {
fold_attrs( fold_attrs(
attributes, attributes,
is_command_attribute, |attr| is_command_attribute(attr) || is_doc_comment(attr),
CommandAttr::parse, CommandAttr::parse,
Self { Self {
prefix: None, 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 { match attr.kind {
Prefix(p) => insert(&mut this.prefix, p, attr.sp), 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), RenameRule(r) => insert(&mut this.rename_rule, r, attr.sp),
Rename(r) => insert(&mut this.rename, r, attr.sp), Rename(r) => insert(&mut this.rename, r, attr.sp),
ParseWith(p) => insert(&mut this.parser, p, attr.sp), ParseWith(p) => insert(&mut this.parser, p, attr.sp),
@ -94,10 +124,41 @@ impl CommandAttr {
use CommandAttrKind::*; use CommandAttrKind::*;
let sp = attr.span(); let sp = attr.span();
let Attr { key, value } = attr; let Attr { mut key, value } = attr;
let kind = match &*key.to_string() {
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)
}
"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()?), "prefix" => Prefix(value.expect_string()?),
"description" => Description(value.expect_string()?), "description" => Description(value.expect_string()?, false),
"rename_rule" => { "rename_rule" => {
RenameRule(value.expect_string().and_then(|r| self::RenameRule::parse(&r))?) RenameRule(value.expect_string().and_then(|r| self::RenameRule::parse(&r))?)
} }
@ -109,7 +170,16 @@ impl CommandAttr {
return Err(compile_error_at( return Err(compile_error_at(
"unexpected attribute name (expected one of `prefix`, `description`, \ "unexpected attribute name (expected one of `prefix`, `description`, \
`rename`, `parse_with`, `separator` and `hide`", `rename`, `parse_with`, `separator` and `hide`",
key.span(), attr.span(),
))
}
}
}
_ => {
return Err(compile_error_at(
"unexpected attribute (expected `command` or `doc`)",
outermost_key.span(),
)) ))
} }
}; };
@ -119,8 +189,21 @@ impl CommandAttr {
} }
fn is_command_attribute(a: &Attribute) -> bool { fn is_command_attribute(a: &Attribute) -> bool {
match a.path.get_ident() { matches!(a.path.get_ident(), Some(ident) if ident == "command")
Some(ident) => ident == "command",
_ => false,
} }
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()
} }

View file

@ -5,7 +5,8 @@ use crate::{
pub(crate) struct CommandEnum { pub(crate) struct CommandEnum {
pub prefix: String, pub prefix: String,
pub description: Option<String>, /// The bool is true if the description contains a doc comment
pub description: Option<(String, bool)>,
pub rename_rule: RenameRule, pub rename_rule: RenameRule,
pub parser_type: ParserType, pub parser_type: ParserType,
} }
@ -37,7 +38,7 @@ impl CommandEnum {
Ok(Self { Ok(Self {
prefix: prefix.map(|(p, _)| p).unwrap_or_else(|| "/".to_owned()), 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), rename_rule: rename_rule.map(|(rr, _)| rr).unwrap_or(RenameRule::Identity),
parser_type: parser, parser_type: parser,
}) })

View file

@ -12,21 +12,19 @@ use teloxide::{prelude::*, types::ChatPermissions, utils::command::BotCommands};
// your commands in this format: // your commands in this format:
// %GENERAL-DESCRIPTION% // %GENERAL-DESCRIPTION%
// %PREFIX%%COMMAND% - %DESCRIPTION% // %PREFIX%%COMMAND% - %DESCRIPTION%
/// Use commands in format /%command% %num% %unit%
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command( #[command(rename_rule = "lowercase", parse_with = "split")]
rename_rule = "lowercase",
description = "Use commands in format /%command% %num% %unit%",
parse_with = "split"
)]
enum Command { enum Command {
#[command(description = "kick user from chat.")] /// Kick user from chat.
Kick, Kick,
#[command(description = "ban user in chat.")] /// Ban user in chat.
Ban { Ban {
time: u64, time: u64,
unit: UnitOfTime, unit: UnitOfTime,
}, },
#[command(description = "mute user in chat.")] /// Mute user in chat.
Mute { Mute {
time: u64, time: u64,
unit: UnitOfTime, unit: UnitOfTime,

View file

@ -9,12 +9,13 @@ use teloxide::{
utils::command::BotCommands, utils::command::BotCommands,
}; };
/// These commands are supported:
#[derive(BotCommands)] #[derive(BotCommands)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")] #[command(rename_rule = "lowercase")]
enum Command { enum Command {
#[command(description = "Display this text")] /// Display this text
Help, Help,
#[command(description = "Start")] /// Start
Start, Start,
} }

View file

@ -10,14 +10,16 @@ async fn main() {
Command::repl(bot, answer).await; Command::repl(bot, answer).await;
} }
/// These commands are supported:
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")] #[command(rename_rule = "lowercase")]
enum Command { enum Command {
#[command(description = "display this text.")] /// Display this text.
Help, Help,
#[command(description = "handle a username.")] /// Handle a username.
Username(String), 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 }, UsernameAndAge { username: String, age: u8 },
} }

View file

@ -21,12 +21,13 @@ pub enum State {
GotNumber(i32), GotNumber(i32),
} }
/// These commands are supported:
#[derive(Clone, BotCommands)] #[derive(Clone, BotCommands)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")] #[command(rename_rule = "lowercase")]
pub enum Command { pub enum Command {
#[command(description = "get your number.")] /// Get your number.
Get, Get,
#[command(description = "reset your number.")] /// Reset your number.
Reset, Reset,
} }

View file

@ -95,21 +95,24 @@ struct ConfigParameters {
maintainer_username: Option<String>, maintainer_username: Option<String>,
} }
/// Simple commands
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Simple commands")] #[command(rename_rule = "lowercase")]
enum SimpleCommand { enum SimpleCommand {
#[command(description = "shows this message.")] /// Shows this message.
Help, Help,
#[command(description = "shows maintainer info.")] /// Shows maintainer info.
Maintainer, Maintainer,
#[command(description = "shows your ID.")] /// Shows your ID.
MyId, MyId,
} }
/// Maintainer commands
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Maintainer commands")] #[command(rename_rule = "lowercase")]
enum MaintainerCommands { 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 }, Rand { from: u64, to: u64 },
} }

View file

@ -32,14 +32,15 @@ pub enum State {
}, },
} }
/// These commands are supported:
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "These commands are supported:")] #[command(rename_rule = "lowercase")]
enum Command { enum Command {
#[command(description = "display this text.")] /// Display this text.
Help, Help,
#[command(description = "start the purchase procedure.")] /// Start the purchase procedure.
Start, Start,
#[command(description = "cancel the purchase procedure.")] /// Cancel the purchase procedure.
Cancel, Cancel,
} }

View file

@ -90,7 +90,7 @@ pub use teloxide_macros::BotCommands;
/// 2. `#[command(prefix = "prefix")]` /// 2. `#[command(prefix = "prefix")]`
/// Change a prefix for all commands (the default is `/`). /// 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. /// Add a summary description of commands before all commands.
/// ///
/// 4. `#[command(parse_with = "parser")]` /// 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 one command to `name` (literal renaming; do not confuse with
/// `rename_rule`). /// `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. /// Give your command a description. It will be shown in the help message.
/// ///
/// 4. `#[command(parse_with = "parser")]` /// 4. `#[command(parse_with = "parser")]`

View file

@ -228,18 +228,66 @@ fn parse_named_fields() {
#[test] #[test]
#[cfg(feature = "macros")] #[cfg(feature = "macros")]
#[allow(deprecated)]
fn descriptions_off() { fn descriptions_off() {
#[derive(BotCommands, Debug, PartialEq)] #[derive(BotCommands, Debug, PartialEq)]
#[command(rename_rule = "lowercase")] #[command(rename_rule = "lowercase")]
enum DefaultCommands { enum DefaultCommands {
#[command(hide)] #[command(hide)]
Start, Start,
#[command(hide)] #[command(description = "off")]
Username, Username,
/// off
Help, 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] #[test]