diff options
Diffstat (limited to 'src/tools/cargo/crates/mdman')
42 files changed, 3820 insertions, 0 deletions
diff --git a/src/tools/cargo/crates/mdman/Cargo.lock b/src/tools/cargo/crates/mdman/Cargo.lock new file mode 100644 index 000000000..51fe47a9c --- /dev/null +++ b/src/tools/cargo/crates/mdman/Cargo.lock @@ -0,0 +1,459 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b602bfe940d21c130f3895acd65221e8a61270debe89d628b9cb4e3ccb8569b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "ctor" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39858aa5bac06462d4dd4b9164848eb81ffc4aa5c479746393598fd193afa227" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f0274ae0e023facc3c97b2e00f076be70e254bc851d972503b328db79b2ec" +dependencies = [ + "typenum", +] + +[[package]] +name = "handlebars" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86dbc8a0746b08f363d2e00da48e6c9ceb75c198ac692d2715fcbb5bee74c87d" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error", + "serde", + "serde_json", + "walkdir", +] + +[[package]] +name = "idna" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e2673c30ee86b5b96a9cb52ad15718aa1f966f5ab9ad54a8b95d5ca33120a9" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "itoa" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" + +[[package]] +name = "log" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "mdman" +version = "0.1.0" +dependencies = [ + "anyhow", + "handlebars", + "pretty_assertions", + "pulldown-cmark", + "same-file", + "serde_json", + "url", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "output_vt100" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9" +dependencies = [ + "winapi", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1", +] + +[[package]] +name = "pretty_assertions" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +dependencies = [ + "ctor", + "diff", + "output_vt100", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f5f085b5d71e2188cb8271e5da0161ad52c3f227a661a3c135fdf28e258b12" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5317f7588f0a5078ee60ef675ef96735a1442132dc645eb1d12c018620ed8cd3" + +[[package]] +name = "serde_json" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164eacbdb13512ec2745fb09d51fd5b22b0d65ed294a1dcf7285a360c80a675c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer", + "digest", + "fake-simd", + "opaque-debug", +] + +[[package]] +name = "syn" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdb98bcb1f9d81d07b536179c269ea15999b5d14ea958196413869445bb5250" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tinyvec" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53953d2d3a5ad81d9f844a32f14ebb121f50b650cd59d0ee2a07cf13c617efed" + +[[package]] +name = "typenum" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fb19cf769fa8c6a80a162df694621ebeb4dafb606470b2b2fce0be40a98a977" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "version_check" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" + +[[package]] +name = "walkdir" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/src/tools/cargo/crates/mdman/Cargo.toml b/src/tools/cargo/crates/mdman/Cargo.toml new file mode 100644 index 000000000..92cdf2eb6 --- /dev/null +++ b/src/tools/cargo/crates/mdman/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mdman" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "Creates a man page page from markdown." + +[dependencies] +anyhow = "1.0.31" +handlebars = { version = "3.2.1", features = ["dir_source"] } +pulldown-cmark = { version = "0.9.2", default-features = false } +same-file = "1.0.6" +serde_json = "1.0.56" +url = "2.2.2" + +[dev-dependencies] +pretty_assertions = "1.3.0" diff --git a/src/tools/cargo/crates/mdman/README.md b/src/tools/cargo/crates/mdman/README.md new file mode 100644 index 000000000..e28b596ba --- /dev/null +++ b/src/tools/cargo/crates/mdman/README.md @@ -0,0 +1,7 @@ +# mdman + +mdman is a small utility for creating man pages from markdown text files. + +## Usage + +See the [man page](doc/out/mdman.md) generated by this tool. diff --git a/src/tools/cargo/crates/mdman/build-man.sh b/src/tools/cargo/crates/mdman/build-man.sh new file mode 100755 index 000000000..9286b17c2 --- /dev/null +++ b/src/tools/cargo/crates/mdman/build-man.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -e + +cargo run -- -t md -o doc/out doc/*.md +cargo run -- -t txt -o doc/out doc/*.md +cargo run -- -t man -o doc/out doc/*.md diff --git a/src/tools/cargo/crates/mdman/doc/mdman.md b/src/tools/cargo/crates/mdman/doc/mdman.md new file mode 100644 index 000000000..2025c13dc --- /dev/null +++ b/src/tools/cargo/crates/mdman/doc/mdman.md @@ -0,0 +1,95 @@ +# mdman(1) + +## NAME + +mdman - Converts markdown to a man page + +## SYNOPSIS + +`mdman` [_options_] `-t` _type_ `-o` _outdir_ _sources..._ + +## DESCRIPTION + +Converts a markdown file to a man page. + +The source file is first processed as a +[handlebars](https://handlebarsjs.com/) template. Then, it is processed as +markdown into the target format. This supports different output formats, +such as troff or plain text. + +Every man page should start with a level-1 header with the man name and +section, such as `# mdman(1)`. + +The handlebars template has several special tags to assist with generating the +man page: + +{{{{raw}}}} +- Every block of command-line options must be wrapped between `{{#options}}` + and `{{/options}}` tags. This tells the processor where the options start + and end. +- Each option must be expressed with a `{{#option}}` block. The parameters to + the the block are a sequence of strings indicating the option. For example, + ```{{#option "`-p` _spec_..." "`--package` _spec_..."}}``` is an option that + has two different forms. The text within the string is processed as markdown. + It is recommended to use formatting similar to this example. + + The content of the `{{#option}}` block should contain a detailed description + of the option. + + Use the `{{/option}}` tag to end the option block. +- References to other man pages should use the `{{man name section}}` + expression. For example, `{{man "mdman" 1}}` will generate a reference to + the `mdman(1)` man page. For non-troff output, the `--man` option will tell + `mdman` how to create links to the man page. If there is no matching `--man` + option, then it links to a file named _name_`.md` in the same directory. +- Variables can be set with `{{*set name="value"}}`. These variables can + then be referenced with `{{name}}` expressions. +- Partial templates should be placed in a directory named `includes` + next to the source file. Templates can be included with an expression like + `{{> template-name}}`. +- Other helpers include: + - `{{lower value}}` Converts the given value to lowercase. +{{{{/raw}}}} + +## OPTIONS + +{{#options}} + +{{#option "`-t` _type_"}} +Specifies the output type. The following output types are supported: +- `man` — A troff-style man page. Outputs with a numbered extension (like + `.1`) matching the man page section. +- `md` — A markdown file, after all handlebars processing has been finished. + Outputs with the `.md` extension. +- `txt` — A text file, rendered for situations where a man page viewer isn't + available. Outputs with the `.txt` extension. +{{/option}} + +{{#option "`-o` _outdir_"}} +Specifies the directory where to save the output. +{{/option}} + +{{#option "`--url` _base_url_"}} +Specifies a base URL to use for relative URLs within the document. Any +relative URL will be joined with this URL. +{{/option}} + +{{#option "`--man` _name_`:`_section_`=`_url_"}} +Specifies a URL to use for the given man page. When the `\{{man name +section}}` expression is used, the given URL will be inserted as a link. This +may be specified multiple times. If a man page reference does not have a +matching `--man` entry, then a relative link to a file named _name_`.md` will +be used. +{{/option}} + +{{#option "_sources..._"}} +The source input filename, may be specified multiple times. +{{/option}} + +{{/options}} + +## EXAMPLES + +1. Convert the given documents to man pages: + + mdman -t man -o doc doc/mdman.md diff --git a/src/tools/cargo/crates/mdman/doc/out/mdman.1 b/src/tools/cargo/crates/mdman/doc/out/mdman.1 new file mode 100644 index 000000000..0718d6ddb --- /dev/null +++ b/src/tools/cargo/crates/mdman/doc/out/mdman.1 @@ -0,0 +1,124 @@ +'\" t +.TH "MDMAN" "1" +.nh +.ad l +.ss \n[.ss] 0 +.SH "NAME" +mdman \- Converts markdown to a man page +.SH "SYNOPSIS" +\fBmdman\fR [\fIoptions\fR] \fB\-t\fR \fItype\fR \fB\-o\fR \fIoutdir\fR \fIsources...\fR +.SH "DESCRIPTION" +Converts a markdown file to a man page. +.sp +The source file is first processed as a +\fIhandlebars\fR <https://handlebarsjs.com/> template. Then, it is processed as +markdown into the target format. This supports different output formats, +such as troff or plain text. +.sp +Every man page should start with a level\-1 header with the man name and +section, such as \fB# mdman(1)\fR\&. +.sp +The handlebars template has several special tags to assist with generating the +man page: +.sp +.RS 4 +\h'-04'\(bu\h'+02'Every block of command\-line options must be wrapped between \fB{{#options}}\fR +and \fB{{/options}}\fR tags. This tells the processor where the options start +and end. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Each option must be expressed with a \fB{{#option}}\fR block. The parameters to +the the block are a sequence of strings indicating the option. For example, +\fB{{#option "`\-p` _spec_..." "`\-\-package` _spec_..."}}\fR is an option that +has two different forms. The text within the string is processed as markdown. +It is recommended to use formatting similar to this example. +.sp +The content of the \fB{{#option}}\fR block should contain a detailed description +of the option. +.sp +Use the \fB{{/option}}\fR tag to end the option block. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'References to other man pages should use the \fB{{man name section}}\fR +expression. For example, \fB{{man "mdman" 1}}\fR will generate a reference to +the \fBmdman(1)\fR man page. For non\-troff output, the \fB\-\-man\fR option will tell +\fBmdman\fR how to create links to the man page. If there is no matching \fB\-\-man\fR +option, then it links to a file named \fIname\fR\fB\&.md\fR in the same directory. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Variables can be set with \fB{{*set name="value"}}\fR\&. These variables can +then be referenced with \fB{{name}}\fR expressions. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Partial templates should be placed in a directory named \fBincludes\fR +next to the source file. Templates can be included with an expression like +\fB{{> template\-name}}\fR\&. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Other helpers include: +.sp +.RS 4 +\h'-04'\(bu\h'+02'\fB{{lower value}}\fR Converts the given value to lowercase. +.RE +.RE +.SH "OPTIONS" +.sp +\fB\-t\fR \fItype\fR +.RS 4 +Specifies the output type. The following output types are supported: +.sp +.RS 4 +\h'-04'\(bu\h'+02'\fBman\fR \[em] A troff\-style man page. Outputs with a numbered extension (like +\fB\&.1\fR) matching the man page section. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'\fBmd\fR \[em] A markdown file, after all handlebars processing has been finished. +Outputs with the \fB\&.md\fR extension. +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'\fBtxt\fR \[em] A text file, rendered for situations where a man page viewer isn't +available. Outputs with the \fB\&.txt\fR extension. +.RE +.RE +.sp +\fB\-o\fR \fIoutdir\fR +.RS 4 +Specifies the directory where to save the output. +.RE +.sp +\fB\-\-url\fR \fIbase_url\fR +.RS 4 +Specifies a base URL to use for relative URLs within the document. Any +relative URL will be joined with this URL. +.RE +.sp +\fB\-\-man\fR \fIname\fR\fB:\fR\fIsection\fR\fB=\fR\fIurl\fR +.RS 4 +Specifies a URL to use for the given man page. When the \fB{{man name section}}\fR expression is used, the given URL will be inserted as a link. This +may be specified multiple times. If a man page reference does not have a +matching \fB\-\-man\fR entry, then a relative link to a file named \fIname\fR\fB\&.md\fR will +be used. +.RE +.sp +\fIsources...\fR +.RS 4 +The source input filename, may be specified multiple times. +.RE +.SH "EXAMPLES" +.sp +.RS 4 +\h'-04' 1.\h'+01'Convert the given documents to man pages: +.sp +.RS 4 +.nf +mdman \-t man \-o doc doc/mdman.md +.fi +.RE +.RE diff --git a/src/tools/cargo/crates/mdman/doc/out/mdman.md b/src/tools/cargo/crates/mdman/doc/out/mdman.md new file mode 100644 index 000000000..d0dd34511 --- /dev/null +++ b/src/tools/cargo/crates/mdman/doc/out/mdman.md @@ -0,0 +1,95 @@ +# mdman(1) + +## NAME + +mdman - Converts markdown to a man page + +## SYNOPSIS + +`mdman` [_options_] `-t` _type_ `-o` _outdir_ _sources..._ + +## DESCRIPTION + +Converts a markdown file to a man page. + +The source file is first processed as a +[handlebars](https://handlebarsjs.com/) template. Then, it is processed as +markdown into the target format. This supports different output formats, +such as troff or plain text. + +Every man page should start with a level-1 header with the man name and +section, such as `# mdman(1)`. + +The handlebars template has several special tags to assist with generating the +man page: + +- Every block of command-line options must be wrapped between `{{#options}}` + and `{{/options}}` tags. This tells the processor where the options start + and end. +- Each option must be expressed with a `{{#option}}` block. The parameters to + the the block are a sequence of strings indicating the option. For example, + ```{{#option "`-p` _spec_..." "`--package` _spec_..."}}``` is an option that + has two different forms. The text within the string is processed as markdown. + It is recommended to use formatting similar to this example. + + The content of the `{{#option}}` block should contain a detailed description + of the option. + + Use the `{{/option}}` tag to end the option block. +- References to other man pages should use the `{{man name section}}` + expression. For example, `{{man "mdman" 1}}` will generate a reference to + the `mdman(1)` man page. For non-troff output, the `--man` option will tell + `mdman` how to create links to the man page. If there is no matching `--man` + option, then it links to a file named _name_`.md` in the same directory. +- Variables can be set with `{{*set name="value"}}`. These variables can + then be referenced with `{{name}}` expressions. +- Partial templates should be placed in a directory named `includes` + next to the source file. Templates can be included with an expression like + `{{> template-name}}`. +- Other helpers include: + - `{{lower value}}` Converts the given value to lowercase. + + +## OPTIONS + +<dl> + +<dt class="option-term" id="option-mdman--t"><a class="option-anchor" href="#option-mdman--t"></a><code>-t</code> <em>type</em></dt> +<dd class="option-desc">Specifies the output type. The following output types are supported:</p> +<ul> +<li><code>man</code> — A troff-style man page. Outputs with a numbered extension (like +<code>.1</code>) matching the man page section.</li> +<li><code>md</code> — A markdown file, after all handlebars processing has been finished. +Outputs with the <code>.md</code> extension.</li> +<li><code>txt</code> — A text file, rendered for situations where a man page viewer isn't +available. Outputs with the <code>.txt</code> extension.</li> +</ul></dd> + + +<dt class="option-term" id="option-mdman--o"><a class="option-anchor" href="#option-mdman--o"></a><code>-o</code> <em>outdir</em></dt> +<dd class="option-desc">Specifies the directory where to save the output.</dd> + + +<dt class="option-term" id="option-mdman---url"><a class="option-anchor" href="#option-mdman---url"></a><code>--url</code> <em>base_url</em></dt> +<dd class="option-desc">Specifies a base URL to use for relative URLs within the document. Any +relative URL will be joined with this URL.</dd> + + +<dt class="option-term" id="option-mdman---man"><a class="option-anchor" href="#option-mdman---man"></a><code>--man</code> <em>name</em><code>:</code><em>section</em><code>=</code><em>url</em></dt> +<dd class="option-desc">Specifies a URL to use for the given man page. When the <code>{{man name section}}</code> expression is used, the given URL will be inserted as a link. This +may be specified multiple times. If a man page reference does not have a +matching <code>--man</code> entry, then a relative link to a file named <em>name</em><code>.md</code> will +be used.</dd> + + +<dt class="option-term" id="option-mdman-sources..."><a class="option-anchor" href="#option-mdman-sources..."></a><em>sources...</em></dt> +<dd class="option-desc">The source input filename, may be specified multiple times.</dd> + + +</dl> + +## EXAMPLES + +1. Convert the given documents to man pages: + + mdman -t man -o doc doc/mdman.md diff --git a/src/tools/cargo/crates/mdman/doc/out/mdman.txt b/src/tools/cargo/crates/mdman/doc/out/mdman.txt new file mode 100644 index 000000000..83fa7de90 --- /dev/null +++ b/src/tools/cargo/crates/mdman/doc/out/mdman.txt @@ -0,0 +1,91 @@ +MDMAN(1) + +NAME + mdman - Converts markdown to a man page + +SYNOPSIS + mdman [options] -t type -o outdir sources... + +DESCRIPTION + Converts a markdown file to a man page. + + The source file is first processed as a handlebars + <https://handlebarsjs.com/> template. Then, it is processed as markdown + into the target format. This supports different output formats, such as + troff or plain text. + + Every man page should start with a level-1 header with the man name and + section, such as # mdman(1). + + The handlebars template has several special tags to assist with + generating the man page: + + o Every block of command-line options must be wrapped between + {{#options}} and {{/options}} tags. This tells the processor where + the options start and end. + + o Each option must be expressed with a {{#option}} block. The + parameters to the the block are a sequence of strings indicating the + option. For example, {{#option "`-p` _spec_..." "`--package` + _spec_..."}} is an option that has two different forms. The text + within the string is processed as markdown. It is recommended to use + formatting similar to this example. + + The content of the {{#option}} block should contain a detailed + description of the option. + + Use the {{/option}} tag to end the option block. + + o References to other man pages should use the {{man name section}} + expression. For example, {{man "mdman" 1}} will generate a reference + to the mdman(1) man page. For non-troff output, the --man option will + tell mdman how to create links to the man page. If there is no + matching --man option, then it links to a file named name.md in the + same directory. + + o Variables can be set with {{*set name="value"}}. These variables can + then be referenced with {{name}} expressions. + + o Partial templates should be placed in a directory named includes next + to the source file. Templates can be included with an expression like + {{> template-name}}. + + o Other helpers include: + + o {{lower value}} Converts the given value to lowercase. + +OPTIONS + -t type + Specifies the output type. The following output types are supported: + + o man — A troff-style man page. Outputs with a numbered extension + (like .1) matching the man page section. + + o md — A markdown file, after all handlebars processing has been + finished. Outputs with the .md extension. + + o txt — A text file, rendered for situations where a man page + viewer isn't available. Outputs with the .txt extension. + + -o outdir + Specifies the directory where to save the output. + + --url base_url + Specifies a base URL to use for relative URLs within the document. + Any relative URL will be joined with this URL. + + --man name:section=url + Specifies a URL to use for the given man page. When the {{man name + section}} expression is used, the given URL will be inserted as a + link. This may be specified multiple times. If a man page reference + does not have a matching --man entry, then a relative link to a file + named name.md will be used. + + sources... + The source input filename, may be specified multiple times. + +EXAMPLES + 1. Convert the given documents to man pages: + + mdman -t man -o doc doc/mdman.md + diff --git a/src/tools/cargo/crates/mdman/src/format.rs b/src/tools/cargo/crates/mdman/src/format.rs new file mode 100644 index 000000000..7bc9781b9 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/format.rs @@ -0,0 +1,20 @@ +use anyhow::Error; + +pub mod man; +pub mod md; +pub mod text; + +pub trait Formatter { + /// Renders the given markdown to the formatter's output. + fn render(&self, input: &str) -> Result<String, Error>; + /// Renders the start of a block of options (triggered by `{{#options}}`). + fn render_options_start(&self) -> &'static str; + /// Renders the end of a block of options (triggered by `{{/options}}`). + fn render_options_end(&self) -> &'static str; + /// Renders an option (triggered by `{{#option}}`). + fn render_option(&self, params: &[&str], block: &str, man_name: &str) -> Result<String, Error>; + /// Converts a man page reference into markdown that is appropriate for this format. + /// + /// Triggered by `{{man name section}}`. + fn linkify_man_to_md(&self, name: &str, section: u8) -> Result<String, Error>; +} diff --git a/src/tools/cargo/crates/mdman/src/format/man.rs b/src/tools/cargo/crates/mdman/src/format/man.rs new file mode 100644 index 000000000..9767fdd51 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/format/man.rs @@ -0,0 +1,436 @@ +//! Man-page formatter. + +use crate::util::{header_text, parse_name_and_section}; +use crate::EventIter; +use anyhow::{bail, Error}; +use pulldown_cmark::{Alignment, Event, HeadingLevel, LinkType, Tag}; +use std::fmt::Write; +use url::Url; + +pub struct ManFormatter { + url: Option<Url>, +} + +impl ManFormatter { + pub fn new(url: Option<Url>) -> ManFormatter { + ManFormatter { url } + } +} + +impl super::Formatter for ManFormatter { + fn render(&self, input: &str) -> Result<String, Error> { + ManRenderer::render(input, self.url.clone()) + } + + fn render_options_start(&self) -> &'static str { + // Tell pulldown_cmark to ignore this. + // This will be stripped out later. + "<![CDATA[" + } + + fn render_options_end(&self) -> &'static str { + "]]>" + } + + fn render_option( + &self, + params: &[&str], + block: &str, + _man_name: &str, + ) -> Result<String, Error> { + let rendered_options = params + .iter() + .map(|param| { + let r = self.render(param)?; + Ok(r.trim().trim_start_matches(".sp").to_string()) + }) + .collect::<Result<Vec<_>, Error>>()?; + let rendered_block = self.render(block)?; + let rendered_block = rendered_block.trim().trim_start_matches(".sp").trim(); + // .RS = move left margin to right 4. + // .RE = move margin back one level. + Ok(format!( + "\n.sp\n{}\n.RS 4\n{}\n.RE\n", + rendered_options.join(", "), + rendered_block + )) + } + + fn linkify_man_to_md(&self, name: &str, section: u8) -> Result<String, Error> { + Ok(format!("`{}`({})", name, section)) + } +} + +#[derive(Copy, Clone)] +enum Font { + Bold, + Italic, +} + +impl Font { + fn str_from_stack(font_stack: &[Font]) -> &'static str { + let has_bold = font_stack.iter().any(|font| matches!(font, Font::Bold)); + let has_italic = font_stack.iter().any(|font| matches!(font, Font::Italic)); + match (has_bold, has_italic) { + (false, false) => "\\fR", // roman (normal) + (false, true) => "\\fI", // italic + (true, false) => "\\fB", // bold + (true, true) => "\\f(BI", // bold italic + } + } +} + +struct ManRenderer<'e> { + output: String, + parser: EventIter<'e>, + font_stack: Vec<Font>, +} + +impl<'e> ManRenderer<'e> { + fn render(input: &str, url: Option<Url>) -> Result<String, Error> { + let parser = crate::md_parser(input, url); + let output = String::with_capacity(input.len() * 3 / 2); + let mut mr = ManRenderer { + parser, + output, + font_stack: Vec::new(), + }; + mr.push_man()?; + Ok(mr.output) + } + + fn push_man(&mut self) -> Result<(), Error> { + // If this is true, this is inside a cdata block used for hiding + // content from pulldown_cmark. + let mut in_cdata = false; + // The current list stack. None if unordered, Some if ordered with the + // given number as the current index. + let mut list: Vec<Option<u64>> = Vec::new(); + // Used in some cases where spacing isn't desired. + let mut suppress_paragraph = false; + let mut table_cell_index = 0; + + while let Some((event, range)) = self.parser.next() { + let this_suppress_paragraph = suppress_paragraph; + suppress_paragraph = false; + match event { + Event::Start(tag) => { + match tag { + Tag::Paragraph => { + if !this_suppress_paragraph { + self.flush(); + self.output.push_str(".sp\n"); + } + } + Tag::Heading(level, ..) => { + if level == HeadingLevel::H1 { + self.push_top_header()?; + } else if level == HeadingLevel::H2 { + // Section header + let text = header_text(&mut self.parser)?; + self.flush(); + write!(self.output, ".SH \"{}\"\n", text)?; + suppress_paragraph = true; + } else { + // Subsection header + let text = header_text(&mut self.parser)?; + self.flush(); + write!(self.output, ".SS \"{}\"\n", text)?; + suppress_paragraph = true; + } + } + Tag::BlockQuote => { + self.flush(); + // .RS = move left margin over 3 + // .ll = shrink line length + self.output.push_str(".RS 3\n.ll -5\n.sp\n"); + suppress_paragraph = true; + } + Tag::CodeBlock(_kind) => { + // space down, indent 4, no-fill mode + self.flush(); + self.output.push_str(".sp\n.RS 4\n.nf\n"); + } + Tag::List(start) => list.push(start), + Tag::Item => { + // Note: This uses explicit movement instead of .IP + // because the spacing on .IP looks weird to me. + // space down, indent 4 + self.flush(); + self.output.push_str(".sp\n.RS 4\n"); + match list.last_mut().expect("item must have list start") { + // Ordered list. + Some(n) => { + // move left 4, output the list index number, move right 1. + write!(self.output, "\\h'-04' {}.\\h'+01'", n)?; + *n += 1; + } + // Unordered list. + None => self.output.push_str("\\h'-04'\\(bu\\h'+02'"), + } + suppress_paragraph = true; + } + Tag::FootnoteDefinition(_label) => unimplemented!(), + Tag::Table(alignment) => { + // Table start + // allbox = draw a box around all the cells + // tab(:) = Use `:` to separate cell data (instead of tab) + // ; = end of options + self.output.push_str( + "\n.TS\n\ + allbox tab(:);\n", + ); + let alignments: Vec<_> = alignment + .iter() + .map(|a| match a { + Alignment::Left | Alignment::None => "lt", + Alignment::Center => "ct", + Alignment::Right => "rt", + }) + .collect(); + self.output.push_str(&alignments.join(" ")); + self.output.push_str(".\n"); + table_cell_index = 0; + } + Tag::TableHead => { + table_cell_index = 0; + } + Tag::TableRow => { + table_cell_index = 0; + self.output.push('\n'); + } + Tag::TableCell => { + if table_cell_index != 0 { + // Separator between columns. + self.output.push(':'); + } + // Start a text block. + self.output.push_str("T{\n"); + table_cell_index += 1 + } + Tag::Emphasis => self.push_font(Font::Italic), + Tag::Strong => self.push_font(Font::Bold), + // Strikethrough isn't usually supported for TTY. + Tag::Strikethrough => self.output.push_str("~~"), + Tag::Link(link_type, dest_url, _title) => { + if dest_url.starts_with('#') { + // In a man page, page-relative anchors don't + // have much meaning. + continue; + } + match link_type { + LinkType::Autolink | LinkType::Email => { + // The text is a copy of the URL, which is not needed. + match self.parser.next() { + Some((Event::Text(_), _range)) => {} + _ => bail!("expected text after autolink"), + } + } + LinkType::Inline + | LinkType::Reference + | LinkType::Collapsed + | LinkType::Shortcut => { + self.push_font(Font::Italic); + } + // This is currently unused. This is only + // emitted with a broken link callback, but I + // felt it is too annoying to escape `[` in + // option descriptions. + LinkType::ReferenceUnknown + | LinkType::CollapsedUnknown + | LinkType::ShortcutUnknown => { + bail!( + "link with missing reference `{}` located at offset {}", + dest_url, + range.start + ); + } + } + } + Tag::Image(_link_type, _dest_url, _title) => { + bail!("images are not currently supported") + } + } + } + Event::End(tag) => { + match &tag { + Tag::Paragraph => self.flush(), + Tag::Heading(..) => {} + Tag::BlockQuote => { + self.flush(); + // restore left margin, restore line length + self.output.push_str(".br\n.RE\n.ll\n"); + } + Tag::CodeBlock(_kind) => { + self.flush(); + // Restore fill mode, move margin back one level. + self.output.push_str(".fi\n.RE\n"); + } + Tag::List(_) => { + list.pop(); + } + Tag::Item => { + self.flush(); + // Move margin back one level. + self.output.push_str(".RE\n"); + } + Tag::FootnoteDefinition(_label) => {} + Tag::Table(_) => { + // Table end + // I don't know why, but the .sp is needed to provide + // space with the following content. + self.output.push_str("\n.TE\n.sp\n"); + } + Tag::TableHead => {} + Tag::TableRow => {} + Tag::TableCell => { + // End text block. + self.output.push_str("\nT}"); + } + Tag::Emphasis | Tag::Strong => self.pop_font(), + Tag::Strikethrough => self.output.push_str("~~"), + Tag::Link(link_type, dest_url, _title) => { + if dest_url.starts_with('#') { + continue; + } + match link_type { + LinkType::Autolink | LinkType::Email => {} + LinkType::Inline + | LinkType::Reference + | LinkType::Collapsed + | LinkType::Shortcut => { + self.pop_font(); + self.output.push(' '); + } + _ => { + panic!("unexpected tag {:?}", tag); + } + } + write!(self.output, "<{}>", escape(&dest_url)?)?; + } + Tag::Image(_link_type, _dest_url, _title) => {} + } + } + Event::Text(t) => { + self.output.push_str(&escape(&t)?); + } + Event::Code(t) => { + self.push_font(Font::Bold); + self.output.push_str(&escape(&t)?); + self.pop_font(); + } + Event::Html(t) => { + if t.starts_with("<![CDATA[") { + // CDATA is a special marker used for handling options. + in_cdata = true; + } else if in_cdata { + if t.trim().ends_with("]]>") { + in_cdata = false; + } else if !t.trim().is_empty() { + self.output.push_str(&t); + } + } else { + self.output.push_str(&escape(&t)?); + } + } + Event::FootnoteReference(_t) => {} + Event::SoftBreak => self.output.push('\n'), + Event::HardBreak => { + self.flush(); + self.output.push_str(".br\n"); + } + Event::Rule => { + self.flush(); + // \l' **length** ' Draw horizontal line (default underscore). + // \n(.lu Gets value from register "lu" (current line length) + self.output.push_str("\\l'\\n(.lu'\n"); + } + Event::TaskListMarker(_b) => unimplemented!(), + } + } + Ok(()) + } + + fn flush(&mut self) { + if !self.output.ends_with('\n') { + self.output.push('\n'); + } + } + + /// Switch to the given font. + /// + /// Because the troff sequence `\fP` for switching to the "previous" font + /// doesn't support nesting, this needs to emulate it here. This is needed + /// for situations like **hi _there_**. + fn push_font(&mut self, font: Font) { + self.font_stack.push(font); + self.output.push_str(Font::str_from_stack(&self.font_stack)); + } + + fn pop_font(&mut self) { + self.font_stack.pop(); + self.output.push_str(Font::str_from_stack(&self.font_stack)); + } + + /// Parse and render the first top-level header of the document. + fn push_top_header(&mut self) -> Result<(), Error> { + // This enables the tbl preprocessor for tables. + // This seems to be enabled by default on every modern system I could + // find, but it doesn't seem to hurt to enable this. + self.output.push_str("'\\\" t\n"); + // Extract the name of the man page. + let text = header_text(&mut self.parser)?; + let (name, section) = parse_name_and_section(&text)?; + // .TH = Table header + // .nh = disable hyphenation + // .ad l = Left-adjust mode (disable justified). + // .ss sets sentence_space_size to 0 (prevents double spaces after . + // if . is last on the line) + write!( + self.output, + ".TH \"{}\" \"{}\"\n\ + .nh\n\ + .ad l\n\ + .ss \\n[.ss] 0\n", + escape(&name.to_uppercase())?, + section + )?; + Ok(()) + } +} + +fn escape(s: &str) -> Result<String, Error> { + // Note: Possible source on output escape sequences: https://man7.org/linux/man-pages/man7/groff_char.7.html. + // Otherwise, use generic escaping in the form `\[u1EE7]` or `\[u1F994]`. + + let mut replaced = s + .replace('\\', "\\(rs") + .replace('-', "\\-") + .replace('\u{00A0}', "\\ ") // non-breaking space (non-stretchable) + .replace('–', "\\[en]") // \u{2013} en-dash + .replace('—', "\\[em]") // \u{2014} em-dash + .replace('‘', "\\[oq]") // \u{2018} left single quote + .replace('’', "\\[cq]") // \u{2019} right single quote or apostrophe + .replace('“', "\\[lq]") // \u{201C} left double quote + .replace('”', "\\[rq]") // \u{201D} right double quote + .replace('…', "\\[u2026]") // \u{2026} ellipsis + .replace('│', "|") // \u{2502} box drawing light vertical (could use \[br]) + .replace('├', "|") // \u{251C} box drawings light vertical and right + .replace('└', "`") // \u{2514} box drawings light up and right + .replace('─', "\\-") // \u{2500} box drawing light horizontal + ; + if replaced.starts_with('.') { + replaced = format!("\\&.{}", &replaced[1..]); + } + + if let Some(ch) = replaced.chars().find(|ch| { + !matches!(ch, '\n' | ' ' | '!'..='/' | '0'..='9' + | ':'..='@' | 'A'..='Z' | '['..='`' | 'a'..='z' | '{'..='~') + }) { + bail!( + "character {:?} is not allowed (update the translation table if needed)", + ch + ); + } + Ok(replaced) +} diff --git a/src/tools/cargo/crates/mdman/src/format/md.rs b/src/tools/cargo/crates/mdman/src/format/md.rs new file mode 100644 index 000000000..0e1c49837 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/format/md.rs @@ -0,0 +1,112 @@ +//! Markdown formatter. + +use crate::util::unwrap; +use crate::ManMap; +use anyhow::{bail, format_err, Error}; +use std::fmt::Write; + +pub struct MdFormatter { + man_map: ManMap, +} + +impl MdFormatter { + pub fn new(man_map: ManMap) -> MdFormatter { + MdFormatter { man_map } + } +} + +impl MdFormatter { + fn render_html(&self, input: &str) -> Result<String, Error> { + let parser = crate::md_parser(input, None); + let mut html_output: String = String::with_capacity(input.len() * 3 / 2); + pulldown_cmark::html::push_html(&mut html_output, parser.map(|(e, _r)| e)); + Ok(html_output) + } +} + +impl super::Formatter for MdFormatter { + fn render(&self, input: &str) -> Result<String, Error> { + Ok(input.replace("\r\n", "\n")) + } + + fn render_options_start(&self) -> &'static str { + "<dl>" + } + + fn render_options_end(&self) -> &'static str { + "</dl>" + } + + fn render_option(&self, params: &[&str], block: &str, man_name: &str) -> Result<String, Error> { + let mut result = String::new(); + fn unwrap_p(t: &str) -> &str { + unwrap(t, "<p>", "</p>") + } + + for param in params { + let rendered = self.render_html(param)?; + let no_p = unwrap_p(&rendered); + // split out first term to use as the id. + let first = no_p + .split_whitespace() + .next() + .ok_or_else(|| format_err!("did not expect option `{}` to be empty", param))?; + let no_tags = trim_tags(first); + if no_tags.is_empty() { + bail!("unexpected empty option with no tags `{}`", param); + } + let id = format!("option-{}-{}", man_name, no_tags); + write!( + result, + "<dt class=\"option-term\" id=\"{ID}\">\ + <a class=\"option-anchor\" href=\"#{ID}\"></a>{OPTION}</dt>\n", + ID = id, + OPTION = no_p + )?; + } + let rendered_block = self.render_html(block)?; + write!( + result, + "<dd class=\"option-desc\">{}</dd>\n", + unwrap_p(&rendered_block) + )?; + Ok(result) + } + + fn linkify_man_to_md(&self, name: &str, section: u8) -> Result<String, Error> { + let s = match self.man_map.get(&(name.to_string(), section)) { + Some(link) => format!("[{}({})]({})", name, section, link), + None => format!("[{}({})]({}.html)", name, section, name), + }; + Ok(s) + } +} + +fn trim_tags(s: &str) -> String { + // This is a hack. It removes all HTML tags. + let mut in_tag = false; + let mut in_char_ref = false; + s.chars() + .filter(|&ch| match ch { + '<' if in_tag => panic!("unexpected nested tag"), + '&' if in_char_ref => panic!("unexpected nested char ref"), + '<' => { + in_tag = true; + false + } + '&' => { + in_char_ref = true; + false + } + '>' if in_tag => { + in_tag = false; + false + } + ';' if in_char_ref => { + in_char_ref = false; + false + } + _ => !in_tag && !in_char_ref, + }) + .collect() +} diff --git a/src/tools/cargo/crates/mdman/src/format/text.rs b/src/tools/cargo/crates/mdman/src/format/text.rs new file mode 100644 index 000000000..ae07985a6 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/format/text.rs @@ -0,0 +1,605 @@ +//! Text formatter. + +use crate::util::{header_text, unwrap}; +use crate::EventIter; +use anyhow::{bail, Error}; +use pulldown_cmark::{Alignment, Event, HeadingLevel, LinkType, Tag}; +use std::fmt::Write; +use std::mem; +use url::Url; + +pub struct TextFormatter { + url: Option<Url>, +} + +impl TextFormatter { + pub fn new(url: Option<Url>) -> TextFormatter { + TextFormatter { url } + } +} + +impl super::Formatter for TextFormatter { + fn render(&self, input: &str) -> Result<String, Error> { + TextRenderer::render(input, self.url.clone(), 0) + } + + fn render_options_start(&self) -> &'static str { + // Tell pulldown_cmark to ignore this. + // This will be stripped out later. + "<![CDATA[" + } + + fn render_options_end(&self) -> &'static str { + "]]>" + } + + fn render_option( + &self, + params: &[&str], + block: &str, + _man_name: &str, + ) -> Result<String, Error> { + let rendered_options = params + .iter() + .map(|param| TextRenderer::render(param, self.url.clone(), 0)) + .collect::<Result<Vec<_>, Error>>()?; + let trimmed: Vec<_> = rendered_options.iter().map(|o| o.trim()).collect(); + // Wrap in HTML tags, they will be stripped out during rendering. + Ok(format!( + "<dt>{}</dt>\n<dd>{}</dd>\n<br>\n", + trimmed.join(", "), + block + )) + } + + fn linkify_man_to_md(&self, name: &str, section: u8) -> Result<String, Error> { + Ok(format!("`{}`({})", name, section)) + } +} + +struct TextRenderer<'e> { + output: String, + indent: usize, + /// The current line being written. Once a line break is encountered (such + /// as starting a new paragraph), this will be written to `output` via + /// `flush`. + line: String, + /// The current word being written. Once a break is encountered (such as a + /// space) this will be written to `line` via `flush_word`. + word: String, + parser: EventIter<'e>, + /// The base URL used for relative URLs. + url: Option<Url>, + table: Table, +} + +impl<'e> TextRenderer<'e> { + fn render(input: &str, url: Option<Url>, indent: usize) -> Result<String, Error> { + let parser = crate::md_parser(input, url.clone()); + let output = String::with_capacity(input.len() * 3 / 2); + let mut mr = TextRenderer { + output, + indent, + line: String::new(), + word: String::new(), + parser, + url, + table: Table::new(), + }; + mr.push_md()?; + Ok(mr.output) + } + + fn push_md(&mut self) -> Result<(), Error> { + // If this is true, this is inside a cdata block used for hiding + // content from pulldown_cmark. + let mut in_cdata = false; + // The current list stack. None if unordered, Some if ordered with the + // given number as the current index. + let mut list: Vec<Option<u64>> = Vec::new(); + // Used in some cases where spacing isn't desired. + let mut suppress_paragraph = false; + // Whether or not word-wrapping is enabled. + let mut wrap_text = true; + + while let Some((event, range)) = self.parser.next() { + let this_suppress_paragraph = suppress_paragraph; + // Always reset suppression, even if the next event isn't a + // paragraph. This is in essence, a 1-token lookahead where the + // suppression is only enabled if the next event is a paragraph. + suppress_paragraph = false; + match event { + Event::Start(tag) => { + match tag { + Tag::Paragraph => { + if !this_suppress_paragraph { + self.flush(); + } + } + Tag::Heading(level, ..) => { + self.flush(); + if level == HeadingLevel::H1 { + let text = header_text(&mut self.parser)?; + self.push_to_line(&text.to_uppercase()); + self.hard_break(); + self.hard_break(); + } else if level == HeadingLevel::H2 { + let text = header_text(&mut self.parser)?; + self.push_to_line(&text.to_uppercase()); + self.flush(); + self.indent = 7; + } else { + let text = header_text(&mut self.parser)?; + self.push_indent((level as usize - 2) * 3); + self.push_to_line(&text); + self.flush(); + self.indent = (level as usize - 1) * 3 + 1; + } + } + Tag::BlockQuote => { + self.indent += 3; + } + Tag::CodeBlock(_kind) => { + self.flush(); + wrap_text = false; + self.indent += 4; + } + Tag::List(start) => list.push(start), + Tag::Item => { + self.flush(); + match list.last_mut().expect("item must have list start") { + // Ordered list. + Some(n) => { + self.push_indent(self.indent); + write!(self.line, "{}.", n)?; + *n += 1; + } + // Unordered list. + None => { + self.push_indent(self.indent); + self.push_to_line("o ") + } + } + self.indent += 3; + suppress_paragraph = true; + } + Tag::FootnoteDefinition(_label) => unimplemented!(), + Tag::Table(alignment) => { + assert!(self.table.alignment.is_empty()); + self.flush(); + self.table.alignment.extend(alignment); + let table = self.table.process(&mut self.parser, self.indent)?; + self.output.push_str(&table); + self.hard_break(); + self.table = Table::new(); + } + Tag::TableHead | Tag::TableRow | Tag::TableCell => { + bail!("unexpected table element") + } + Tag::Emphasis => {} + Tag::Strong => {} + // Strikethrough isn't usually supported for TTY. + Tag::Strikethrough => self.word.push_str("~~"), + Tag::Link(link_type, dest_url, _title) => { + if dest_url.starts_with('#') { + // In a man page, page-relative anchors don't + // have much meaning. + continue; + } + match link_type { + LinkType::Autolink | LinkType::Email => { + // The text is a copy of the URL, which is not needed. + match self.parser.next() { + Some((Event::Text(_), _range)) => {} + _ => bail!("expected text after autolink"), + } + } + LinkType::Inline + | LinkType::Reference + | LinkType::Collapsed + | LinkType::Shortcut => {} + // This is currently unused. This is only + // emitted with a broken link callback, but I + // felt it is too annoying to escape `[` in + // option descriptions. + LinkType::ReferenceUnknown + | LinkType::CollapsedUnknown + | LinkType::ShortcutUnknown => { + bail!( + "link with missing reference `{}` located at offset {}", + dest_url, + range.start + ); + } + } + } + Tag::Image(_link_type, _dest_url, _title) => { + bail!("images are not currently supported") + } + } + } + Event::End(tag) => match &tag { + Tag::Paragraph => { + self.flush(); + self.hard_break(); + } + Tag::Heading(..) => {} + Tag::BlockQuote => { + self.indent -= 3; + } + Tag::CodeBlock(_kind) => { + self.hard_break(); + wrap_text = true; + self.indent -= 4; + } + Tag::List(_) => { + list.pop(); + } + Tag::Item => { + self.flush(); + self.indent -= 3; + self.hard_break(); + } + Tag::FootnoteDefinition(_label) => {} + Tag::Table(_) => {} + Tag::TableHead => {} + Tag::TableRow => {} + Tag::TableCell => {} + Tag::Emphasis => {} + Tag::Strong => {} + Tag::Strikethrough => self.word.push_str("~~"), + Tag::Link(link_type, dest_url, _title) => { + if dest_url.starts_with('#') { + continue; + } + match link_type { + LinkType::Autolink | LinkType::Email => {} + LinkType::Inline + | LinkType::Reference + | LinkType::Collapsed + | LinkType::Shortcut => self.flush_word(), + _ => { + panic!("unexpected tag {:?}", tag); + } + } + self.flush_word(); + write!(self.word, "<{}>", dest_url)?; + } + Tag::Image(_link_type, _dest_url, _title) => {} + }, + Event::Text(t) | Event::Code(t) => { + if wrap_text { + let chunks = split_chunks(&t); + for chunk in chunks { + if chunk == " " { + self.flush_word(); + } else { + self.word.push_str(chunk); + } + } + } else { + for line in t.lines() { + self.push_indent(self.indent); + self.push_to_line(line); + self.flush(); + } + } + } + Event::Html(t) => { + if t.starts_with("<![CDATA[") { + // CDATA is a special marker used for handling options. + in_cdata = true; + self.flush(); + } else if in_cdata { + if t.trim().ends_with("]]>") { + in_cdata = false; + } else { + let trimmed = t.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed == "<br>" { + self.hard_break(); + } else if trimmed.starts_with("<dt>") { + let opts = unwrap(trimmed, "<dt>", "</dt>"); + self.push_indent(self.indent); + self.push_to_line(opts); + self.flush(); + } else if trimmed.starts_with("<dd>") { + let mut def = String::new(); + while let Some((Event::Html(t), _range)) = self.parser.next() { + if t.starts_with("</dd>") { + break; + } + def.push_str(&t); + } + let rendered = + TextRenderer::render(&def, self.url.clone(), self.indent + 4)?; + self.push_to_line(rendered.trim_end()); + self.flush(); + } else { + self.push_to_line(&t); + self.flush(); + } + } + } else { + self.push_to_line(&t); + self.flush(); + } + } + Event::FootnoteReference(_t) => {} + Event::SoftBreak => self.flush_word(), + Event::HardBreak => self.flush(), + Event::Rule => { + self.flush(); + self.push_indent(self.indent); + self.push_to_line(&"_".repeat(79 - self.indent * 2)); + self.flush(); + } + Event::TaskListMarker(_b) => unimplemented!(), + } + } + Ok(()) + } + + fn flush(&mut self) { + self.flush_word(); + if !self.line.is_empty() { + self.output.push_str(&self.line); + self.output.push('\n'); + self.line.clear(); + } + } + + fn hard_break(&mut self) { + self.flush(); + if !self.output.ends_with("\n\n") { + self.output.push('\n'); + } + } + + fn flush_word(&mut self) { + if self.word.is_empty() { + return; + } + if self.line.len() + self.word.len() >= 79 { + self.output.push_str(&self.line); + self.output.push('\n'); + self.line.clear(); + } + if self.line.is_empty() { + self.push_indent(self.indent); + self.line.push_str(&self.word); + } else { + self.line.push(' '); + self.line.push_str(&self.word); + } + self.word.clear(); + } + + fn push_indent(&mut self, indent: usize) { + for _ in 0..indent { + self.line.push(' '); + } + } + + fn push_to_line(&mut self, text: &str) { + self.flush_word(); + self.line.push_str(text); + } +} + +/// Splits the text on whitespace. +/// +/// Consecutive whitespace is collapsed to a single ' ', and is included as a +/// separate element in the result. +fn split_chunks(text: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut start = 0; + while start < text.len() { + match text[start..].find(' ') { + Some(i) => { + if i != 0 { + result.push(&text[start..start + i]); + } + result.push(" "); + // Skip past whitespace. + match text[start + i..].find(|c| c != ' ') { + Some(n) => { + start = start + i + n; + } + None => { + break; + } + } + } + None => { + result.push(&text[start..]); + break; + } + } + } + result +} + +struct Table { + alignment: Vec<Alignment>, + rows: Vec<Vec<String>>, + row: Vec<String>, + cell: String, +} + +impl Table { + fn new() -> Table { + Table { + alignment: Vec::new(), + rows: Vec::new(), + row: Vec::new(), + cell: String::new(), + } + } + + /// Processes table events and generates a text table. + fn process(&mut self, parser: &mut EventIter<'_>, indent: usize) -> Result<String, Error> { + while let Some((event, _range)) = parser.next() { + match event { + Event::Start(tag) => match tag { + Tag::TableHead + | Tag::TableRow + | Tag::TableCell + | Tag::Emphasis + | Tag::Strong => {} + Tag::Strikethrough => self.cell.push_str("~~"), + // Links not yet supported, they usually won't fit. + Tag::Link(_, _, _) => {} + _ => bail!("unexpected tag in table: {:?}", tag), + }, + Event::End(tag) => match tag { + Tag::Table(_) => return self.render(indent), + Tag::TableCell => { + let cell = mem::replace(&mut self.cell, String::new()); + self.row.push(cell); + } + Tag::TableHead | Tag::TableRow => { + let row = mem::replace(&mut self.row, Vec::new()); + self.rows.push(row); + } + Tag::Strikethrough => self.cell.push_str("~~"), + _ => {} + }, + Event::Text(t) | Event::Code(t) => { + self.cell.push_str(&t); + } + Event::Html(t) => bail!("html unsupported in tables: {:?}", t), + _ => bail!("unexpected event in table: {:?}", event), + } + } + bail!("table end not reached"); + } + + fn render(&self, indent: usize) -> Result<String, Error> { + // This is an extremely primitive layout routine. + // First compute the potential maximum width of each cell. + // 2 for 1 space margin on left and right. + let width_acc = vec![2; self.alignment.len()]; + let mut col_widths = self + .rows + .iter() + .map(|row| row.iter().map(|cell| cell.len())) + .fold(width_acc, |mut acc, row| { + acc.iter_mut() + .zip(row) + // +3 for left/right margin and | symbol + .for_each(|(a, b)| *a = (*a).max(b + 3)); + acc + }); + // Shrink each column until it fits the total width, proportional to + // the columns total percent width. + let max_width = 78 - indent; + // Include total len for | characters, and +1 for final |. + let total_width = col_widths.iter().sum::<usize>() + col_widths.len() + 1; + if total_width > max_width { + let to_shrink = total_width - max_width; + // Compute percentage widths, and shrink each column based on its + // total percentage. + for width in &mut col_widths { + let percent = *width as f64 / total_width as f64; + *width -= (to_shrink as f64 * percent).ceil() as usize; + } + } + // Start rendering. + let mut result = String::new(); + + // Draw the horizontal line separating each row. + let mut row_line = String::new(); + row_line.push_str(&" ".repeat(indent)); + row_line.push('+'); + let lines = col_widths + .iter() + .map(|width| "-".repeat(*width)) + .collect::<Vec<_>>(); + row_line.push_str(&lines.join("+")); + row_line.push('+'); + row_line.push('\n'); + + // Draw top of the table. + result.push_str(&row_line); + // Draw each row. + for row in &self.rows { + // Word-wrap and fill each column as needed. + let filled = fill_row(row, &col_widths, &self.alignment); + // Need to transpose the cells across rows for cells that span + // multiple rows. + let height = filled.iter().map(|c| c.len()).max().unwrap(); + for row_i in 0..height { + result.push_str(&" ".repeat(indent)); + result.push('|'); + for filled_row in &filled { + let cell = &filled_row[row_i]; + result.push_str(cell); + result.push('|'); + } + result.push('\n'); + } + result.push_str(&row_line); + } + Ok(result) + } +} + +/// Formats a row, filling cells with spaces and word-wrapping text. +/// +/// Returns a vec of cells, where each cell is split into multiple lines. +fn fill_row(row: &[String], col_widths: &[usize], alignment: &[Alignment]) -> Vec<Vec<String>> { + let mut cell_lines = row + .iter() + .zip(col_widths) + .zip(alignment) + .map(|((cell, width), alignment)| fill_cell(cell, *width - 2, *alignment)) + .collect::<Vec<_>>(); + // Fill each cell to match the maximum vertical height of the tallest cell. + let max_lines = cell_lines.iter().map(|cell| cell.len()).max().unwrap(); + for (cell, width) in cell_lines.iter_mut().zip(col_widths) { + if cell.len() < max_lines { + cell.extend(std::iter::repeat(" ".repeat(*width)).take(max_lines - cell.len())); + } + } + cell_lines +} + +/// Formats a cell. Word-wraps based on width, and adjusts based on alignment. +/// +/// Returns a vec of lines for the cell. +fn fill_cell(text: &str, width: usize, alignment: Alignment) -> Vec<String> { + let fill_width = |text: &str| match alignment { + Alignment::None | Alignment::Left => format!(" {:<width$} ", text, width = width), + Alignment::Center => format!(" {:^width$} ", text, width = width), + Alignment::Right => format!(" {:>width$} ", text, width = width), + }; + if text.len() < width { + // No wrapping necessary, just format. + vec![fill_width(text)] + } else { + // Word-wrap the cell. + let mut result = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if line.len() + word.len() >= width { + // todo: word.len() > width + result.push(fill_width(&line)); + line.clear(); + } + if line.is_empty() { + line.push_str(word); + } else { + line.push(' '); + line.push_str(&word); + } + } + if !line.is_empty() { + result.push(fill_width(&line)); + } + + result + } +} diff --git a/src/tools/cargo/crates/mdman/src/hbs.rs b/src/tools/cargo/crates/mdman/src/hbs.rs new file mode 100644 index 000000000..81ad7ee45 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/hbs.rs @@ -0,0 +1,215 @@ +//! Handlebars template processing. + +use crate::format::Formatter; +use anyhow::Error; +use handlebars::{ + handlebars_helper, Context, Decorator, Handlebars, Helper, HelperDef, HelperResult, Output, + RenderContext, RenderError, Renderable, +}; +use std::collections::HashMap; +use std::path::Path; + +type FormatterRef<'a> = &'a (dyn Formatter + Send + Sync); + +/// Processes the handlebars template at the given file. +pub fn expand(file: &Path, formatter: FormatterRef) -> Result<String, Error> { + let mut handlebars = Handlebars::new(); + handlebars.set_strict_mode(true); + handlebars.register_helper("lower", Box::new(lower)); + handlebars.register_helper("options", Box::new(OptionsHelper { formatter })); + handlebars.register_helper("option", Box::new(OptionHelper { formatter })); + handlebars.register_helper("man", Box::new(ManLinkHelper { formatter })); + handlebars.register_decorator("set", Box::new(set_decorator)); + handlebars.register_template_file("template", file)?; + let includes = file.parent().unwrap().join("includes"); + handlebars.register_templates_directory(".md", includes)?; + let man_name = file + .file_stem() + .expect("expected filename") + .to_str() + .expect("utf8 filename") + .to_string(); + let data = HashMap::from([("man_name", man_name)]); + let expanded = handlebars.render("template", &data)?; + Ok(expanded) +} + +/// Helper for `{{#options}}` block. +struct OptionsHelper<'a> { + formatter: FormatterRef<'a>, +} + +impl HelperDef for OptionsHelper<'_> { + fn call<'reg: 'rc, 'rc>( + &self, + h: &Helper<'reg, 'rc>, + r: &'reg Handlebars<'reg>, + ctx: &'rc Context, + rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> HelperResult { + if in_options(rc) { + return Err(RenderError::new("options blocks cannot be nested")); + } + // Prevent nested {{#options}}. + set_in_context(rc, "__MDMAN_IN_OPTIONS", serde_json::Value::Bool(true)); + let s = self.formatter.render_options_start(); + out.write(&s)?; + let t = match h.template() { + Some(t) => t, + None => return Err(RenderError::new("options block must not be empty")), + }; + let block = t.renders(r, ctx, rc)?; + out.write(&block)?; + + let s = self.formatter.render_options_end(); + out.write(&s)?; + remove_from_context(rc, "__MDMAN_IN_OPTIONS"); + Ok(()) + } +} + +/// Whether or not the context is currently inside a `{{#options}}` block. +fn in_options(rc: &RenderContext<'_, '_>) -> bool { + rc.context() + .map_or(false, |ctx| ctx.data().get("__MDMAN_IN_OPTIONS").is_some()) +} + +/// Helper for `{{#option}}` block. +struct OptionHelper<'a> { + formatter: FormatterRef<'a>, +} + +impl HelperDef for OptionHelper<'_> { + fn call<'reg: 'rc, 'rc>( + &self, + h: &Helper<'reg, 'rc>, + r: &'reg Handlebars<'reg>, + ctx: &'rc Context, + rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> HelperResult { + if !in_options(rc) { + return Err(RenderError::new("option must be in options block")); + } + let params = h.params(); + if params.is_empty() { + return Err(RenderError::new( + "option block must have at least one param", + )); + } + // Convert params to strings. + let params = params + .iter() + .map(|param| { + param + .value() + .as_str() + .ok_or_else(|| RenderError::new("option params must be strings")) + }) + .collect::<Result<Vec<&str>, RenderError>>()?; + let t = match h.template() { + Some(t) => t, + None => return Err(RenderError::new("option block must not be empty")), + }; + // Render the block. + let block = t.renders(r, ctx, rc)?; + + // Get the name of this page. + let man_name = ctx + .data() + .get("man_name") + .expect("expected man_name in context") + .as_str() + .expect("expect man_name str"); + + // Ask the formatter to convert this option to its format. + let option = self + .formatter + .render_option(¶ms, &block, man_name) + .map_err(|e| RenderError::new(format!("option render failed: {}", e)))?; + out.write(&option)?; + Ok(()) + } +} + +/// Helper for `{{man name section}}` expression. +struct ManLinkHelper<'a> { + formatter: FormatterRef<'a>, +} + +impl HelperDef for ManLinkHelper<'_> { + fn call<'reg: 'rc, 'rc>( + &self, + h: &Helper<'reg, 'rc>, + _r: &'reg Handlebars<'reg>, + _ctx: &'rc Context, + _rc: &mut RenderContext<'reg, 'rc>, + out: &mut dyn Output, + ) -> HelperResult { + let params = h.params(); + if params.len() != 2 { + return Err(RenderError::new("{{man}} must have two arguments")); + } + let name = params[0] + .value() + .as_str() + .ok_or_else(|| RenderError::new("man link name must be a string"))?; + let section = params[1] + .value() + .as_u64() + .ok_or_else(|| RenderError::new("man link section must be an integer"))?; + let section = + u8::try_from(section).map_err(|_e| RenderError::new("section number too large"))?; + let link = self + .formatter + .linkify_man_to_md(name, section) + .map_err(|e| RenderError::new(format!("failed to linkify man: {}", e)))?; + out.write(&link)?; + Ok(()) + } +} + +/// `{{*set var=value}}` decorator. +/// +/// This sets a variable to a value within the template context. +fn set_decorator( + d: &Decorator, + _: &Handlebars, + _ctx: &Context, + rc: &mut RenderContext, +) -> Result<(), RenderError> { + let data_to_set = d.hash(); + for (k, v) in data_to_set { + set_in_context(rc, k, v.value().clone()); + } + Ok(()) +} + +/// Sets a variable to a value within the context. +fn set_in_context(rc: &mut RenderContext, key: &str, value: serde_json::Value) { + let mut ctx = match rc.context() { + Some(c) => (*c).clone(), + None => Context::wraps(serde_json::Value::Object(serde_json::Map::new())).unwrap(), + }; + if let serde_json::Value::Object(m) = ctx.data_mut() { + m.insert(key.to_string(), value); + rc.set_context(ctx); + } else { + panic!("expected object in context"); + } +} + +/// Removes a variable from the context. +fn remove_from_context(rc: &mut RenderContext, key: &str) { + let ctx = rc.context().expect("cannot remove from null context"); + let mut ctx = (*ctx).clone(); + if let serde_json::Value::Object(m) = ctx.data_mut() { + m.remove(key); + rc.set_context(ctx); + } else { + panic!("expected object in context"); + } +} + +handlebars_helper!(lower: |s: str| s.to_lowercase()); diff --git a/src/tools/cargo/crates/mdman/src/lib.rs b/src/tools/cargo/crates/mdman/src/lib.rs new file mode 100644 index 000000000..01c3c8d31 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/lib.rs @@ -0,0 +1,122 @@ +//! mdman markdown to man converter. + +use anyhow::{bail, Context, Error}; +use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag}; +use std::collections::HashMap; +use std::fs; +use std::io::{self, BufRead}; +use std::ops::Range; +use std::path::Path; +use url::Url; + +mod format; +mod hbs; +mod util; + +use format::Formatter; + +/// Mapping of `(name, section)` of a man page to a URL. +pub type ManMap = HashMap<(String, u8), String>; + +/// A man section. +pub type Section = u8; + +/// The output formats supported by mdman. +#[derive(Copy, Clone)] +pub enum Format { + Man, + Md, + Text, +} + +impl Format { + /// The filename extension for the format. + pub fn extension(&self, section: Section) -> String { + match self { + Format::Man => section.to_string(), + Format::Md => "md".to_string(), + Format::Text => "txt".to_string(), + } + } +} + +/// Converts the handlebars markdown file at the given path into the given +/// format, returning the translated result. +pub fn convert( + file: &Path, + format: Format, + url: Option<Url>, + man_map: ManMap, +) -> Result<String, Error> { + let formatter: Box<dyn Formatter + Send + Sync> = match format { + Format::Man => Box::new(format::man::ManFormatter::new(url)), + Format::Md => Box::new(format::md::MdFormatter::new(man_map)), + Format::Text => Box::new(format::text::TextFormatter::new(url)), + }; + let expanded = hbs::expand(file, &*formatter)?; + // pulldown-cmark can behave a little differently with Windows newlines, + // just normalize it. + let expanded = expanded.replace("\r\n", "\n"); + formatter.render(&expanded) +} + +/// Pulldown-cmark iterator yielding an `(event, range)` tuple. +type EventIter<'a> = Box<dyn Iterator<Item = (Event<'a>, Range<usize>)> + 'a>; + +/// Creates a new markdown parser with the given input. +pub(crate) fn md_parser(input: &str, url: Option<Url>) -> EventIter { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_SMART_PUNCTUATION); + let parser = Parser::new_ext(input, options); + let parser = parser.into_offset_iter(); + // Translate all links to include the base url. + let parser = parser.map(move |(event, range)| match event { + Event::Start(Tag::Link(lt, dest_url, title)) if !matches!(lt, LinkType::Email) => ( + Event::Start(Tag::Link(lt, join_url(url.as_ref(), dest_url), title)), + range, + ), + Event::End(Tag::Link(lt, dest_url, title)) if !matches!(lt, LinkType::Email) => ( + Event::End(Tag::Link(lt, join_url(url.as_ref(), dest_url), title)), + range, + ), + _ => (event, range), + }); + Box::new(parser) +} + +fn join_url<'a>(base: Option<&Url>, dest: CowStr<'a>) -> CowStr<'a> { + match base { + Some(base_url) => { + // Absolute URL or page-relative anchor doesn't need to be translated. + if dest.contains(':') || dest.starts_with('#') { + dest + } else { + let joined = base_url.join(&dest).unwrap_or_else(|e| { + panic!("failed to join URL `{}` to `{}`: {}", dest, base_url, e) + }); + String::from(joined).into() + } + } + None => dest, + } +} + +pub fn extract_section(file: &Path) -> Result<Section, Error> { + let f = fs::File::open(file).with_context(|| format!("could not open `{}`", file.display()))?; + let mut f = io::BufReader::new(f); + let mut line = String::new(); + f.read_line(&mut line)?; + if !line.starts_with("# ") { + bail!("expected input file to start with # header"); + } + let (_name, section) = util::parse_name_and_section(&line[2..].trim()).with_context(|| { + format!( + "expected input file to have header with the format `# command-name(1)`, found: `{}`", + line + ) + })?; + Ok(section) +} diff --git a/src/tools/cargo/crates/mdman/src/main.rs b/src/tools/cargo/crates/mdman/src/main.rs new file mode 100644 index 000000000..2bdf96d72 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/main.rs @@ -0,0 +1,133 @@ +use anyhow::{bail, format_err, Context, Error}; +use mdman::{Format, ManMap}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use url::Url; + +/// Command-line options. +struct Options { + format: Format, + output_dir: PathBuf, + sources: Vec<PathBuf>, + url: Option<Url>, + man_map: ManMap, +} + +fn main() { + if let Err(e) = run() { + eprintln!("error: {}", e); + for cause in e.chain().skip(1) { + eprintln!("\nCaused by:"); + for line in cause.to_string().lines() { + if line.is_empty() { + eprintln!(); + } else { + eprintln!(" {}", line); + } + } + } + std::process::exit(1); + } +} + +fn run() -> Result<(), Error> { + let opts = process_args()?; + if !opts.output_dir.exists() { + std::fs::create_dir_all(&opts.output_dir).with_context(|| { + format!( + "failed to create output directory {}", + opts.output_dir.display() + ) + })?; + } + for source in &opts.sources { + let section = mdman::extract_section(source)?; + let filename = + Path::new(source.file_name().unwrap()).with_extension(opts.format.extension(section)); + let out_path = opts.output_dir.join(filename); + if same_file::is_same_file(source, &out_path).unwrap_or(false) { + bail!("cannot output to the same file as the source"); + } + println!("Converting {} -> {}", source.display(), out_path.display()); + let result = mdman::convert(&source, opts.format, opts.url.clone(), opts.man_map.clone()) + .with_context(|| format!("failed to translate {}", source.display()))?; + + std::fs::write(out_path, result)?; + } + Ok(()) +} + +fn process_args() -> Result<Options, Error> { + let mut format = None; + let mut output = None; + let mut url = None; + let mut man_map: ManMap = HashMap::new(); + let mut sources = Vec::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "-t" => { + format = match args.next().as_deref() { + Some("man") => Some(Format::Man), + Some("md") => Some(Format::Md), + Some("txt") => Some(Format::Text), + Some(s) => bail!("unknown output format: {}", s), + None => bail!("-t requires a value (man, md, txt)"), + }; + } + "-o" => { + output = match args.next() { + Some(s) => Some(PathBuf::from(s)), + None => bail!("-o requires a value"), + }; + } + "--url" => { + url = match args.next() { + Some(s) => { + let url = Url::parse(&s) + .with_context(|| format!("could not convert `{}` to a url", s))?; + if !url.path().ends_with('/') { + bail!("url `{}` should end with a /", url); + } + Some(url) + } + None => bail!("--url requires a value"), + } + } + "--man" => { + let man = args + .next() + .ok_or_else(|| format_err!("--man requires a value"))?; + let parts: Vec<_> = man.splitn(2, '=').collect(); + let key_parts: Vec<_> = parts[0].splitn(2, ':').collect(); + if parts.len() != 2 || key_parts.len() != 2 { + bail!("--man expected value with form name:1=link"); + } + let section: u8 = key_parts[1].parse().with_context(|| { + format!("expected unsigned integer for section, got `{}`", parts[1]) + })?; + man_map.insert((key_parts[0].to_string(), section), parts[1].to_string()); + } + s => { + sources.push(PathBuf::from(s)); + } + } + } + if format.is_none() { + bail!("-t must be specified (man, md, txt)"); + } + if output.is_none() { + bail!("-o must be specified (output directory)"); + } + if sources.is_empty() { + bail!("at least one source must be specified"); + } + let opts = Options { + format: format.unwrap(), + output_dir: output.unwrap(), + sources, + url, + man_map, + }; + Ok(opts) +} diff --git a/src/tools/cargo/crates/mdman/src/util.rs b/src/tools/cargo/crates/mdman/src/util.rs new file mode 100644 index 000000000..a4c71ad38 --- /dev/null +++ b/src/tools/cargo/crates/mdman/src/util.rs @@ -0,0 +1,44 @@ +///! General utilities. +use crate::EventIter; +use anyhow::{bail, format_err, Context, Error}; +use pulldown_cmark::{CowStr, Event, Tag}; + +/// Splits the text `foo(1)` into "foo" and `1`. +pub fn parse_name_and_section(text: &str) -> Result<(&str, u8), Error> { + let mut i = text.split_terminator(&['(', ')'][..]); + let name = i + .next() + .ok_or_else(|| format_err!("man reference must have a name"))?; + let section = i + .next() + .ok_or_else(|| format_err!("man reference must have a section such as mycommand(1)"))?; + if let Some(s) = i.next() { + bail!( + "man reference must have the form mycommand(1), got extra part `{}`", + s + ); + } + let section: u8 = section + .parse() + .with_context(|| format!("section must be a number, got {}", section))?; + Ok((name, section)) +} + +/// Extracts the text from a header after Tag::Heading has been received. +pub fn header_text<'e>(parser: &mut EventIter<'e>) -> Result<CowStr<'e>, Error> { + let text = match parser.next() { + Some((Event::Text(t), _range)) => t, + e => bail!("expected plain text in man header, got {:?}", e), + }; + match parser.next() { + Some((Event::End(Tag::Heading(..)), _range)) => { + return Ok(text); + } + e => bail!("expected plain text in man header, got {:?}", e), + } +} + +/// Removes tags from the front and back of a string. +pub fn unwrap<'t>(text: &'t str, front: &str, back: &str) -> &'t str { + text.trim().trim_start_matches(front).trim_end_matches(back) +} diff --git a/src/tools/cargo/crates/mdman/tests/compare.rs b/src/tools/cargo/crates/mdman/tests/compare.rs new file mode 100644 index 000000000..3e679d127 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare.rs @@ -0,0 +1,48 @@ +//! Compares input to expected output. +//! +//! Use the MDMAN_BLESS environment variable to automatically update the +//! expected output. + +use mdman::{Format, ManMap}; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use url::Url; + +fn run(name: &str) { + let input = PathBuf::from(format!("tests/compare/{}.md", name)); + let url = Some(Url::parse("https://example.org/").unwrap()); + let mut map = ManMap::new(); + map.insert( + ("other-cmd".to_string(), 1), + "https://example.org/commands/other-cmd.html".to_string(), + ); + + for &format in &[Format::Man, Format::Md, Format::Text] { + let section = mdman::extract_section(&input).unwrap(); + let result = mdman::convert(&input, format, url.clone(), map.clone()).unwrap(); + let expected_path = format!( + "tests/compare/expected/{}.{}", + name, + format.extension(section) + ); + if std::env::var("MDMAN_BLESS").is_ok() { + std::fs::write(&expected_path, result).unwrap(); + } else { + let expected = std::fs::read_to_string(&expected_path).unwrap(); + // Fix if Windows checked out with autocrlf. + let expected = expected.replace("\r\n", "\n"); + assert_eq!(expected, result); + } + } +} + +macro_rules! test( ($name:ident) => ( + #[test] + fn $name() { run(stringify!($name)); } +) ); + +test!(formatting); +test!(links); +test!(options); +test!(tables); +test!(vars); diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.1 b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.1 new file mode 100644 index 000000000..840734cd0 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.1 @@ -0,0 +1,118 @@ +'\" t +.TH "FORMATTING" "1" +.nh +.ad l +.ss \n[.ss] 0 +.sp +This is \fBnested \f(BIformatting\fB \fBtext\fB\fR\&. +.SH "SECOND HEADING" +Some text at second level. +.SS "Third heading" +Some text at third level. +.SS "Fourth heading" +Some text at fourth level. +.SH "Quotes and blocks." +Here are some quotes and blocks. +.RS 3 +.ll -5 +.sp +This is a block quote. Ambidextrously koala apart that prudent blindly alas +far amid dear goodness turgid so exact inside oh and alas much fanciful that +dark on spoon\-fed adequately insolent walking crud. +.br +.RE +.ll +.sp +.RS 4 +.nf +This is a code block. Groundhog watchfully sudden firefly some self\-consciously hotly jeepers satanic after that this parrot this at virtuous +some mocking the leaned jeez nightingale as much mallard so because jeez +turned dear crud grizzly strenuously. + + Indented and should be unmodified. +.fi +.RE +.sp +.RS 4 +.nf +This is an indented code block. Egregiously yikes animatedly since outside beseechingly a badger hey shakily giraffe a one wow one this +goodness regarding reindeer so astride before. + + Doubly indented +.fi +.RE +.SH "Lists" +.sp +.RS 4 +\h'-04' 1.\h'+01'Ordered list +.sp +.RS 4 +\h'-04'\(bu\h'+02'Unordered list +.sp +With a second paragraph inside it +.sp +.RS 4 +\h'-04' 1.\h'+01'Inner ordered list +.RE +.sp +.RS 4 +\h'-04' 2.\h'+01'Another +.RE +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Eggs +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Milk +.sp +.RS 4 +\h'-04' 5.\h'+01'Don\[cq]t start at one. +.RE +.sp +.RS 4 +\h'-04' 6.\h'+01'tamarind +.RE +.RE +.RE +.sp +.RS 4 +\h'-04' 2.\h'+01'Second element +.RE +.sp +.RS 4 +\h'-04' 3.\h'+01'Third element +.RE +.SH "Breaks" +This has a +.br +hard break in it +and a soft one. +.SH "Horizontal rule" +This should contain a line: +\l'\n(.lu' +.sp +Nice! +.SH "Strange characters" +Handles escaping for characters +.sp +\&.dot at the start of a line. +.sp +\(rsfBnot really troff +.sp +Various characters \(rs \- \[en] \[em] \- | | ` +.sp +.RS 4 +.nf +tree +`\-\- example + |\-\- salamander + | |\-\- honey + | `\-\- some + |\-\- fancifully + `\-\- trout +.fi +.RE +.sp +\ \ \ \ non\-breaking space. diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.md b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.md new file mode 100644 index 000000000..3b9f5b888 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.md @@ -0,0 +1,95 @@ +# formatting(1) + +This is **nested _formatting_ `text`**. + +## SECOND HEADING + +Some text at second level. + +### Third heading + +Some text at third level. + +#### Fourth heading + +Some text at fourth level. + +## Quotes and blocks. + +Here are some quotes and blocks. + +> This is a block quote. Ambidextrously koala apart that prudent blindly alas +> far amid dear goodness turgid so exact inside oh and alas much fanciful that +> dark on spoon-fed adequately insolent walking crud. + +``` +This is a code block. Groundhog watchfully sudden firefly some self-consciously hotly jeepers satanic after that this parrot this at virtuous +some mocking the leaned jeez nightingale as much mallard so because jeez +turned dear crud grizzly strenuously. + + Indented and should be unmodified. +``` + + This is an indented code block. Egregiously yikes animatedly since outside beseechingly a badger hey shakily giraffe a one wow one this + goodness regarding reindeer so astride before. + + Doubly indented + +## Lists + +1. Ordered list + + * Unordered list + + With a second paragraph inside it + + 1. Inner ordered list + + 1. Another + + * Eggs + + * Milk + + 5. Don't start at one. + 6. tamarind + +1. Second element + +1. Third element + +## Breaks + +This has a\ +hard break in it +and a soft one. + +## Horizontal rule + +This should contain a line: + +--- + +Nice! + +## Strange characters + +Handles escaping for characters + +.dot at the start of a line. + +\fBnot really troff + +Various characters \ - – — ─ │ ├ └ + +``` +tree +└── example + ├── salamander + │ ├── honey + │ └── some + ├── fancifully + └── trout +``` + + non-breaking space. diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.txt b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.txt new file mode 100644 index 000000000..b5258c4f5 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/formatting.txt @@ -0,0 +1,84 @@ +FORMATTING(1) + +This is nested formatting text. + +SECOND HEADING + Some text at second level. + + Third heading + Some text at third level. + + Fourth heading + Some text at fourth level. + +QUOTES AND BLOCKS. + Here are some quotes and blocks. + + This is a block quote. Ambidextrously koala apart that prudent + blindly alas far amid dear goodness turgid so exact inside oh and + alas much fanciful that dark on spoon-fed adequately insolent walking + crud. + + This is a code block. Groundhog watchfully sudden firefly some self-consciously hotly jeepers satanic after that this parrot this at virtuous + some mocking the leaned jeez nightingale as much mallard so because jeez + turned dear crud grizzly strenuously. + + Indented and should be unmodified. + + This is an indented code block. Egregiously yikes animatedly since outside beseechingly a badger hey shakily giraffe a one wow one this + goodness regarding reindeer so astride before. + + Doubly indented + +LISTS + 1. Ordered list + + o Unordered list + + With a second paragraph inside it + + 1. Inner ordered list + + 2. Another + + o Eggs + + o Milk + + 5. Don’t start at one. + + 6. tamarind + + 2. Second element + + 3. Third element + +BREAKS + This has a + hard break in it and a soft one. + +HORIZONTAL RULE + This should contain a line: + + _________________________________________________________________ + Nice! + +STRANGE CHARACTERS + Handles escaping for characters + + .dot at the start of a line. + + \fBnot really troff + + Various characters \ - – — ─ │ ├ └ + + tree + └── example + ├── salamander + │ ├── honey + │ └── some + ├── fancifully + └── trout + + non-breaking space. + diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/links.1 b/src/tools/cargo/crates/mdman/tests/compare/expected/links.1 new file mode 100644 index 000000000..e56cef74c --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/links.1 @@ -0,0 +1,45 @@ +'\" t +.TH "LINKS" "1" +.nh +.ad l +.ss \n[.ss] 0 +.SH "NAME" +links \- Test of different link kinds +.SH "DESCRIPTION" +Inline link: \fIinline link\fR <https://example.com/inline> +.sp +Reference link: \fIthis is a link\fR <https://example.com/bar> +.sp +Collapsed: \fIcollapsed\fR <https://example.com/collapsed> +.sp +Shortcut: \fIshortcut\fR <https://example.com/shortcut> +.sp +Autolink: <https://example.com/auto> +.sp +Email: <foo@example.com> +.sp +Relative link: \fIrelative link\fR <https://example.org/foo/bar.html> +.sp +Collapsed unknown: [collapsed unknown][] +.sp +Reference unknown: [foo][unknown] +.sp +Shortcut unknown: [shortcut unknown] +.sp +\fBother\-cmd\fR(1) +.sp +\fBlocal\-cmd\fR(1) +.sp +\fISome link\fR <https://example.org/foo.html> +.sp +\fB\-\-include\fR +.RS 4 +Testing an \fIincluded link\fR <https://example.org/included_link.html>\&. +.RE +.SH "OPTIONS" +.sp +\fB\-\-foo\-bar\fR +.RS 4 +Example \fIlink\fR <https://example.org/bar.html>\&. +See \fBother\-cmd\fR(1), \fBlocal\-cmd\fR(1) +.RE diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/links.md b/src/tools/cargo/crates/mdman/tests/compare/expected/links.md new file mode 100644 index 000000000..11afcf3bd --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/links.md @@ -0,0 +1,56 @@ +# links(1) + +## NAME + +links - Test of different link kinds + +## DESCRIPTION + +Inline link: [inline link](https://example.com/inline) + +Reference link: [this is a link][bar] + +Collapsed: [collapsed][] + +Shortcut: [shortcut] + +Autolink: <https://example.com/auto> + +Email: <foo@example.com> + +Relative link: [relative link](foo/bar.html) + +Collapsed unknown: [collapsed unknown][] + +Reference unknown: [foo][unknown] + +Shortcut unknown: [shortcut unknown] + +[other-cmd(1)](https://example.org/commands/other-cmd.html) + +[local-cmd(1)](local-cmd.html) + +[Some link](foo.html) + +<dl> +<dt class="option-term" id="option-links---include"><a class="option-anchor" href="#option-links---include"></a><code>--include</code></dt> +<dd class="option-desc">Testing an <a href="included_link.html">included link</a>.</dd> + +</dl> + + +## OPTIONS + +<dl> + +<dt class="option-term" id="option-links---foo-bar"><a class="option-anchor" href="#option-links---foo-bar"></a><code>--foo-bar</code></dt> +<dd class="option-desc">Example <a href="bar.html">link</a>. +See <a href="https://example.org/commands/other-cmd.html">other-cmd(1)</a>, <a href="local-cmd.html">local-cmd(1)</a></dd> + + +</dl> + + +[bar]: https://example.com/bar +[collapsed]: https://example.com/collapsed +[shortcut]: https://example.com/shortcut diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/links.txt b/src/tools/cargo/crates/mdman/tests/compare/expected/links.txt new file mode 100644 index 000000000..7748c3d10 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/links.txt @@ -0,0 +1,40 @@ +LINKS(1) + +NAME + links - Test of different link kinds + +DESCRIPTION + Inline link: inline link <https://example.com/inline> + + Reference link: this is a link <https://example.com/bar> + + Collapsed: collapsed <https://example.com/collapsed> + + Shortcut: shortcut <https://example.com/shortcut> + + Autolink: <https://example.com/auto> + + Email: <foo@example.com> + + Relative link: relative link <https://example.org/foo/bar.html> + + Collapsed unknown: [collapsed unknown][] + + Reference unknown: [foo][unknown] + + Shortcut unknown: [shortcut unknown] + + other-cmd(1) + + local-cmd(1) + + Some link <https://example.org/foo.html> + + --include + Testing an included link <https://example.org/included_link.html>. + +OPTIONS + --foo-bar + Example link <https://example.org/bar.html>. See other-cmd(1), + local-cmd(1) + diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/options.1 b/src/tools/cargo/crates/mdman/tests/compare/expected/options.1 new file mode 100644 index 000000000..d362421e9 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/options.1 @@ -0,0 +1,94 @@ +'\" t +.TH "MY\-COMMAND" "1" +.nh +.ad l +.ss \n[.ss] 0 +.SH "NAME" +my\-command \- A brief description +.SH "SYNOPSIS" +\fBmy\-command\fR [\fB\-\-abc\fR | \fB\-\-xyz\fR] \fIname\fR +.br +\fBmy\-command\fR [\fB\-f\fR \fIfile\fR] +.br +\fBmy\-command\fR (\fB\-m\fR | \fB\-M\fR) [\fIoldbranch\fR] \fInewbranch\fR +.br +\fBmy\-command\fR (\fB\-d\fR | \fB\-D\fR) [\fB\-r\fR] \fIbranchname\fR\[u2026] +.SH "DESCRIPTION" +A description of the command. +.sp +.RS 4 +\h'-04'\(bu\h'+02'One +.sp +.RS 4 +\h'-04'\(bu\h'+02'Sub one +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Sub two +.RE +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Two +.RE +.sp +.RS 4 +\h'-04'\(bu\h'+02'Three +.RE +.SH "OPTIONS" +.SS "Command options" +.sp +\fB\-\-foo\-bar\fR +.RS 4 +Demo \fIemphasis\fR, \fBstrong\fR, ~~strike~~ +.RE +.sp +\fB\-p\fR \fIspec\fR, +\fB\-\-package\fR \fIspec\fR +.RS 4 +This has multiple flags. +.RE +.sp +\fInamed\-arg\[u2026]\fR +.RS 4 +A named argument. +.RE +.SS "Common Options" +.sp +\fB@\fR\fIfilename\fR +.RS 4 +Load from filename. +.RE +.sp +\fB\-\-foo\fR [\fIbar\fR] +.RS 4 +Flag with optional value. +.RE +.sp +\fB\-\-foo\fR[\fB=\fR\fIbar\fR] +.RS 4 +Alternate syntax for optional value (with required = for disambiguation). +.RE +.SH "EXAMPLES" +.sp +.RS 4 +\h'-04' 1.\h'+01'An example +.sp +.RS 4 +.nf +my\-command \-\-abc +.fi +.RE +.RE +.sp +.RS 4 +\h'-04' 2.\h'+01'Another example +.sp +.RS 4 +.nf +my\-command \-\-xyz +.fi +.RE +.RE +.SH "SEE ALSO" +\fBother\-command\fR(1) \fBabc\fR(7) diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/options.md b/src/tools/cargo/crates/mdman/tests/compare/expected/options.md new file mode 100644 index 000000000..19b0b443b --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/options.md @@ -0,0 +1,77 @@ +# my-command(1) + +## NAME + +my-command - A brief description + +## SYNOPSIS + +`my-command` [`--abc` | `--xyz`] _name_\ +`my-command` [`-f` _file_]\ +`my-command` (`-m` | `-M`) [_oldbranch_] _newbranch_\ +`my-command` (`-d` | `-D`) [`-r`] _branchname_... + +## DESCRIPTION + +A description of the command. + +* One + * Sub one + * Sub two +* Two +* Three + + +## OPTIONS + +### Command options + +<dl> + +<dt class="option-term" id="option-options---foo-bar"><a class="option-anchor" href="#option-options---foo-bar"></a><code>--foo-bar</code></dt> +<dd class="option-desc">Demo <em>emphasis</em>, <strong>strong</strong>, <del>strike</del></dd> + + +<dt class="option-term" id="option-options--p"><a class="option-anchor" href="#option-options--p"></a><code>-p</code> <em>spec</em></dt> +<dt class="option-term" id="option-options---package"><a class="option-anchor" href="#option-options---package"></a><code>--package</code> <em>spec</em></dt> +<dd class="option-desc">This has multiple flags.</dd> + + +<dt class="option-term" id="option-options-named-arg…"><a class="option-anchor" href="#option-options-named-arg…"></a><em>named-arg…</em></dt> +<dd class="option-desc">A named argument.</dd> + + +</dl> + +### Common Options + +<dl> +<dt class="option-term" id="option-options-@filename"><a class="option-anchor" href="#option-options-@filename"></a><code>@</code><em>filename</em></dt> +<dd class="option-desc">Load from filename.</dd> + + +<dt class="option-term" id="option-options---foo"><a class="option-anchor" href="#option-options---foo"></a><code>--foo</code> [<em>bar</em>]</dt> +<dd class="option-desc">Flag with optional value.</dd> + + +<dt class="option-term" id="option-options---foo[=bar]"><a class="option-anchor" href="#option-options---foo[=bar]"></a><code>--foo</code>[<code>=</code><em>bar</em>]</dt> +<dd class="option-desc">Alternate syntax for optional value (with required = for disambiguation).</dd> + + +</dl> + + +## EXAMPLES + +1. An example + + ``` + my-command --abc + ``` + +1. Another example + + my-command --xyz + +## SEE ALSO +[other-command(1)](other-command.html) [abc(7)](abc.html) diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/options.txt b/src/tools/cargo/crates/mdman/tests/compare/expected/options.txt new file mode 100644 index 000000000..9bfdec67c --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/options.txt @@ -0,0 +1,57 @@ +MY-COMMAND(1) + +NAME + my-command - A brief description + +SYNOPSIS + my-command [--abc | --xyz] name + my-command [-f file] + my-command (-m | -M) [oldbranch] newbranch + my-command (-d | -D) [-r] branchname… + +DESCRIPTION + A description of the command. + + o One + o Sub one + + o Sub two + + o Two + + o Three + +OPTIONS + Command options + --foo-bar + Demo emphasis, strong, ~~strike~~ + + -p spec, --package spec + This has multiple flags. + + named-arg… + A named argument. + + Common Options + @filename + Load from filename. + + --foo [bar] + Flag with optional value. + + --foo[=bar] + Alternate syntax for optional value (with required = for + disambiguation). + +EXAMPLES + 1. An example + + my-command --abc + + 2. Another example + + my-command --xyz + +SEE ALSO + other-command(1) abc(7) + diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/tables.1 b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.1 new file mode 100644 index 000000000..7175a3e85 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.1 @@ -0,0 +1,108 @@ +'\" t +.TH "TABLES" "1" +.nh +.ad l +.ss \n[.ss] 0 +.SH "DESCRIPTION" +Testing tables. + +.TS +allbox tab(:); +lt. +T{ +Single col +T} +T{ +Hi! :) +T} +.TE +.sp + +.TS +allbox tab(:); +lt lt lt. +T{ +Header content +T}:T{ +With \fBformat\fR \fItext\fR +T}:T{ +Another column +T} +T{ +Some data +T}:T{ +More data +T}:T{ + +T} +T{ +Extra long amount of text within a column +T}:T{ +hi +T}:T{ +there +T} +.TE +.sp + +.TS +allbox tab(:); +lt ct rt. +T{ +Left aligned +T}:T{ +Center aligned +T}:T{ +Right aligned +T} +T{ +abc +T}:T{ +def +T}:T{ +ghi +T} +.TE +.sp + +.TS +allbox tab(:); +lt ct rt. +T{ +Left aligned +T}:T{ +Center aligned +T}:T{ +Right aligned +T} +T{ +X +T}:T{ +X +T}:T{ +X +T} +T{ +Extra long text 123456789012 with mixed widths. +T}:T{ +Extra long text 123456789012 with mixed widths. +T}:T{ +Extra long text 123456789012 with mixed widths. +T} +.TE +.sp + +.TS +allbox tab(:); +lt. +T{ +Link check +T} +T{ +\fIfoo\fR <https://example.com/> +T} +T{ +<https://example.com/> +T} +.TE +.sp diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/tables.md b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.md new file mode 100644 index 000000000..831132c44 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.md @@ -0,0 +1,35 @@ +# tables(1) + +## DESCRIPTION + +Testing tables. + +| Single col | +-------------- +| Hi! :) | + + +Header content | With `format` *text* | Another column +---------------|----------------------|---------------- +Some data | More data | +Extra long amount of text within a column | hi | there + + +Left aligned | Center aligned | Right aligned +-------------|:--------------:|--------------: +abc | def | ghi + + +Left aligned | Center aligned | Right aligned +-------------|:--------------:|--------------: +X | X | X +Extra long text 123456789012 with mixed widths. | Extra long text 123456789012 with mixed widths. | Extra long text 123456789012 with mixed widths. + + +| Link check | +-------------- +| [foo] | +| <https://example.com/> | + + +[foo]: https://example.com/ diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/tables.txt b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.txt new file mode 100644 index 000000000..fed53f9a4 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/tables.txt @@ -0,0 +1,45 @@ +TABLES(1) + +DESCRIPTION + Testing tables. + + +-------------+ + | Single col | + +-------------+ + | Hi! :) | + +-------------+ + + +-------------------------------------+----------------+--------------+ + | Header content | With format | Another | + | | text | column | + +-------------------------------------+----------------+--------------+ + | Some data | More data | | + +-------------------------------------+----------------+--------------+ + | Extra long amount of text within a | hi | there | + | column | | | + +-------------------------------------+----------------+--------------+ + + +---------------+-----------------+----------------+ + | Left aligned | Center aligned | Right aligned | + +---------------+-----------------+----------------+ + | abc | def | ghi | + +---------------+-----------------+----------------+ + + +-----------------------+-----------------------+-----------------------+ + | Left aligned | Center aligned | Right aligned | + +-----------------------+-----------------------+-----------------------+ + | X | X | X | + +-----------------------+-----------------------+-----------------------+ + | Extra long text | Extra long text | Extra long text | + | 123456789012 with | 123456789012 with | 123456789012 with | + | mixed widths. | mixed widths. | mixed widths. | + +-----------------------+-----------------------+-----------------------+ + + +-----------------------+ + | Link check | + +-----------------------+ + | foo | + +-----------------------+ + | https://example.com/ | + +-----------------------+ + diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/vars.7 b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.7 new file mode 100644 index 000000000..0ee33ad36 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.7 @@ -0,0 +1,9 @@ +'\" t +.TH "VARS" "7" +.nh +.ad l +.ss \n[.ss] 0 +.sp +Bar +.sp +bar diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/vars.md b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.md new file mode 100644 index 000000000..2493aca36 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.md @@ -0,0 +1,7 @@ +# vars(7) + + + +Bar + +bar diff --git a/src/tools/cargo/crates/mdman/tests/compare/expected/vars.txt b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.txt new file mode 100644 index 000000000..11d34ca12 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/expected/vars.txt @@ -0,0 +1,6 @@ +VARS(7) + +Bar + +bar + diff --git a/src/tools/cargo/crates/mdman/tests/compare/formatting.md b/src/tools/cargo/crates/mdman/tests/compare/formatting.md new file mode 100644 index 000000000..3b9f5b888 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/formatting.md @@ -0,0 +1,95 @@ +# formatting(1) + +This is **nested _formatting_ `text`**. + +## SECOND HEADING + +Some text at second level. + +### Third heading + +Some text at third level. + +#### Fourth heading + +Some text at fourth level. + +## Quotes and blocks. + +Here are some quotes and blocks. + +> This is a block quote. Ambidextrously koala apart that prudent blindly alas +> far amid dear goodness turgid so exact inside oh and alas much fanciful that +> dark on spoon-fed adequately insolent walking crud. + +``` +This is a code block. Groundhog watchfully sudden firefly some self-consciously hotly jeepers satanic after that this parrot this at virtuous +some mocking the leaned jeez nightingale as much mallard so because jeez +turned dear crud grizzly strenuously. + + Indented and should be unmodified. +``` + + This is an indented code block. Egregiously yikes animatedly since outside beseechingly a badger hey shakily giraffe a one wow one this + goodness regarding reindeer so astride before. + + Doubly indented + +## Lists + +1. Ordered list + + * Unordered list + + With a second paragraph inside it + + 1. Inner ordered list + + 1. Another + + * Eggs + + * Milk + + 5. Don't start at one. + 6. tamarind + +1. Second element + +1. Third element + +## Breaks + +This has a\ +hard break in it +and a soft one. + +## Horizontal rule + +This should contain a line: + +--- + +Nice! + +## Strange characters + +Handles escaping for characters + +.dot at the start of a line. + +\fBnot really troff + +Various characters \ - – — ─ │ ├ └ + +``` +tree +└── example + ├── salamander + │ ├── honey + │ └── some + ├── fancifully + └── trout +``` + + non-breaking space. diff --git a/src/tools/cargo/crates/mdman/tests/compare/includes/links-include.md b/src/tools/cargo/crates/mdman/tests/compare/includes/links-include.md new file mode 100644 index 000000000..737336070 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/includes/links-include.md @@ -0,0 +1,7 @@ +[Some link](foo.html) + +{{#options}} +{{#option "`--include`"}} +Testing an [included link](included_link.html). +{{/option}} +{{/options}} diff --git a/src/tools/cargo/crates/mdman/tests/compare/includes/options-common.md b/src/tools/cargo/crates/mdman/tests/compare/includes/options-common.md new file mode 100644 index 000000000..07404e3f7 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/includes/options-common.md @@ -0,0 +1,14 @@ +{{#options}} +{{#option "`@`_filename_"}} +Load from filename. +{{/option}} + +{{#option "`--foo` [_bar_]"}} +Flag with optional value. +{{/option}} + +{{#option "`--foo`[`=`_bar_]"}} +Alternate syntax for optional value (with required = for disambiguation). +{{/option}} + +{{/options}} diff --git a/src/tools/cargo/crates/mdman/tests/compare/links.md b/src/tools/cargo/crates/mdman/tests/compare/links.md new file mode 100644 index 000000000..949f3749a --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/links.md @@ -0,0 +1,49 @@ +# links(1) + +## NAME + +links - Test of different link kinds + +## DESCRIPTION + +Inline link: [inline link](https://example.com/inline) + +Reference link: [this is a link][bar] + +Collapsed: [collapsed][] + +Shortcut: [shortcut] + +Autolink: <https://example.com/auto> + +Email: <foo@example.com> + +Relative link: [relative link](foo/bar.html) + +Collapsed unknown: [collapsed unknown][] + +Reference unknown: [foo][unknown] + +Shortcut unknown: [shortcut unknown] + +{{man "other-cmd" 1}} + +{{man "local-cmd" 1}} + +{{> links-include}} + +## OPTIONS + +{{#options}} + +{{#option "`--foo-bar`"}} +Example [link](bar.html). +See {{man "other-cmd" 1}}, {{man "local-cmd" 1}} +{{/option}} + +{{/options}} + + +[bar]: https://example.com/bar +[collapsed]: https://example.com/collapsed +[shortcut]: https://example.com/shortcut diff --git a/src/tools/cargo/crates/mdman/tests/compare/options.md b/src/tools/cargo/crates/mdman/tests/compare/options.md new file mode 100644 index 000000000..51415b09e --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/options.md @@ -0,0 +1,62 @@ +# my-command(1) + +## NAME + +my-command - A brief description + +## SYNOPSIS + +`my-command` [`--abc` | `--xyz`] _name_\ +`my-command` [`-f` _file_]\ +`my-command` (`-m` | `-M`) [_oldbranch_] _newbranch_\ +`my-command` (`-d` | `-D`) [`-r`] _branchname_... + +## DESCRIPTION + +A description of the command. + +* One + * Sub one + * Sub two +* Two +* Three + + +## OPTIONS + +### Command options + +{{#options}} + +{{#option "`--foo-bar`"}} +Demo *emphasis*, **strong**, ~~strike~~ +{{/option}} + +{{#option "`-p` _spec_" "`--package` _spec_"}} +This has multiple flags. +{{/option}} + +{{#option "_named-arg..._"}} +A named argument. +{{/option}} + +{{/options}} + +### Common Options + +{{> options-common}} + +## EXAMPLES + +1. An example + + ``` + my-command --abc + ``` + +1. Another example + + my-command --xyz + +## SEE ALSO +{{man "other-command" 1}} {{man "abc" 7}} diff --git a/src/tools/cargo/crates/mdman/tests/compare/tables.md b/src/tools/cargo/crates/mdman/tests/compare/tables.md new file mode 100644 index 000000000..831132c44 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/tables.md @@ -0,0 +1,35 @@ +# tables(1) + +## DESCRIPTION + +Testing tables. + +| Single col | +-------------- +| Hi! :) | + + +Header content | With `format` *text* | Another column +---------------|----------------------|---------------- +Some data | More data | +Extra long amount of text within a column | hi | there + + +Left aligned | Center aligned | Right aligned +-------------|:--------------:|--------------: +abc | def | ghi + + +Left aligned | Center aligned | Right aligned +-------------|:--------------:|--------------: +X | X | X +Extra long text 123456789012 with mixed widths. | Extra long text 123456789012 with mixed widths. | Extra long text 123456789012 with mixed widths. + + +| Link check | +-------------- +| [foo] | +| <https://example.com/> | + + +[foo]: https://example.com/ diff --git a/src/tools/cargo/crates/mdman/tests/compare/vars.md b/src/tools/cargo/crates/mdman/tests/compare/vars.md new file mode 100644 index 000000000..d41b76583 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/compare/vars.md @@ -0,0 +1,7 @@ +# vars(7) + +{{*set foo="Bar"}} + +{{foo}} + +{{lower foo}} diff --git a/src/tools/cargo/crates/mdman/tests/invalid.rs b/src/tools/cargo/crates/mdman/tests/invalid.rs new file mode 100644 index 000000000..cc81d06c4 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/invalid.rs @@ -0,0 +1,34 @@ +//! Tests for errors and invalid input. + +use mdman::{Format, ManMap}; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +fn run(name: &str, expected_error: &str) { + let input = PathBuf::from(format!("tests/invalid/{}", name)); + match mdman::convert(&input, Format::Man, None, ManMap::new()) { + Ok(_) => { + panic!("expected {} to fail", name); + } + Err(e) => { + assert_eq!(expected_error, e.to_string()); + } + } +} + +macro_rules! test( ($name:ident, $file_name:expr, $error:expr) => ( + #[test] + fn $name() { run($file_name, $error); } +) ); + +test!( + nested, + "nested.md", + "Error rendering \"template\" line 4, col 1: options blocks cannot be nested" +); + +test!( + not_inside_options, + "not-inside-options.md", + "Error rendering \"template\" line 3, col 1: option must be in options block" +); diff --git a/src/tools/cargo/crates/mdman/tests/invalid/nested.md b/src/tools/cargo/crates/mdman/tests/invalid/nested.md new file mode 100644 index 000000000..6a33e6df6 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/invalid/nested.md @@ -0,0 +1,6 @@ +# nested(1) + +{{#options}} +{{#options}} +{{/options}} +{{/options}} diff --git a/src/tools/cargo/crates/mdman/tests/invalid/not-inside-options.md b/src/tools/cargo/crates/mdman/tests/invalid/not-inside-options.md new file mode 100644 index 000000000..b6c816f09 --- /dev/null +++ b/src/tools/cargo/crates/mdman/tests/invalid/not-inside-options.md @@ -0,0 +1,5 @@ +# not-inside-options(1) + +{{#option "`-o`"}} +Testing without options block. +{{/option}} |