Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions conformance/tests/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,107 @@ struct Verbatim {
command: Option<Commands>,
}

#[doc = " Ordinary first line\n indented continuation"]
#[derive(Cli)]
#[usage(bin = "ordinary-comments")]
struct OrdinaryComments {}

#[doc = " Verbatim first line\n indented continuation"]
#[derive(Cli)]
#[usage(bin = "verbatim-attribute-comments", verbatim_doc_comment)]
struct VerbatimAttributeComments {}

/// First root line
/// second root line
///
/// root example
#[derive(Cli)]
#[usage(bin = "verbatim-comments", verbatim_doc_comment)]
struct VerbatimComments {
/// First field line
/// second field line
///
/// field example
#[usage(long, verbatim_doc_comment)]
layout: bool,
#[usage(subcommand)]
command: Option<VerbatimCommands>,
}

#[derive(Args)]
struct Paint {}

#[derive(Subcommands)]
enum VerbatimCommands {
/// First command line
/// second command line
#[usage(verbatim_doc_comment)]
Paint(Paint),
}

#[test]
fn doc_comments_can_preserve_their_layout() {
let spec: LibSpec = VerbatimComments::to_kdl().parse().expect("valid spec");
assert_eq!(
spec.about.as_deref(),
Some("First root line\nsecond root line")
);
assert_eq!(
spec.about_long.as_deref(),
Some("First root line\nsecond root line\n\n root example")
);

let layout = spec.cmd.flags.iter().find(|f| f.name == "layout").unwrap();
assert_eq!(
layout.help.as_deref(),
Some("First field line\nsecond field line")
);
assert_eq!(
layout.help_long.as_deref(),
Some("First field line\nsecond field line\n\n field example")
);

let paint = spec.cmd.subcommands.get("paint").expect("paint");
assert_eq!(
paint.help.as_deref(),
Some("First command line\nsecond command line")
);
assert!(paint.help_long.is_none());

let argv = [
std::ffi::OsStr::new("--layout"),
std::ffi::OsStr::new("paint"),
];
let parsed = VerbatimComments::parse_from(&argv).expect("the metadata still parses");
assert!(parsed.layout);
assert!(matches!(parsed.command, Some(VerbatimCommands::Paint(_))));
}

#[test]
fn ordinary_multiline_doc_attributes_keep_their_indentation() {
let spec: LibSpec = OrdinaryComments::to_kdl().parse().expect("valid spec");
assert_eq!(
spec.about.as_deref(),
Some("Ordinary first line indented continuation")
);
assert_eq!(
spec.about_long.as_deref(),
Some("Ordinary first line\n indented continuation")
);
}

#[test]
fn verbatim_multiline_doc_attributes_keep_their_indentation() {
let spec: LibSpec = VerbatimAttributeComments::to_kdl()
.parse()
.expect("valid spec");
assert_eq!(
spec.about.as_deref(),
Some("Verbatim first line\n indented continuation")
);
assert!(spec.about_long.is_none());
}

