summaryrefslogtreecommitdiffstats
path: root/src/tools/cargo/crates/mdman
diff options
context:
space:
mode:
Diffstat (limited to 'src/tools/cargo/crates/mdman')
-rw-r--r--src/tools/cargo/crates/mdman/Cargo.lock459
-rw-r--r--src/tools/cargo/crates/mdman/Cargo.toml17
-rw-r--r--src/tools/cargo/crates/mdman/README.md7
-rwxr-xr-xsrc/tools/cargo/crates/mdman/build-man.sh7
-rw-r--r--src/tools/cargo/crates/mdman/doc/mdman.md95
-rw-r--r--src/tools/cargo/crates/mdman/doc/out/mdman.1124
-rw-r--r--src/tools/cargo/crates/mdman/doc/out/mdman.md95
-rw-r--r--src/tools/cargo/crates/mdman/doc/out/mdman.txt91
-rw-r--r--src/tools/cargo/crates/mdman/src/format.rs20
-rw-r--r--src/tools/cargo/crates/mdman/src/format/man.rs436
-rw-r--r--src/tools/cargo/crates/mdman/src/format/md.rs112
-rw-r--r--src/tools/cargo/crates/mdman/src/format/text.rs605
-rw-r--r--src/tools/cargo/crates/mdman/src/hbs.rs215
-rw-r--r--src/tools/cargo/crates/mdman/src/lib.rs122
-rw-r--r--src/tools/cargo/crates/mdman/src/main.rs133
-rw-r--r--src/tools/cargo/crates/mdman/src/util.rs44
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare.rs48
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/formatting.1118
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/formatting.md95
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/formatting.txt84
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/links.145
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/links.md56
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/links.txt40
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/options.194
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/options.md77
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/options.txt57
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/tables.1108
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/tables.md35
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/tables.txt45
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/vars.79
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/vars.md7
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/expected/vars.txt6
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/formatting.md95
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/includes/links-include.md7
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/includes/options-common.md14
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/links.md49
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/options.md62
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/tables.md35
-rw-r--r--src/tools/cargo/crates/mdman/tests/compare/vars.md7
-rw-r--r--src/tools/cargo/crates/mdman/tests/invalid.rs34
-rw-r--r--src/tools/cargo/crates/mdman/tests/invalid/nested.md6
-rw-r--r--src/tools/cargo/crates/mdman/tests/invalid/not-inside-options.md5
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(&params, &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
+```
+
+&nbsp;&nbsp;&nbsp;&nbsp;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
+```
+
+&nbsp;&nbsp;&nbsp;&nbsp;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}}