From 7ec6ca26b2e19b9696601f294ed8513f444709a6 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:17:42 +0000 Subject: [PATCH 1/3] feat(derive): preserve verbatim doc comments --- conformance/tests/metadata.rs | 66 +++++++++++++++++++++++++++++++++++ derive/src/lib.rs | 2 ++ derive/src/model.rs | 59 ++++++++++++++++++++++++------- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 80258ef36..77f9fbca9 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -212,6 +212,72 @@ struct Verbatim { command: Option, } +/// 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, +} + +#[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 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 diff --git a/derive/src/lib.rs b/derive/src/lib.rs index 6f237419b..8e7ab3e11 100644 --- a/derive/src/lib.rs +++ b/derive/src/lib.rs @@ -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 @@ -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) | diff --git a/derive/src/model.rs b/derive/src/model.rs index a4395a747..60aca02f1 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -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(), @@ -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, @@ -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)?), @@ -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" ), @@ -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); @@ -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)? { @@ -1042,6 +1043,7 @@ impl Field { let mut required_collection = false; let mut help_attr: Option = None; let mut long_help_attr: Option = None; + let mut verbatim_doc_comment = false; let mut hide = false; let mut is_arg = false; let mut choices: Vec = Vec::new(); @@ -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)?; @@ -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`" ), )); @@ -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 @@ -1971,10 +1977,13 @@ fn flag_value(meta: &Meta) -> syn::Result { /// 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, Option)> { +/// 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, Option)> { let mut lines: Vec = Vec::new(); for attr in attrs.iter().filter(|a| a.path().is_ident("doc")) { if let Meta::NameValue(nv) = &attr.meta { @@ -1987,14 +1996,35 @@ fn doc_comment(attrs: &[Attribute]) -> syn::Result<(Option, Option syn::Result { - 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()); @@ -2247,12 +2277,14 @@ 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" ), )); @@ -2260,6 +2292,7 @@ impl Variant { } } } + 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( From 522909b555d11dc1e63ed24f97c1c690921fe3ba Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:33:07 +0000 Subject: [PATCH 2/3] fix(derive): preserve ordinary doc indentation --- conformance/tests/metadata.rs | 18 ++++++++++++++++++ derive/src/model.rs | 19 +++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 77f9fbca9..127be0f99 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -212,6 +212,11 @@ struct Verbatim { command: Option, } +#[doc = " Ordinary first line\n indented continuation"] +#[derive(Cli)] +#[usage(bin = "ordinary-comments")] +struct OrdinaryComments {} + /// First root line /// second root line /// @@ -278,6 +283,19 @@ fn doc_comments_can_preserve_their_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 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 diff --git a/derive/src/model.rs b/derive/src/model.rs index 60aca02f1..81a743c33 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -1996,14 +1996,17 @@ fn doc_comment( // 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.extend(raw.split('\n').map(|line| { - let line = line.strip_prefix(' ').unwrap_or(line); - if verbatim { - line.to_string() - } else { - line.trim_end().to_string() - } - })); + if verbatim { + lines.extend( + raw.split('\n') + .map(|line| line.strip_prefix(' ').unwrap_or(line).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()); + } } } } From 641c80c6efb6933c09cf23e83a1205f933263e04 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Mon, 17 Aug 2026 04:34:29 +0000 Subject: [PATCH 3/3] fix(derive): preserve verbatim continuation indentation --- conformance/tests/metadata.rs | 17 +++++++++++++++++ derive/src/model.rs | 9 +++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/conformance/tests/metadata.rs b/conformance/tests/metadata.rs index 127be0f99..62d124c99 100644 --- a/conformance/tests/metadata.rs +++ b/conformance/tests/metadata.rs @@ -217,6 +217,11 @@ struct Verbatim { #[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 /// @@ -296,6 +301,18 @@ fn ordinary_multiline_doc_attributes_keep_their_indentation() { ); } +#[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 diff --git a/derive/src/model.rs b/derive/src/model.rs index 81a743c33..91cb47eec 100644 --- a/derive/src/model.rs +++ b/derive/src/model.rs @@ -1997,10 +1997,11 @@ fn doc_comment( // command to type. let raw = s.value(); if verbatim { - lines.extend( - raw.split('\n') - .map(|line| line.strip_prefix(' ').unwrap_or(line).to_string()), - ); + 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