diff --git a/Cargo.lock b/Cargo.lock index 221295003bb..4b8d1849014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "heck", "proc-macro-error", "proc-macro2", + "pulldown-cmark", "quote", "syn", ] @@ -370,6 +371,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "gimli" version = "0.26.2" @@ -654,6 +664,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.21" diff --git a/clap_derive/Cargo.toml b/clap_derive/Cargo.toml index 7c293958fd9..eae8dd85ddc 100644 --- a/clap_derive/Cargo.toml +++ b/clap_derive/Cargo.toml @@ -42,6 +42,7 @@ quote = "1.0.9" proc-macro2 = "1.0.42" heck = "0.4.0" proc-macro-error = "1" +pulldown-cmark = "0.9" [features] default = [] diff --git a/clap_derive/src/item.rs b/clap_derive/src/item.rs index b9f64da6499..8f103c98b02 100644 --- a/clap_derive/src/item.rs +++ b/clap_derive/src/item.rs @@ -25,7 +25,7 @@ use syn::{ }; use crate::attr::*; -use crate::utils::{inner_type, is_simple_ty, process_doc_comment, Sp, Ty}; +use crate::utils::{inner_type, is_simple_ty, process_doc_comment, process_md_doc_comment, Sp, Ty}; /// Default casing style for generated arguments. pub const DEFAULT_CASING: CasingStyle = CasingStyle::Kebab; @@ -896,7 +896,14 @@ impl Item { }) .collect(); - let (short, long) = process_doc_comment(comment_parts, name, !self.verbatim_doc_comment); + let (short, long) = if self.verbatim_doc_comment { + process_doc_comment(comment_parts, name, !self.verbatim_doc_comment) + } else if !self.verbatim_doc_comment { + // TODO: ^ change to markdown marker or make default? + process_md_doc_comment(comment_parts, name) + } else { + process_md_doc_comment(comment_parts, name) + }; self.doc_comment.extend(short); if supports_long_help { self.doc_comment.extend(long); @@ -1191,7 +1198,7 @@ impl Kind { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Method { name: Ident, args: TokenStream, diff --git a/clap_derive/src/utils/doc_comments.rs b/clap_derive/src/utils/doc_comments.rs index 0da1f3b0267..64a7974bf37 100644 --- a/clap_derive/src/utils/doc_comments.rs +++ b/clap_derive/src/utils/doc_comments.rs @@ -5,9 +5,251 @@ use crate::item::Method; +use proc_macro2::TokenStream; use quote::{format_ident, quote}; use std::iter; +pub fn process_md_doc_comment(lines: Vec, name: &str) -> (Option, Option) { + use pulldown_cmark::{Event, Parser, Tag}; + + let text = lines + .iter() + .skip_while(|s| is_blank(s)) + .flat_map(|s| s.split('\n')) + .map(|l| if l.starts_with(' ') { &l[1..] } else { l }) + .collect::>() + .join("\n"); + + let mut short = TokenStream::new(); + let mut long = TokenStream::new(); + let mut man = TokenStream::new(); + + let parser = Parser::new(&text); + + // ordered list of parent blocks where we're currently parsing + let mut blocking = Vec::new(); + // ordered list of inline features currently active where we're parsing + let mut inliners = Vec::new(); + + #[derive(PartialEq)] + enum State { + Short, + Long, + Man, + } + + let mut state = State::Short; + + for def in parser { + let chunk = match def { + Event::Start(tag) => { + match &tag { + Tag::Paragraph => blocking.push(tag), + Tag::Heading(_level, _fragment, _classes) => todo!("heading"), //blocking.push(tag), + Tag::BlockQuote => todo!("blockquote"), //blocking.push(tag), + Tag::CodeBlock(_kind) => todo!("codeblock"), //blocking.push(tag), + Tag::List(_start) => todo!("list"), //blocking.push(tag), + Tag::Item => todo!("item"), //blocking.push(tag), + Tag::FootnoteDefinition(_label) => todo!("footnote"), //blocking.push(tag), + Tag::Table(_alignment) => todo!("table"), //blocking.push(tag), + Tag::TableHead => todo!("tablehead"), //blocking.push(tag), + Tag::TableRow => todo!("tablerow"), //blocking.push(tag), + Tag::TableCell => todo!("tablecell"), //blocking.push(tag), + Tag::Emphasis => inliners.push(tag), + Tag::Strong => inliners.push(tag), + Tag::Strikethrough => todo!("strike"), //inliners.push(tag), + Tag::Link(_type, _url, _title) => {} //todo!("link"), //inliners.push(tag), + Tag::Image(_type, _url, _title) => todo!("image"), //inliners.push(tag), + }; + None + } + Event::Text(t) => { + let t = t.as_ref(); + // StyledStr can only define a single style, just take last inline container + match inliners.last() { + None => Some(quote!(text.none(#t);)), + Some(Tag::Strong) => Some(quote!(text.literal(#t);)), + Some(Tag::Emphasis) => Some(quote!(text.italic(#t);)), + _ => todo!(), + } + } + Event::End(tag) => match &tag { + // only got twenty dollars in my pocket... + Tag::Paragraph => { + assert_eq!(blocking.pop(), Some(tag)); + if state == State::Short { + state = State::Long; + } + + Some(quote!(text.none("\n\n");)) + } + Tag::Heading(_level, _fragment, _classes) => { + assert_eq!(blocking.pop(), Some(tag)); + state = State::Man; + + Some(quote!(text.none("\n\n");)) + } + Tag::BlockQuote => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::CodeBlock(_kind) => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::List(_start) => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::Item => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::FootnoteDefinition(_label) => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::Table(_alignment) => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::TableHead => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::TableRow => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::TableCell => { + assert_eq!(blocking.pop(), Some(tag)); + None + } + Tag::Emphasis => { + assert_eq!(inliners.pop(), Some(tag)); + None + } + Tag::Strong => { + assert_eq!(inliners.pop(), Some(tag)); + None + } + Tag::Strikethrough => { + assert_eq!(inliners.pop(), Some(tag)); + None + } + Tag::Link(_type, _url, _title) => { + //assert_eq!(inliners.pop(), Some(tag)); + None + } + Tag::Image(_type, _url, _title) => { + assert_eq!(inliners.pop(), Some(tag)); + None + } + }, + Event::Code(t) => { + let t = t.as_ref(); + Some(quote!(text.code(#t);)) + } + Event::Html(_) => { + todo!("drop or panic?") + } + Event::FootnoteReference(_) => { + todo!("can this be handled? just leave the markdown as-is?") + } + // single line breaks within paragraphs + Event::SoftBreak => Some(quote!(text.none(" ");)), + // double line breaks between paragraphs + // TODO: peek into the parser to check if there's more content coming to avoid adding + // blank lines at the end. + Event::HardBreak => Some(quote!(text.none("\n\n");)), + Event::Rule => { + todo!("would need terminal width for this? is there any sort of responsive way to do this?") + } + Event::TaskListMarker(checked) => { + let _marker = if checked { '☑' } else { '☐' }; + None + } + }; + + if let Some(chunk) = chunk { + match state { + State::Short => { + short.extend(chunk.clone()); + long.extend(chunk.clone()); + man.extend(chunk); + } + State::Long => { + long.extend(chunk.clone()); + man.extend(chunk); + } + State::Man => { + man.extend(chunk); + } + } + } + } + + let short_name = format_ident!("{}", name); + let long_name = format_ident!("long_{}", name); + + let short_about = if short.is_empty() { + None + } else { + let text_block = quote! { + { + let mut text = clap::builder::StyledStr::new(); + #short + text + } + }; + Some(Method::new(short_name, text_block)) + }; + + let long_about = if long.is_empty() { + None + } else { + let text_block = quote! { + { + let mut text = clap::builder::StyledStr::new(); + #long + text + } + }; + Some(Method::new(long_name, text_block)) + }; + + (short_about, long_about) +} + +#[test] +fn md() { + let inp = r##" +This is the __short__ desciption. + +This is the *long* description, it contains an [inline link](rust-lang.org). +"##; + let _ = r##" + +It also contains a [ref link]. + +# Examples + +``` +frobulate compular +``` + +[ref link]: https://github.com/clap-rs/clap + +Okay. + + "##; + + // mangle input to match how we'd normally get it + let lines: Vec = inp.lines().map(|l| format!(" {}", l)).collect(); + + let tokens = dbg!(process_md_doc_comment(lines.clone(), "frobulate")); +} + pub fn process_doc_comment( lines: Vec, name: &str, diff --git a/clap_derive/src/utils/mod.rs b/clap_derive/src/utils/mod.rs index 77a467c754e..05b6c0280a4 100644 --- a/clap_derive/src/utils/mod.rs +++ b/clap_derive/src/utils/mod.rs @@ -3,7 +3,7 @@ mod spanned; mod ty; pub use self::{ - doc_comments::process_doc_comment, + doc_comments::{process_doc_comment, process_md_doc_comment}, spanned::Sp, ty::{inner_type, is_simple_ty, sub_type, subty_if_name, Ty}, }; diff --git a/src/builder/styled_str.rs b/src/builder/styled_str.rs index ecd05a359d0..59124a8395f 100644 --- a/src/builder/styled_str.rs +++ b/src/builder/styled_str.rs @@ -28,38 +28,56 @@ impl StyledStr { AnsiDisplay { styled: self } } - pub(crate) fn header(&mut self, msg: impl Into) { + /// Display string as header. + pub fn header(&mut self, msg: impl Into) { self.stylize_(Some(Style::Header), msg.into()); } - pub(crate) fn literal(&mut self, msg: impl Into) { + /// Display string as literal. + pub fn literal(&mut self, msg: impl Into) { self.stylize_(Some(Style::Literal), msg.into()); } - pub(crate) fn placeholder(&mut self, msg: impl Into) { + /// Display string as placeholder. + pub fn placeholder(&mut self, msg: impl Into) { self.stylize_(Some(Style::Placeholder), msg.into()); } #[cfg_attr(not(feature = "error-context"), allow(dead_code))] - pub(crate) fn good(&mut self, msg: impl Into) { + /// Display string as good. + pub fn good(&mut self, msg: impl Into) { self.stylize_(Some(Style::Good), msg.into()); } #[cfg_attr(not(feature = "error-context"), allow(dead_code))] - pub(crate) fn warning(&mut self, msg: impl Into) { + /// Display string as warning. + pub fn warning(&mut self, msg: impl Into) { self.stylize_(Some(Style::Warning), msg.into()); } - pub(crate) fn error(&mut self, msg: impl Into) { + /// Display string as error. + pub fn error(&mut self, msg: impl Into) { self.stylize_(Some(Style::Error), msg.into()); } #[allow(dead_code)] - pub(crate) fn hint(&mut self, msg: impl Into) { + /// Display string as hint. + pub fn hint(&mut self, msg: impl Into) { self.stylize_(Some(Style::Hint), msg.into()); } - pub(crate) fn none(&mut self, msg: impl Into) { + /// Display string as code. + pub fn code(&mut self, msg: impl Into) { + self.stylize_(Some(Style::Code), msg.into()); + } + + /// Display string as italic. + pub fn italic(&mut self, msg: impl Into) { + self.stylize_(Some(Style::Italic), msg.into()); + } + + /// Display string without any defined style. + pub fn none(&mut self, msg: impl Into) { self.stylize_(None, msg.into()); } @@ -221,6 +239,12 @@ impl StyledStr { Some(Style::Hint) => { color.set_dimmed(true); } + Some(Style::Code) => { + color.set_bold(true); + } + Some(Style::Italic) => { + color.set_italic(true); + } None => {} } @@ -329,6 +353,8 @@ pub(crate) enum Style { Warning, Error, Hint, + Code, + Italic, } impl Style { @@ -341,6 +367,8 @@ impl Style { Self::Warning => 4, Self::Error => 5, Self::Hint => 6, + Self::Code => 7, + Self::Italic => 8, } } } diff --git a/tests/derive/doc_comments_help.rs b/tests/derive/doc_comments_help.rs index 8aa4fa1e567..39ce2532f8a 100644 --- a/tests/derive/doc_comments_help.rs +++ b/tests/derive/doc_comments_help.rs @@ -14,7 +14,7 @@ use crate::utils; -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +use clap::{builder::StyledStr, CommandFactory, Parser, Subcommand, ValueEnum}; #[test] fn doc_comments() { @@ -126,6 +126,78 @@ fn top_long_doc_comment_both_help_long_help() { assert!(long_help.contains("Or something else")); } +#[test] +fn md_doc_comment() { + /// A remarkable tool + /// + /// This tool is remarkable becasue its help is generated from **markdown**! + /// + /// It has some _italic_ text. + /// + /// This is a [link](https://example.com). (But not in the help output.) + /// + /// This paragraph has `inline code`. + /// + /// This is also a paragraph, but unlike the other paragraphs, this one is + /// spread across multiple lines. Or at least it is in the markdown. + /// + // * It contains lists + // * `inline code` + // + // ```plain + // It also contains + // fenced + // code blocks. + // ``` + // + // # Examples + // + // It has examples + // + // And indented + // code blocks. + // + #[derive(Parser, Debug)] + //#[command(verbatim_doc_comment)] + struct Remarkable { + /// Markdown _is_ __also__ handled here. + #[arg(long)] + foo: bool, + } + + let short_snippet = "A remarkable tool"; + let _long_snippet = "generated from markdown!"; + let man_snippet = "It has examples"; + + let long_colored_snippet = { + let mut s = StyledStr::new(); + // this must contain a complete markdown paragraph + // TODO: trim start and end codes to be able to check for smaller snippets + s.none("This tool is remarkable becasue its help is generated from "); + s.literal("markdown"); + s.none("!"); + let ansi = s.ansi(); + ansi.to_string() + }; + + let short_help = Remarkable::command().render_help().ansi().to_string(); + + //let short_help = utils::get_help::(); + assert!(short_help.contains(short_snippet)); + assert!(!short_help.contains(&long_colored_snippet)); + assert!(!short_help.contains(man_snippet)); + + let long_help = Remarkable::command().render_long_help().ansi().to_string(); + + eprintln!("\n%%% LONG_HELP %%%:=====\n{}\n=====\n", long_help); + assert!(long_help.contains("A remarkable tool")); + assert!(long_help.contains(short_snippet)); + // TODO: filter style from output and check text content + //assert!(long_help.contains(long_snippet)); + assert!(long_help.contains(&long_colored_snippet)); + assert!(!long_help.contains(man_snippet)); +} + #[test] fn verbatim_doc_comment() { /// DANCE!