#[test]
fn help_text_can_keep_line_breaks_a_comment_would_flow() {
// A doc comment's first paragraph is read the way Rust reads one, so a line break inside
Expand Down
2 changes: 2 additions & 0 deletions derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
//! here the declaration simply wins and the other spelling still answers.
//!
//! On the struct itself: `bin`, `version`, `about`, `long_about`, `before_help`, `after_help`,
//! `verbatim_doc_comment` — preserve doc-comment line breaks and whitespace —
//! `default_subcommand`, `min_usage_version` — the oldest `usage` that can read the emitted
//! spec, declared rather than worked out — `effect` — what running this command does to the world, on an `Args`
//! rather than on the root, which does nothing itself — `completion`, which adds the hidden command a generated shell
Expand All @@ -201,6 +202,7 @@
//! | `env = "X"` | an environment variable that can supply the value |
//! | `default = "x"` | the value when the command line does not supply one; a `Vec` may be given several, and starts out holding all of them |
//! | `help_heading = "x"` | the section to list this under in help output |
//! | `verbatim_doc_comment` | preserve line breaks and whitespace in the doc comment instead of flowing its first paragraph |
//! | `hide` | keep it out of help and completions |
//! | `effect = "write"` | what supplying this flag does to the world: `read`, `write` or `destructive`. Also goes on an `Args`, where it says what *running* the command does |
//! | `double_dash = "…"` | how a positional relates to `--`: `optional` (the default), `required` (fillable only after one), `preserve` (the `--` is a value), `automatic` (filling it ends flag parsing, so a wrapper forwards) |
Expand Down
63 changes: 50 additions & 13 deletions derive/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ impl Cli {
}

let mut name_given = false;
let (about, long_about) = doc_comment(&input.attrs)?;
let mut verbatim_doc_comment = false;
let mut cli = Cli {
ident: input.ident.clone(),
fingerprint: quote::ToTokens::to_token_stream(input).to_string(),
Expand All @@ -354,8 +354,8 @@ impl Cli {
.find(|a| a.path().is_ident("usage"))
.map(|a| a.path().span()),
version: None,
about,
long_about,
about: None,
long_about: None,
unknown_flags: None,
default_subcommand: None,
about_attr: None,
Expand Down Expand Up @@ -383,6 +383,7 @@ impl Cli {
// decorative after it.
"completion" => cli.completion = flag_value(&meta)?,
"settings" => cli.settings = flag_value(&meta)?,
"verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?,
"effect" => cli.effect = Some(effect_value(&meta)?),
"alias" => cli.aliases.extend(selectors(&meta)?),
"alias_hidden" => cli.hidden_aliases.extend(selectors(&meta)?),
Expand Down Expand Up @@ -436,7 +437,7 @@ impl Cli {
path,
format!(
"unknown option `{other}` on a struct; usage::Cli takes \
`name`, `bin`, `version`, `unknown_flags`, \
`name`, `bin`, `version`, `verbatim_doc_comment`, `unknown_flags`, \
`default_subcommand`, `restart_token`, and `mount` here, \
and the description comes from the doc comment"
),
Expand All @@ -446,6 +447,8 @@ impl Cli {
}
}

(cli.about, cli.long_about) = doc_comment(&input.attrs, verbatim_doc_comment)?;

// Declared descriptions win over the comment, which is the point of declaring them.
if let Some(about) = cli.about_attr.take() {
cli.about = Some(about);
Expand Down Expand Up @@ -1010,8 +1013,6 @@ impl Field {
.clone()
.expect("named fields were checked by the caller");
let span = field.span();
let (help, long_help) = doc_comment(&field.attrs)?;

// A subcommand field is neither a flag nor an argument, and shares none of
// their options, so it is recognized before any of them are read.
if let Some(subcommand) = Self::subcommand(field, &ident, span)? {
Expand Down Expand Up @@ -1042,6 +1043,7 @@ impl Field {
let mut required_collection = false;
let mut help_attr: Option<String> = None;
let mut long_help_attr: Option<String> = None;
let mut verbatim_doc_comment = false;
let mut hide = false;
let mut is_arg = false;
let mut choices: Vec<String> = Vec::new();
Expand Down Expand Up @@ -1153,6 +1155,7 @@ impl Field {
// help whose breaks are meant literally has to be given directly.
"help" => help_attr = Some(string_value(&meta)?),
"long_help" => long_help_attr = Some(string_value(&meta)?),
"verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?,
"required" => required_collection = flag_value(&meta)?,
"double_dash" => {
let mode = string_value(&meta)?;
Expand Down Expand Up @@ -1183,6 +1186,7 @@ impl Field {
`var_min`, `var_max`, `value_enum`, `overrides`, \
`conflicts`, `requires`, `required_if`, \
`required_unless`, `help_heading`, `value_name`, \
`verbatim_doc_comment`, \
`required`, and `double_dash`"
),
));
Expand All @@ -1191,6 +1195,8 @@ impl Field {
}
}

let (help, long_help) = doc_comment(&field.attrs, verbatim_doc_comment)?;

// A bare `long` or `short` written before `name` would have captured the
// field name rather than the renamed one, so resolve both once everything
// has been read. Counted rather than rewritten, so a field carrying both a
Expand Down Expand Up @@ -1971,10 +1977,13 @@ fn flag_value(meta: &Meta) -> syn::Result<bool> {

/// Split a doc comment into the short help and the long help.
///
/// The first paragraph is the short form, matching what every Rust CLI framework
/// does and what an author expects from writing one; the whole comment is the long
/// form, and is only reported when it says more than the short one.
fn doc_comment(attrs: &[Attribute]) -> syn::Result<(Option<String>, Option<String>)> {
/// The first paragraph is the short form; the whole comment is the long form and is only
/// reported when it says more than the short one. Prose is flowed by default, while
/// `verbatim` keeps line breaks and whitespace for tables, examples, and ASCII art.
fn doc_comment(
attrs: &[Attribute],
verbatim: bool,
) -> syn::Result<(Option<String>, Option<String>)> {
let mut lines: Vec<String> = Vec::new();
for attr in attrs.iter().filter(|a| a.path().is_ident("doc")) {
if let Meta::NameValue(nv) = &attr.meta {
Expand All @@ -1987,14 +1996,39 @@ fn doc_comment(attrs: &[Attribute]) -> syn::Result<(Option<String>, Option<Strin
// mise's help is full of them, since an indented block is how a spec shows a
// command to type.
let raw = s.value();
lines.push(raw.strip_prefix(' ').unwrap_or(&raw).trim_end().to_string());
if verbatim {
let mut raw_lines = raw.split('\n');
if let Some(first) = raw_lines.next() {
lines.push(first.strip_prefix(' ').unwrap_or(first).to_string());
}
lines.extend(raw_lines.map(str::to_string));
} else {
// Preserve the pre-verbatim behaviour for an explicitly written,
// multiline `#[doc = "..."]`: only `///` contributes one leading
// space per attribute. A newline inside one attribute does not.
lines.push(raw.strip_prefix(' ').unwrap_or(&raw).trim_end().to_string());
}
}
}
}
while lines.first().is_some_and(|line| line.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
if lines.is_empty() {
return Ok((None, None));
}

if verbatim {
let first_blank = lines.iter().position(|line| line.trim().is_empty());
let short_lines = first_blank.map_or(lines.as_slice(), |i| &lines[..i]);
let short = short_lines.join("\n");
let long = first_blank.map(|_| lines.join("\n"));
return Ok(((!short.is_empty()).then_some(short), long));
}

let full = lines.join("\n").trim().to_string();
let short = full
.split("\n\n")
Expand Down Expand Up @@ -2216,7 +2250,7 @@ impl Subcommands {

impl Variant {
fn from_variant(variant: &syn::Variant, enum_ident: &syn::Ident) -> syn::Result<Self> {
let (help, long_help) = doc_comment(&variant.attrs)?;
let mut verbatim_doc_comment = false;
// `unraw` first: `r#type` is how a variant named after a keyword prints, and a command
// called `r#type` is one no user could type. `type` is what they meant.
let mut name = to_kebab(&variant.ident.unraw().to_string());
Expand Down Expand Up @@ -2247,19 +2281,22 @@ impl Variant {
// breaks matter is declared instead.
"help" => help_attr = Some(string_value(&meta)?),
"long_help" => long_help_attr = Some(string_value(&meta)?),
"verbatim_doc_comment" => verbatim_doc_comment = flag_value(&meta)?,
other => {
return Err(syn::Error::new_spanned(
path,
format!(
"unknown option `{other}` on a variant; a subcommand \
variant takes `name`, `alias` and `alias_hidden` here, \
variant takes `name`, `alias`, `alias_hidden` and \
`verbatim_doc_comment` here, \
and its description comes from the doc comment"
),
));
}
}
}
}
let (help, long_help) = doc_comment(&variant.attrs, verbatim_doc_comment)?;
for alias in aliases.iter().chain(&hidden_aliases) {
if alias.is_empty() {
return Err(syn::Error::new_spanned(
Expand Down
Loading