├── .gitignore ├── tests └── testsuite │ ├── main.rs │ ├── demo.troff │ └── demo.rs ├── .cargo └── config.toml ├── committed.toml ├── release.toml ├── .pre-commit-config.yaml ├── .github ├── workflows │ ├── spelling.yml │ ├── pre-commit.yml │ ├── committed.yml │ ├── audit.yml │ ├── rust-next.yml │ └── ci.yml ├── settings.yml └── renovate.json5 ├── .clippy.toml ├── LICENSE-MIT ├── CHANGELOG.md ├── README.md ├── CONTRIBUTING.md ├── Cargo.toml ├── Cargo.lock ├── deny.toml ├── LICENSE-APACHE └── src └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /tests/testsuite/main.rs: -------------------------------------------------------------------------------- 1 | automod::dir!("tests/testsuite"); 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [resolver] 2 | incompatible-rust-versions = "fallback" 3 | -------------------------------------------------------------------------------- /committed.toml: -------------------------------------------------------------------------------- 1 | style="conventional" 2 | ignore_author_re="(dependabot|renovate)" 3 | merge_commit = false 4 | -------------------------------------------------------------------------------- /release.toml: -------------------------------------------------------------------------------- 1 | owners = ["github:rust-cli:Maintainers"] 2 | dependent-version = "fix" 3 | allow-branch = ["master"] 4 | -------------------------------------------------------------------------------- /tests/testsuite/demo.troff: -------------------------------------------------------------------------------- 1 | .TH CORRUPT 1 2 | .SH NAME 3 | corrupt \- modify files by randomly changing bits 4 | .SH SYNOPSIS 5 | .B corrupt 6 | [\fB\-n\fR \fIBITS\fR] 7 | [\fB\-\-bits\fR \fIBITS\fR] 8 | .IR file ... 9 | .SH DESCRIPTION 10 | .B corrupt 11 | modifies files by toggling a randomly chosen bit. 12 | .SH OPTIONS 13 | .TP 14 | .BR \-n ", " \-\-bits =\fIBITS\fR 15 | Set the number of bits to modify. 16 | Default is one bit. -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: ["pre-commit", "commit-msg"] 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: check-yaml 7 | - id: check-json 8 | - id: check-toml 9 | - id: check-merge-conflict 10 | - id: check-case-conflict 11 | - id: detect-private-key 12 | - repo: https://github.com/crate-ci/typos 13 | rev: v1.32.0 14 | hooks: 15 | - id: typos 16 | - repo: https://github.com/crate-ci/committed 17 | rev: v1.1.7 18 | hooks: 19 | - id: committed 20 | -------------------------------------------------------------------------------- /.github/workflows/spelling.yml: -------------------------------------------------------------------------------- 1 | name: Spelling 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: [pull_request] 7 | 8 | env: 9 | RUST_BACKTRACE: 1 10 | CARGO_TERM_COLOR: always 11 | CLICOLOR: 1 12 | 13 | concurrency: 14 | group: "${{ github.workflow }}-${{ github.ref }}" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | spelling: 19 | name: Spell Check with Typos 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout Actions Repository 23 | uses: actions/checkout@v6 24 | - name: Spell Check Repo 25 | uses: crate-ci/typos@master 26 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | permissions: {} # none 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: [master] 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | pre-commit: 21 | permissions: 22 | contents: read 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v6 26 | - uses: actions/setup-python@v6 27 | with: 28 | python-version: '3.x' 29 | - uses: pre-commit/action@v3.0.1 30 | -------------------------------------------------------------------------------- /.github/workflows/committed.yml: -------------------------------------------------------------------------------- 1 | # Not run as part of pre-commit checks because they don't handle sending the correct commit 2 | # range to `committed` 3 | name: Lint Commits 4 | on: [pull_request] 5 | 6 | permissions: 7 | contents: read 8 | 9 | env: 10 | RUST_BACKTRACE: 1 11 | CARGO_TERM_COLOR: always 12 | CLICOLOR: 1 13 | 14 | concurrency: 15 | group: "${{ github.workflow }}-${{ github.ref }}" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | committed: 20 | name: Lint Commits 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout Actions Repository 24 | uses: actions/checkout@v6 25 | with: 26 | fetch-depth: 0 27 | - name: Lint Commits 28 | uses: crate-ci/committed@master 29 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | allow-print-in-tests = true 2 | allow-expect-in-tests = true 3 | allow-unwrap-in-tests = true 4 | allow-dbg-in-tests = true 5 | disallowed-methods = [ 6 | { path = "std::option::Option::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 7 | { path = "std::option::Option::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 8 | { path = "std::result::Result::map_or", reason = "prefer `map(..).unwrap_or(..)` for legibility" }, 9 | { path = "std::result::Result::map_or_else", reason = "prefer `map(..).unwrap_or_else(..)` for legibility" }, 10 | { path = "std::iter::Iterator::for_each", reason = "prefer `for` for side-effects" }, 11 | { path = "std::iter::Iterator::try_for_each", reason = "prefer `for` for side-effects" }, 12 | ] 13 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) Individual contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](https://semver.org/). 6 | 7 | 8 | ## [Unreleased] - ReleaseDate 9 | 10 | ## [0.2.2] - 2024-07-25 11 | 12 | ### Compatibility 13 | 14 | - Update MSRV to 1.70 15 | 16 | ## [0.2.1] - 2021-12-23 17 | 18 | ### Features 19 | 20 | - Allow building up Roffs from each other 21 | 22 | ## [0.2.0] - 2021-12-23 23 | 24 | ### Breaking Changes 25 | 26 | The API changed to be a generic ROFF writer, taking care of 27 | escaping, etc. At the moment, handling of control lines is left up to the 28 | caller 29 | 30 | ## [0.1.0] - 2018-05-08 31 | 32 | 33 | [Unreleased]: https://github.com/rust-cli/roff-rs/compare/v0.2.2...HEAD 34 | [0.2.2]: https://github.com/rust-cli/roff-rs/compare/v0.2.1...v0.2.2 35 | [0.2.1]: https://github.com/rust-cli/roff-rs/compare/v0.2.0...v0.2.1 36 | [0.2.0]: https://github.com/assert-rs/assert_cmd/compare/v0.1.0...v0.2.0 37 | [0.1.0]: https://github.com/assert-rs/assert_cmd/compare/21f419c71f025ef596e7954d62506ff8fe3fd7a2...v0.1.0 38 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - '**/Cargo.toml' 10 | - '**/Cargo.lock' 11 | push: 12 | branches: 13 | - master 14 | 15 | env: 16 | RUST_BACKTRACE: 1 17 | CARGO_TERM_COLOR: always 18 | CLICOLOR: 1 19 | 20 | concurrency: 21 | group: "${{ github.workflow }}-${{ github.ref }}" 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | security_audit: 26 | permissions: 27 | issues: write # to create issues (actions-rs/audit-check) 28 | checks: write # to create check (actions-rs/audit-check) 29 | runs-on: ubuntu-latest 30 | # Prevent sudden announcement of a new advisory from failing ci: 31 | continue-on-error: true 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v6 35 | - uses: actions-rs/audit-check@v1 36 | with: 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | cargo_deny: 40 | permissions: 41 | issues: write # to create issues (actions-rs/audit-check) 42 | checks: write # to create check (actions-rs/audit-check) 43 | runs-on: ubuntu-latest 44 | strategy: 45 | matrix: 46 | checks: 47 | - bans licenses sources 48 | steps: 49 | - uses: actions/checkout@v6 50 | - uses: EmbarkStudios/cargo-deny-action@v2 51 | with: 52 | command: check ${{ matrix.checks }} 53 | rust-version: stable 54 | -------------------------------------------------------------------------------- /.github/workflows/rust-next.yml: -------------------------------------------------------------------------------- 1 | name: rust-next 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | schedule: 8 | - cron: '18 18 6 * *' 9 | 10 | env: 11 | RUST_BACKTRACE: 1 12 | CARGO_TERM_COLOR: always 13 | CLICOLOR: 1 14 | 15 | concurrency: 16 | group: "${{ github.workflow }}-${{ github.ref }}" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | strategy: 23 | matrix: 24 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 25 | rust: ["stable", "beta"] 26 | include: 27 | - os: ubuntu-latest 28 | rust: "nightly" 29 | continue-on-error: ${{ matrix.rust != 'stable' }} 30 | runs-on: ${{ matrix.os }} 31 | env: 32 | # Reduce amount of data cached 33 | CARGO_PROFILE_DEV_DEBUG: line-tables-only 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v6 37 | - name: Install Rust 38 | uses: dtolnay/rust-toolchain@stable 39 | with: 40 | toolchain: ${{ matrix.rust }} 41 | - uses: Swatinem/rust-cache@v2 42 | - uses: taiki-e/install-action@cargo-hack 43 | - name: Build 44 | run: cargo test --workspace --no-run 45 | - name: Test 46 | run: cargo hack test --each-feature --workspace 47 | latest: 48 | name: "Check latest dependencies" 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout repository 52 | uses: actions/checkout@v6 53 | - name: Install Rust 54 | uses: dtolnay/rust-toolchain@stable 55 | with: 56 | toolchain: stable 57 | - uses: Swatinem/rust-cache@v2 58 | - uses: taiki-e/install-action@cargo-hack 59 | - name: Update dependencies 60 | run: cargo update 61 | - name: Build 62 | run: cargo test --workspace --no-run 63 | - name: Test 64 | run: cargo hack test --each-feature --workspace 65 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | # These settings are synced to GitHub by https://probot.github.io/apps/settings/ 2 | 3 | repository: 4 | description: "ROFF (man page format) generation library" 5 | homepage: "docs.rs/roff" 6 | topics: "rust cli man roff" 7 | has_issues: true 8 | has_projects: false 9 | has_wiki: false 10 | has_downloads: true 11 | default_branch: master 12 | 13 | # Preference: people do clean commits 14 | allow_merge_commit: true 15 | # Backup in case we need to clean up commits 16 | allow_squash_merge: true 17 | # Not really needed 18 | allow_rebase_merge: false 19 | 20 | allow_auto_merge: true 21 | delete_branch_on_merge: true 22 | 23 | squash_merge_commit_title: "PR_TITLE" 24 | squash_merge_commit_message: "PR_BODY" 25 | merge_commit_message: "PR_BODY" 26 | 27 | labels: 28 | # Type 29 | - name: bug 30 | color: '#b60205' 31 | description: "Not as expected" 32 | - name: enhancement 33 | color: '#1d76db' 34 | description: "Improve the expected" 35 | # Flavor 36 | - name: question 37 | color: "#cc317c" 38 | description: "Uncertainty is involved" 39 | - name: breaking-change 40 | color: "#e99695" 41 | - name: good first issue 42 | color: '#c2e0c6' 43 | description: "Help wanted!" 44 | 45 | # This serves more as documentation. 46 | # Branch protection API was replaced by rulesets but settings isn't updated. 47 | # See https://github.com/repository-settings/app/issues/825 48 | # 49 | # branches: 50 | # - name: master 51 | # protection: 52 | # required_pull_request_reviews: null 53 | # required_conversation_resolution: true 54 | # required_status_checks: 55 | # # Required. Require branches to be up to date before merging. 56 | # strict: false 57 | # contexts: ["CI", "Spell Check with Typos"] 58 | # enforce_admins: false 59 | # restrictions: null 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # roff-rs 2 | 3 | [![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] 4 | ![License](https://img.shields.io/crates/l/roff.svg) 5 | [![crates.io](https://img.shields.io/crates/v/roff.svg)][Crates.io] 6 | 7 | [Crates.io]: https://crates.io/crates/roff 8 | [Documentation]: https://docs.rs/roff/ 9 | 10 | [Roff](http://man7.org/linux/man-pages/man7/roff.7.html) generation library. 11 | 12 | ## Examples 13 | 14 | ```rust 15 | use roff::{bold, italic, roman, Roff}; 16 | 17 | fn main() { 18 | let page = Roff::new() 19 | .control("TH", ["CORRUPT", "1"]) 20 | .control("SH", ["NAME"]) 21 | .text([roman("corrupt - modify files by randomly changing bits")]) 22 | .control("SH", ["SYNOPSIS"]) 23 | .text([bold("corrupt"), roman(" ["), bold("-n"), roman(" "), italic("BITS"), roman("] ["), 24 | bold("--bits"), roman(" "), italic("BITS"), roman("] "), italic("FILE"), roman("..."), 25 | ]) 26 | .control("SH", ["DESCRIPTION"]) 27 | .text([bold("corrupt"), roman(" modifies files by toggling a randomly chosen bit.")]) 28 | .control("SH", ["OPTIONS"]) 29 | .control("TP", []) 30 | .text([bold("-n"), roman(", "), bold("--bits"), roman("="), italic("BITS")]) 31 | .text([roman("Set the number of bits to modify. Default is one bit.")]) 32 | .render(); 33 | print!("{}", page); 34 | } 35 | ``` 36 | 37 | Which outputs: 38 | ```troff 39 | .ie \n(.g .ds Aq \(aq 40 | .el .ds Aq ' 41 | .TH CORRUPT 1 42 | .SH NAME 43 | corrupt \- modify files by randomly changing bits 44 | .SH SYNOPSIS 45 | \fBcorrupt\fR [\fB\-n\fR \fIBITS\fR] [\fB\-\-bits\fR \fIBITS\fR] \fIFILE\fR... 46 | .SH DESCRIPTION 47 | \fBcorrupt\fR modifies files by toggling a randomly chosen bit. 48 | .SH OPTIONS 49 | .TP 50 | \fB\-n\fR, \fB\-\-bits\fR=\fIBITS\fR 51 | Set the number of bits to modify. Default is one bit. 52 | ``` 53 | 54 | Which will be shown by the `man(1)` command as: 55 | 56 | ```txt 57 | CORRUPT(1) General Commands Manual CORRUPT(1) 58 | 59 | NAME 60 | corrupt - modify files by randomly changing bits 61 | 62 | SYNOPSIS 63 | corrupt [-n BITS] [--bits BITS] FILE... 64 | 65 | DESCRIPTION 66 | corrupt modifies files by toggling a randomly chosen bit. 67 | 68 | OPTIONS 69 | -n, --bits=BITS 70 | Set the number of bits to modify. Default is one bit. 71 | 72 | CORRUPT(1) 73 | ``` 74 | 75 | ## License 76 | 77 | Licensed under either of 78 | 79 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) 80 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or ) 81 | 82 | at your option. 83 | 84 | ### Contribution 85 | 86 | Unless you explicitly state otherwise, any contribution intentionally 87 | submitted for inclusion in the work by you, as defined in the Apache-2.0 88 | license, shall be dual-licensed as above, without any additional terms or 89 | conditions. 90 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | schedule: [ 3 | 'before 5am on the first day of the month', 4 | ], 5 | semanticCommits: 'enabled', 6 | commitMessageLowerCase: 'never', 7 | configMigration: true, 8 | dependencyDashboard: true, 9 | customManagers: [ 10 | { 11 | customType: 'regex', 12 | managerFilePatterns: [ 13 | '/^rust-toolchain\\.toml$/', 14 | '/Cargo.toml$/', 15 | '/clippy.toml$/', 16 | '/\\.clippy.toml$/', 17 | '/^\\.github/workflows/ci.yml$/', 18 | '/^\\.github/workflows/rust-next.yml$/', 19 | ], 20 | matchStrings: [ 21 | 'STABLE.*?(?\\d+\\.\\d+(\\.\\d+)?)', 22 | '(?\\d+\\.\\d+(\\.\\d+)?).*?STABLE', 23 | ], 24 | depNameTemplate: 'STABLE', 25 | packageNameTemplate: 'rust-lang/rust', 26 | datasourceTemplate: 'github-releases', 27 | }, 28 | ], 29 | packageRules: [ 30 | { 31 | commitMessageTopic: 'Rust Stable', 32 | matchManagers: [ 33 | 'custom.regex', 34 | ], 35 | matchDepNames: [ 36 | 'STABLE', 37 | ], 38 | extractVersion: '^(?\\d+\\.\\d+)', // Drop the patch version 39 | schedule: [ 40 | '* * * * *', 41 | ], 42 | automerge: true, 43 | }, 44 | // Goals: 45 | // - Keep version reqs low, ignoring compatible normal/build dependencies 46 | // - Take advantage of latest dev-dependencies 47 | // - Rollup safe upgrades to reduce CI runner load 48 | // - Help keep number of versions down by always using latest breaking change 49 | // - Have lockfile and manifest in-sync 50 | { 51 | matchManagers: [ 52 | 'cargo', 53 | ], 54 | matchDepTypes: [ 55 | 'build-dependencies', 56 | 'dependencies', 57 | ], 58 | matchCurrentVersion: '>=0.1.0', 59 | matchUpdateTypes: [ 60 | 'patch', 61 | ], 62 | enabled: false, 63 | }, 64 | { 65 | matchManagers: [ 66 | 'cargo', 67 | ], 68 | matchDepTypes: [ 69 | 'build-dependencies', 70 | 'dependencies', 71 | ], 72 | matchCurrentVersion: '>=1.0.0', 73 | matchUpdateTypes: [ 74 | 'minor', 75 | 'patch', 76 | ], 77 | enabled: false, 78 | }, 79 | { 80 | matchManagers: [ 81 | 'cargo', 82 | ], 83 | matchDepTypes: [ 84 | 'dev-dependencies', 85 | ], 86 | matchCurrentVersion: '>=0.1.0', 87 | matchUpdateTypes: [ 88 | 'patch', 89 | ], 90 | automerge: true, 91 | groupName: 'compatible (dev)', 92 | }, 93 | { 94 | matchManagers: [ 95 | 'cargo', 96 | ], 97 | matchDepTypes: [ 98 | 'dev-dependencies', 99 | ], 100 | matchCurrentVersion: '>=1.0.0', 101 | matchUpdateTypes: [ 102 | 'minor', 103 | 'patch', 104 | ], 105 | automerge: true, 106 | groupName: 'compatible (dev)', 107 | }, 108 | ], 109 | } 110 | -------------------------------------------------------------------------------- /tests/testsuite/demo.rs: -------------------------------------------------------------------------------- 1 | use pretty_assertions::assert_eq; 2 | 3 | #[test] 4 | #[cfg(unix)] 5 | fn demo() { 6 | use roff::*; 7 | 8 | if !has_command("troff") { 9 | return; 10 | } 11 | 12 | let page = Roff::new() 13 | .control("TH", ["CORRUPT", "1"]) 14 | .control("SH", ["NAME"]) 15 | .text(vec![roman( 16 | "corrupt - modify files by randomly changing bits", 17 | )]) 18 | .control("SH", ["SYNOPSIS"]) 19 | .text(vec![ 20 | bold("corrupt"), 21 | " ".into(), 22 | "[".into(), 23 | bold("-n"), 24 | " ".into(), 25 | italic("BITS"), 26 | "]".into(), 27 | " ".into(), 28 | "[".into(), 29 | bold("--bits"), 30 | " ".into(), 31 | italic("BITS"), 32 | "]".into(), 33 | " ".into(), 34 | italic("file"), 35 | "...".into(), 36 | ]) 37 | .control("SH", ["DESCRIPTION"]) 38 | .text(vec![ 39 | bold("corrupt"), 40 | " modifies files by toggling a randomly chosen bit.".into(), 41 | ]) 42 | .control("SH", ["OPTIONS"]) 43 | .control("TP", []) 44 | .text(vec![ 45 | bold("-n"), 46 | ", ".into(), 47 | bold("--bits"), 48 | "=".into(), 49 | italic("BITS"), 50 | ]) 51 | .text(vec![ 52 | "Set the number of bits to modify. ".into(), 53 | "Default is one bit.".into(), 54 | ]) 55 | .to_roff(); 56 | 57 | assert_eq!( 58 | roff_to_ascii(include_str!("./demo.troff")), 59 | roff_to_ascii(&page) 60 | ); 61 | } 62 | 63 | fn roff_to_ascii(input: &str) -> String { 64 | duct::cmd("troff", &["-a", "-mman"]) 65 | .stdin_bytes(input) 66 | .stdout_capture() 67 | .read() 68 | .unwrap() 69 | } 70 | 71 | pub(crate) fn has_command(command: &str) -> bool { 72 | let output = match std::process::Command::new(command) 73 | .arg("--version") 74 | .output() 75 | { 76 | Ok(output) => output, 77 | Err(e) => { 78 | // CI is expected to support all of the commands 79 | if is_ci() && cfg!(target_os = "linux") { 80 | panic!( 81 | "expected command `{command}` to be somewhere in PATH: {e}" 82 | ); 83 | } 84 | return false; 85 | } 86 | }; 87 | if !output.status.success() { 88 | panic!( 89 | "expected command `{}` to be runnable, got error {}:\n\ 90 | stderr:{}\n\ 91 | stdout:{}\n", 92 | command, 93 | output.status, 94 | String::from_utf8_lossy(&output.stderr), 95 | String::from_utf8_lossy(&output.stdout) 96 | ); 97 | } 98 | let stdout = String::from_utf8_lossy(&output.stdout); 99 | println!( 100 | "$ {command} --version 101 | {stdout}" 102 | ); 103 | if cfg!(target_os = "macos") && stdout.starts_with("GNU bash, version 3") { 104 | return false; 105 | } 106 | 107 | true 108 | } 109 | 110 | /// Whether or not this running in a Continuous Integration environment. 111 | fn is_ci() -> bool { 112 | // Consider using `tracked_env` instead of option_env! when it is stabilized. 113 | // `tracked_env` will handle changes, but not require rebuilding the macro 114 | // itself like option_env does. 115 | option_env!("CI").is_some() || option_env!("TF_BUILD").is_some() 116 | } 117 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to roff-rs 2 | 3 | Thanks for wanting to contribute! There are many ways to contribute and we 4 | appreciate any level you're willing to do. 5 | 6 | ## Feature Requests 7 | 8 | Need some new functionality to help? You can let us know by opening an 9 | [issue][new issue]. It's helpful to look through [all issues][all issues] in 10 | case it's already being talked about. 11 | 12 | ## Bug Reports 13 | 14 | Please let us know about what problems you run into, whether in behavior or 15 | ergonomics of API. You can do this by opening an [issue][new issue]. It's 16 | helpful to look through [all issues][all issues] in case it's already being 17 | talked about. 18 | 19 | ## Pull Requests 20 | 21 | Looking for an idea? Check our [issues][issues]. If the issue looks open ended, 22 | it is probably best to post on the issue how you are thinking of resolving the 23 | issue so you can get feedback early in the process. We want you to be 24 | successful and it can be discouraging to find out a lot of re-work is needed. 25 | 26 | Already have an idea? It might be good to first [create an issue][new issue] 27 | to propose it so we can make sure we are aligned and lower the risk of having 28 | to re-work some of it and the discouragement that goes along with that. 29 | 30 | ### Process 31 | 32 | As a heads up, we'll be running your PR through the following gauntlet: 33 | - warnings turned to compile errors 34 | - `cargo test` 35 | - `rustfmt` 36 | - `clippy` 37 | - `rustdoc` 38 | - [`committed`](https://github.com/crate-ci/committed) as we use [Conventional](https://www.conventionalcommits.org) commit style 39 | - [`typos`](https://github.com/crate-ci/typos) to check spelling 40 | 41 | Not everything can be checked automatically though. 42 | 43 | We request that the commit history gets cleaned up. 44 | 45 | We ask that commits are atomic, meaning they are complete and have a single responsibility. 46 | A complete commit should build, pass tests, update documentation and tests, and not have dead code. 47 | 48 | PRs should tell a cohesive story, with refactor and test commits that keep the 49 | fix or feature commits simple and clear. 50 | 51 | Specifically, we would encourage 52 | - File renames be isolated into their own commit 53 | - Add tests in a commit before their feature or fix, showing the current behavior (i.e. they should pass). 54 | The diff for the feature/fix commit will then show how the behavior changed, 55 | making the commit's intent clearer to reviewers and the community, and showing people that the 56 | test is verifying the expected state. 57 | - e.g. [clap#5520](https://github.com/clap-rs/clap/pull/5520) 58 | 59 | Note that we are talking about ideals. 60 | We understand having a clean history requires more advanced git skills; 61 | feel free to ask us for help! 62 | We might even suggest where it would work to be lax. 63 | We also understand that editing some early commits may cause a lot of churn 64 | with merge conflicts which can make it not worth editing all of the history. 65 | 66 | For code organization, we recommend 67 | - Grouping `impl` blocks next to their type (or trait) 68 | - Grouping private items after the `pub` item that uses them. 69 | - The intent is to help people quickly find the "relevant" details, allowing them to "dig deeper" as needed. Or put another way, the `pub` items serve as a table-of-contents. 70 | - The exact order is fuzzy; do what makes sense 71 | 72 | ## Releasing 73 | 74 | Pre-requisites 75 | - Running `cargo login` 76 | - A member of `rust-cli:Maintainers` 77 | - Push permission to the repo 78 | - [`cargo-release`](https://github.com/crate-ci/cargo-release/) 79 | 80 | When we're ready to release, a project owner should do the following 81 | 1. Update the changelog (see `cargo release changes` for ideas) 82 | 2. Determine what the next version is, according to semver 83 | 3. Run [`cargo release -x `](https://github.com/crate-ci/cargo-release) 84 | 85 | [issues]: https://github.com/rust-cli/roff-rs/issues 86 | [new issue]: https://github.com/rust-cli/roff-rs/issues/new 87 | [all issues]: https://github.com/rust-cli/roff-rs/issues?utf8=%E2%9C%93&q=is%3Aissue 88 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | [workspace.package] 5 | repository = "https://github.com/rust-cli/roff-rs" 6 | license = "MIT OR Apache-2.0" 7 | edition = "2021" 8 | rust-version = "1.70" # MSRV 9 | include = [ 10 | "build.rs", 11 | "src/**/*", 12 | "Cargo.toml", 13 | "Cargo.lock", 14 | "LICENSE*", 15 | "README.md", 16 | "examples/**/*" 17 | ] 18 | 19 | [workspace.lints.rust] 20 | rust_2018_idioms = { level = "warn", priority = -1 } 21 | unnameable_types = "warn" 22 | unreachable_pub = "warn" 23 | unsafe_op_in_unsafe_fn = "warn" 24 | unused_lifetimes = "warn" 25 | unused_macro_rules = "warn" 26 | unused_qualifications = "warn" 27 | 28 | [workspace.lints.clippy] 29 | bool_assert_comparison = "allow" 30 | branches_sharing_code = "allow" 31 | checked_conversions = "warn" 32 | collapsible_else_if = "allow" 33 | create_dir = "warn" 34 | dbg_macro = "warn" 35 | debug_assert_with_mut_call = "warn" 36 | doc_markdown = "warn" 37 | empty_enum = "warn" 38 | enum_glob_use = "warn" 39 | expl_impl_clone_on_copy = "warn" 40 | explicit_deref_methods = "warn" 41 | explicit_into_iter_loop = "warn" 42 | fallible_impl_from = "warn" 43 | filter_map_next = "warn" 44 | flat_map_option = "warn" 45 | float_cmp_const = "warn" 46 | fn_params_excessive_bools = "warn" 47 | from_iter_instead_of_collect = "warn" 48 | if_same_then_else = "allow" 49 | implicit_clone = "warn" 50 | imprecise_flops = "warn" 51 | inconsistent_struct_constructor = "warn" 52 | inefficient_to_string = "warn" 53 | infinite_loop = "warn" 54 | invalid_upcast_comparisons = "warn" 55 | large_digit_groups = "warn" 56 | large_stack_arrays = "warn" 57 | large_types_passed_by_value = "warn" 58 | let_and_return = "allow" # sometimes good to name what you are returning 59 | linkedlist = "warn" 60 | lossy_float_literal = "warn" 61 | macro_use_imports = "warn" 62 | mem_forget = "warn" 63 | mutex_integer = "warn" 64 | needless_continue = "allow" 65 | needless_for_each = "warn" 66 | negative_feature_names = "warn" 67 | path_buf_push_overwrite = "warn" 68 | ptr_as_ptr = "warn" 69 | rc_mutex = "warn" 70 | redundant_feature_names = "warn" 71 | ref_option_ref = "warn" 72 | rest_pat_in_fully_bound_structs = "warn" 73 | result_large_err = "allow" 74 | same_functions_in_if_condition = "warn" 75 | self_named_module_files = "warn" 76 | semicolon_if_nothing_returned = "warn" 77 | str_to_string = "warn" 78 | string_add = "warn" 79 | string_add_assign = "warn" 80 | string_lit_as_bytes = "warn" 81 | string_to_string = "warn" 82 | todo = "warn" 83 | trait_duplication_in_bounds = "warn" 84 | uninlined_format_args = "warn" 85 | verbose_file_reads = "warn" 86 | wildcard_imports = "warn" 87 | zero_sized_map_values = "warn" 88 | 89 | [profile.dev] 90 | panic = "abort" 91 | 92 | [profile.release] 93 | panic = "abort" 94 | codegen-units = 1 95 | lto = true 96 | # debug = "line-tables-only" # requires Cargo 1.71 97 | 98 | [package] 99 | name = "roff" 100 | version = "0.2.2" 101 | description = "ROFF (man page format) generation library" 102 | homepage = "https://github.com/rust-cli/roff-rs" 103 | documentation = "https://docs.rs/roff" 104 | readme = "README.md" 105 | categories = ["development-tools::testing"] 106 | repository.workspace = true 107 | license.workspace = true 108 | edition.workspace = true 109 | rust-version.workspace = true 110 | include.workspace = true 111 | 112 | [package.metadata.docs.rs] 113 | all-features = true 114 | rustdoc-args = ["--generate-link-to-definition"] 115 | 116 | [package.metadata.release] 117 | pre-release-replacements = [ 118 | {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, 119 | {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, 120 | {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, 121 | {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, 122 | {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/rust-cli/roff-rs/compare/{{tag_name}}...HEAD", exactly=1}, 123 | ] 124 | 125 | [dependencies] 126 | 127 | [dev-dependencies] 128 | pretty_assertions = "1.4.0" 129 | duct = "1.0" 130 | automod = "1.0.14" 131 | 132 | [lints] 133 | workspace = true 134 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | pull_request: 8 | push: 9 | branches: 10 | - master 11 | 12 | env: 13 | RUST_BACKTRACE: 1 14 | CARGO_TERM_COLOR: always 15 | CLICOLOR: 1 16 | 17 | concurrency: 18 | group: "${{ github.workflow }}-${{ github.ref }}" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | ci: 23 | permissions: 24 | contents: none 25 | name: CI 26 | needs: [test, msrv, lockfile, docs, rustfmt, clippy, minimal-versions] 27 | runs-on: ubuntu-latest 28 | if: "always()" 29 | steps: 30 | - name: Failed 31 | run: exit 1 32 | if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')" 33 | test: 34 | name: Test 35 | strategy: 36 | matrix: 37 | # TODO: Get working on all platforms 38 | #os: ["ubuntu-latest", "windows-latest", "macos-latest"] 39 | os: ["ubuntu-latest"] 40 | rust: ["stable"] 41 | continue-on-error: ${{ matrix.rust != 'stable' }} 42 | runs-on: ${{ matrix.os }} 43 | env: 44 | # Reduce amount of data cached 45 | CARGO_PROFILE_DEV_DEBUG: line-tables-only 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v6 49 | - name: Install Rust 50 | uses: dtolnay/rust-toolchain@stable 51 | with: 52 | toolchain: ${{ matrix.rust }} 53 | - uses: Swatinem/rust-cache@v2 54 | - uses: taiki-e/install-action@cargo-hack 55 | - name: Build 56 | run: cargo test --workspace --no-run 57 | - name: Test 58 | run: cargo hack test --each-feature --workspace 59 | msrv: 60 | name: "Check MSRV" 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Checkout repository 64 | uses: actions/checkout@v6 65 | - name: Install Rust 66 | uses: dtolnay/rust-toolchain@stable 67 | with: 68 | toolchain: stable 69 | - uses: Swatinem/rust-cache@v2 70 | - uses: taiki-e/install-action@cargo-hack 71 | - name: Default features 72 | run: cargo hack check --each-feature --locked --rust-version --ignore-private --workspace --all-targets --keep-going 73 | minimal-versions: 74 | name: Minimal versions 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Checkout repository 78 | uses: actions/checkout@v6 79 | - name: Install stable Rust 80 | uses: dtolnay/rust-toolchain@stable 81 | with: 82 | toolchain: stable 83 | - name: Install nightly Rust 84 | uses: dtolnay/rust-toolchain@stable 85 | with: 86 | toolchain: nightly 87 | - name: Downgrade dependencies to minimal versions 88 | run: cargo +nightly generate-lockfile -Z minimal-versions 89 | - name: Compile with minimal versions 90 | run: cargo +stable check --workspace --all-features --locked --keep-going 91 | lockfile: 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: Checkout repository 95 | uses: actions/checkout@v6 96 | - name: Install Rust 97 | uses: dtolnay/rust-toolchain@stable 98 | with: 99 | toolchain: stable 100 | - uses: Swatinem/rust-cache@v2 101 | - name: "Is lockfile updated?" 102 | run: cargo update --workspace --locked 103 | docs: 104 | name: Docs 105 | runs-on: ubuntu-latest 106 | steps: 107 | - name: Checkout repository 108 | uses: actions/checkout@v6 109 | - name: Install Rust 110 | uses: dtolnay/rust-toolchain@stable 111 | with: 112 | toolchain: "1.92" # STABLE 113 | - uses: Swatinem/rust-cache@v2 114 | - name: Check documentation 115 | env: 116 | RUSTDOCFLAGS: -D warnings 117 | run: cargo doc --workspace --all-features --no-deps --document-private-items --keep-going 118 | rustfmt: 119 | name: rustfmt 120 | runs-on: ubuntu-latest 121 | steps: 122 | - name: Checkout repository 123 | uses: actions/checkout@v6 124 | - name: Install Rust 125 | uses: dtolnay/rust-toolchain@stable 126 | with: 127 | toolchain: "1.92" # STABLE 128 | components: rustfmt 129 | - uses: Swatinem/rust-cache@v2 130 | - name: Check formatting 131 | run: cargo fmt --all -- --check 132 | clippy: 133 | name: clippy 134 | runs-on: ubuntu-latest 135 | permissions: 136 | security-events: write # to upload sarif results 137 | steps: 138 | - name: Checkout repository 139 | uses: actions/checkout@v6 140 | - name: Install Rust 141 | uses: dtolnay/rust-toolchain@stable 142 | with: 143 | toolchain: "1.92" # STABLE 144 | components: clippy 145 | - uses: Swatinem/rust-cache@v2 146 | - name: Install SARIF tools 147 | run: cargo install clippy-sarif --locked 148 | - name: Install SARIF tools 149 | run: cargo install sarif-fmt --locked 150 | - name: Check 151 | run: > 152 | cargo clippy --workspace --all-features --all-targets --message-format=json 153 | | clippy-sarif 154 | | tee clippy-results.sarif 155 | | sarif-fmt 156 | continue-on-error: true 157 | - name: Upload 158 | uses: github/codeql-action/upload-sarif@v4 159 | with: 160 | sarif_file: clippy-results.sarif 161 | wait-for-processing: true 162 | - name: Report status 163 | run: cargo clippy --workspace --all-features --all-targets --keep-going -- -D warnings --allow deprecated 164 | coverage: 165 | name: Coverage 166 | runs-on: ubuntu-latest 167 | steps: 168 | - name: Checkout repository 169 | uses: actions/checkout@v6 170 | - name: Install Rust 171 | uses: dtolnay/rust-toolchain@stable 172 | with: 173 | toolchain: stable 174 | - uses: Swatinem/rust-cache@v2 175 | - name: Install cargo-tarpaulin 176 | run: cargo install cargo-tarpaulin 177 | - name: Gather coverage 178 | run: cargo tarpaulin --output-dir coverage --out lcov 179 | - name: Publish to Coveralls 180 | uses: coverallsapp/github-action@master 181 | with: 182 | github-token: ${{ secrets.GITHUB_TOKEN }} 183 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "automod" 7 | version = "1.0.15" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "ebb4bd301db2e2ca1f5be131c24eb8ebf2d9559bc3744419e93baf8ddea7e670" 10 | dependencies = [ 11 | "proc-macro2", 12 | "quote", 13 | "syn", 14 | ] 15 | 16 | [[package]] 17 | name = "diff" 18 | version = "0.1.13" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 21 | 22 | [[package]] 23 | name = "duct" 24 | version = "1.1.1" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "7e66e9c0c03d094e1a0ba1be130b849034aa80c3a2ab8ee94316bc809f3fa684" 27 | dependencies = [ 28 | "libc", 29 | "os_pipe", 30 | "shared_child", 31 | "shared_thread", 32 | ] 33 | 34 | [[package]] 35 | name = "libc" 36 | version = "0.2.177" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 39 | 40 | [[package]] 41 | name = "os_pipe" 42 | version = "1.2.3" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" 45 | dependencies = [ 46 | "libc", 47 | "windows-sys", 48 | ] 49 | 50 | [[package]] 51 | name = "pretty_assertions" 52 | version = "1.4.1" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" 55 | dependencies = [ 56 | "diff", 57 | "yansi", 58 | ] 59 | 60 | [[package]] 61 | name = "proc-macro2" 62 | version = "1.0.81" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" 65 | dependencies = [ 66 | "unicode-ident", 67 | ] 68 | 69 | [[package]] 70 | name = "quote" 71 | version = "1.0.36" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 74 | dependencies = [ 75 | "proc-macro2", 76 | ] 77 | 78 | [[package]] 79 | name = "roff" 80 | version = "0.2.2" 81 | dependencies = [ 82 | "automod", 83 | "duct", 84 | "pretty_assertions", 85 | ] 86 | 87 | [[package]] 88 | name = "shared_child" 89 | version = "1.1.0" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "c2778001df1384cf20b6dc5a5a90f48da35539885edaaefd887f8d744e939c0b" 92 | dependencies = [ 93 | "libc", 94 | "sigchld", 95 | "windows-sys", 96 | ] 97 | 98 | [[package]] 99 | name = "shared_thread" 100 | version = "0.2.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "52b86057fcb5423f5018e331ac04623e32d6b5ce85e33300f92c79a1973928b0" 103 | 104 | [[package]] 105 | name = "sigchld" 106 | version = "0.2.4" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" 109 | dependencies = [ 110 | "libc", 111 | "os_pipe", 112 | "signal-hook", 113 | ] 114 | 115 | [[package]] 116 | name = "signal-hook" 117 | version = "0.3.18" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 120 | dependencies = [ 121 | "libc", 122 | "signal-hook-registry", 123 | ] 124 | 125 | [[package]] 126 | name = "signal-hook-registry" 127 | version = "1.4.6" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" 130 | dependencies = [ 131 | "libc", 132 | ] 133 | 134 | [[package]] 135 | name = "syn" 136 | version = "2.0.60" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" 139 | dependencies = [ 140 | "proc-macro2", 141 | "quote", 142 | "unicode-ident", 143 | ] 144 | 145 | [[package]] 146 | name = "unicode-ident" 147 | version = "1.0.12" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 150 | 151 | [[package]] 152 | name = "windows-link" 153 | version = "0.2.1" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 156 | 157 | [[package]] 158 | name = "windows-sys" 159 | version = "0.60.2" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 162 | dependencies = [ 163 | "windows-targets", 164 | ] 165 | 166 | [[package]] 167 | name = "windows-targets" 168 | version = "0.53.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 171 | dependencies = [ 172 | "windows-link", 173 | "windows_aarch64_gnullvm", 174 | "windows_aarch64_msvc", 175 | "windows_i686_gnu", 176 | "windows_i686_gnullvm", 177 | "windows_i686_msvc", 178 | "windows_x86_64_gnu", 179 | "windows_x86_64_gnullvm", 180 | "windows_x86_64_msvc", 181 | ] 182 | 183 | [[package]] 184 | name = "windows_aarch64_gnullvm" 185 | version = "0.53.1" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 188 | 189 | [[package]] 190 | name = "windows_aarch64_msvc" 191 | version = "0.53.1" 192 | source = "registry+https://github.com/rust-lang/crates.io-index" 193 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 194 | 195 | [[package]] 196 | name = "windows_i686_gnu" 197 | version = "0.53.1" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 200 | 201 | [[package]] 202 | name = "windows_i686_gnullvm" 203 | version = "0.53.1" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 206 | 207 | [[package]] 208 | name = "windows_i686_msvc" 209 | version = "0.53.1" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 212 | 213 | [[package]] 214 | name = "windows_x86_64_gnu" 215 | version = "0.53.1" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 218 | 219 | [[package]] 220 | name = "windows_x86_64_gnullvm" 221 | version = "0.53.1" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 224 | 225 | [[package]] 226 | name = "windows_x86_64_msvc" 227 | version = "0.53.1" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 230 | 231 | [[package]] 232 | name = "yansi" 233 | version = "1.0.1" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 236 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # Note that all fields that take a lint level have these possible values: 2 | # * deny - An error will be produced and the check will fail 3 | # * warn - A warning will be produced, but the check will not fail 4 | # * allow - No warning or error will be produced, though in some cases a note 5 | # will be 6 | 7 | # Root options 8 | 9 | # The graph table configures how the dependency graph is constructed and thus 10 | # which crates the checks are performed against 11 | [graph] 12 | # If 1 or more target triples (and optionally, target_features) are specified, 13 | # only the specified targets will be checked when running `cargo deny check`. 14 | # This means, if a particular package is only ever used as a target specific 15 | # dependency, such as, for example, the `nix` crate only being used via the 16 | # `target_family = "unix"` configuration, that only having windows targets in 17 | # this list would mean the nix crate, as well as any of its exclusive 18 | # dependencies not shared by any other crates, would be ignored, as the target 19 | # list here is effectively saying which targets you are building for. 20 | targets = [ 21 | # The triple can be any string, but only the target triples built in to 22 | # rustc (as of 1.40) can be checked against actual config expressions 23 | #"x86_64-unknown-linux-musl", 24 | # You can also specify which target_features you promise are enabled for a 25 | # particular target. target_features are currently not validated against 26 | # the actual valid features supported by the target architecture. 27 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 28 | ] 29 | # When creating the dependency graph used as the source of truth when checks are 30 | # executed, this field can be used to prune crates from the graph, removing them 31 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 32 | # is pruned from the graph, all of its dependencies will also be pruned unless 33 | # they are connected to another crate in the graph that hasn't been pruned, 34 | # so it should be used with care. The identifiers are [Package ID Specifications] 35 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 36 | #exclude = [] 37 | # If true, metadata will be collected with `--all-features`. Note that this can't 38 | # be toggled off if true, if you want to conditionally enable `--all-features` it 39 | # is recommended to pass `--all-features` on the cmd line instead 40 | all-features = false 41 | # If true, metadata will be collected with `--no-default-features`. The same 42 | # caveat with `all-features` applies 43 | no-default-features = false 44 | # If set, these feature will be enabled when collecting metadata. If `--features` 45 | # is specified on the cmd line they will take precedence over this option. 46 | #features = [] 47 | 48 | # The output table provides options for how/if diagnostics are outputted 49 | [output] 50 | # When outputting inclusion graphs in diagnostics that include features, this 51 | # option can be used to specify the depth at which feature edges will be added. 52 | # This option is included since the graphs can be quite large and the addition 53 | # of features from the crate(s) to all of the graph roots can be far too verbose. 54 | # This option can be overridden via `--feature-depth` on the cmd line 55 | feature-depth = 1 56 | 57 | # This section is considered when running `cargo deny check advisories` 58 | # More documentation for the advisories section can be found here: 59 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 60 | [advisories] 61 | # The path where the advisory databases are cloned/fetched into 62 | #db-path = "$CARGO_HOME/advisory-dbs" 63 | # The url(s) of the advisory databases to use 64 | #db-urls = ["https://github.com/rustsec/advisory-db"] 65 | # A list of advisory IDs to ignore. Note that ignored advisories will still 66 | # output a note when they are encountered. 67 | ignore = [ 68 | #"RUSTSEC-0000-0000", 69 | #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, 70 | #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish 71 | #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, 72 | ] 73 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 74 | # If this is false, then it uses a built-in git library. 75 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 76 | # See Git Authentication for more information about setting up git authentication. 77 | #git-fetch-with-cli = true 78 | 79 | # This section is considered when running `cargo deny check licenses` 80 | # More documentation for the licenses section can be found here: 81 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 82 | [licenses] 83 | # List of explicitly allowed licenses 84 | # See https://spdx.org/licenses/ for list of possible licenses 85 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 86 | allow = [ 87 | "MIT", 88 | "MIT-0", 89 | "Apache-2.0", 90 | "BSD-2-Clause", 91 | "BSD-3-Clause", 92 | "MPL-2.0", 93 | "Unicode-DFS-2016", 94 | "Unicode-3.0", 95 | "CC0-1.0", 96 | "ISC", 97 | "OpenSSL", 98 | "Zlib", 99 | "NCSA", 100 | ] 101 | # The confidence threshold for detecting a license from license text. 102 | # The higher the value, the more closely the license text must be to the 103 | # canonical license text of a valid SPDX license file. 104 | # [possible values: any between 0.0 and 1.0]. 105 | confidence-threshold = 0.8 106 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 107 | # aren't accepted for every possible crate as with the normal allow list 108 | exceptions = [ 109 | # Each entry is the crate and version constraint, and its specific allow 110 | # list 111 | #{ allow = ["Zlib"], crate = "adler32" }, 112 | ] 113 | 114 | # Some crates don't have (easily) machine readable licensing information, 115 | # adding a clarification entry for it allows you to manually specify the 116 | # licensing information 117 | [[licenses.clarify]] 118 | # The package spec the clarification applies to 119 | crate = "ring" 120 | # The SPDX expression for the license requirements of the crate 121 | expression = "MIT AND ISC AND OpenSSL" 122 | # One or more files in the crate's source used as the "source of truth" for 123 | # the license expression. If the contents match, the clarification will be used 124 | # when running the license check, otherwise the clarification will be ignored 125 | # and the crate will be checked normally, which may produce warnings or errors 126 | # depending on the rest of your configuration 127 | license-files = [ 128 | # Each entry is a crate relative path, and the (opaque) hash of its contents 129 | { path = "LICENSE", hash = 0xbd0eed23 } 130 | ] 131 | 132 | [licenses.private] 133 | # If true, ignores workspace crates that aren't published, or are only 134 | # published to private registries. 135 | # To see how to mark a crate as unpublished (to the official registry), 136 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 137 | ignore = true 138 | # One or more private registries that you might publish crates to, if a crate 139 | # is only published to private registries, and ignore is true, the crate will 140 | # not have its license(s) checked 141 | registries = [ 142 | #"https://sekretz.com/registry 143 | ] 144 | 145 | # This section is considered when running `cargo deny check bans`. 146 | # More documentation about the 'bans' section can be found here: 147 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 148 | [bans] 149 | # Lint level for when multiple versions of the same crate are detected 150 | multiple-versions = "warn" 151 | # Lint level for when a crate version requirement is `*` 152 | wildcards = "allow" 153 | # The graph highlighting used when creating dotgraphs for crates 154 | # with multiple versions 155 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 156 | # * simplest-path - The path to the version with the fewest edges is highlighted 157 | # * all - Both lowest-version and simplest-path are used 158 | highlight = "all" 159 | # The default lint level for `default` features for crates that are members of 160 | # the workspace that is being checked. This can be overridden by allowing/denying 161 | # `default` on a crate-by-crate basis if desired. 162 | workspace-default-features = "allow" 163 | # The default lint level for `default` features for external crates that are not 164 | # members of the workspace. This can be overridden by allowing/denying `default` 165 | # on a crate-by-crate basis if desired. 166 | external-default-features = "allow" 167 | # List of crates that are allowed. Use with care! 168 | allow = [ 169 | #"ansi_term@0.11.0", 170 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, 171 | ] 172 | # List of crates to deny 173 | deny = [ 174 | #"ansi_term@0.11.0", 175 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, 176 | # Wrapper crates can optionally be specified to allow the crate when it 177 | # is a direct dependency of the otherwise banned crate 178 | #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, 179 | ] 180 | 181 | # List of features to allow/deny 182 | # Each entry the name of a crate and a version range. If version is 183 | # not specified, all versions will be matched. 184 | #[[bans.features]] 185 | #crate = "reqwest" 186 | # Features to not allow 187 | #deny = ["json"] 188 | # Features to allow 189 | #allow = [ 190 | # "rustls", 191 | # "__rustls", 192 | # "__tls", 193 | # "hyper-rustls", 194 | # "rustls", 195 | # "rustls-pemfile", 196 | # "rustls-tls-webpki-roots", 197 | # "tokio-rustls", 198 | # "webpki-roots", 199 | #] 200 | # If true, the allowed features must exactly match the enabled feature set. If 201 | # this is set there is no point setting `deny` 202 | #exact = true 203 | 204 | # Certain crates/versions that will be skipped when doing duplicate detection. 205 | skip = [ 206 | #"ansi_term@0.11.0", 207 | #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, 208 | ] 209 | # Similarly to `skip` allows you to skip certain crates during duplicate 210 | # detection. Unlike skip, it also includes the entire tree of transitive 211 | # dependencies starting at the specified crate, up to a certain depth, which is 212 | # by default infinite. 213 | skip-tree = [ 214 | #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies 215 | #{ crate = "ansi_term@0.11.0", depth = 20 }, 216 | ] 217 | 218 | # This section is considered when running `cargo deny check sources`. 219 | # More documentation about the 'sources' section can be found here: 220 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 221 | [sources] 222 | # Lint level for what to happen when a crate from a crate registry that is not 223 | # in the allow list is encountered 224 | unknown-registry = "deny" 225 | # Lint level for what to happen when a crate from a git repository that is not 226 | # in the allow list is encountered 227 | unknown-git = "deny" 228 | # List of URLs for allowed crate registries. Defaults to the crates.io index 229 | # if not specified. If it is specified but empty, no registries are allowed. 230 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 231 | # List of URLs for allowed Git repositories 232 | allow-git = [] 233 | 234 | [sources.allow-org] 235 | # 1 or more github.com organizations to allow git sources for 236 | github = [] 237 | # 1 or more gitlab.com organizations to allow git sources for 238 | gitlab = [] 239 | # 1 or more bitbucket.org organizations to allow git sources for 240 | bitbucket = [] 241 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A document in the ROFF format. 2 | //! 3 | //! [ROFF] is a family of Unix text-formatting languages, implemented 4 | //! by the `nroff`, `troff`, and `groff` programs, among others. See 5 | //! [groff(7)] for a description of the language. This structure is an 6 | //! abstract representation of a document in ROFF format. It is meant 7 | //! for writing code to generate ROFF documents, such as manual pages. 8 | //! 9 | //! # Example 10 | //! 11 | //! ``` 12 | //! # use roff::*; 13 | //! let doc = Roff::new().text(vec![roman("hello, world")]).render(); 14 | //! assert!(doc.ends_with("hello, world\n")); 15 | //! ``` 16 | //! 17 | //! [ROFF]: https://en.wikipedia.org/wiki/Roff_(software) 18 | //! [groff(7)]: https://manpages.debian.org/bullseye/groff/groff.7.en.html 19 | 20 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 21 | #![cfg_attr(docsrs, feature(doc_cfg))] 22 | #![warn(missing_docs)] 23 | #![warn(clippy::print_stderr)] 24 | #![warn(clippy::print_stdout)] 25 | 26 | use std::io::Write; 27 | use std::write; 28 | 29 | /// A ROFF document, consisting of lines. 30 | /// 31 | /// Lines are either control lines (requests that are built in, or 32 | /// invocations of macros), or text lines. 33 | /// 34 | /// # Example 35 | /// 36 | /// ``` 37 | /// # use roff::*; 38 | /// let doc = Roff::new() 39 | /// .control("TH", ["FOO", "1"]) 40 | /// .control("SH", ["NAME"]) 41 | /// .text([roman("foo - do a foo thing")]) 42 | /// .render(); 43 | /// assert!(doc.ends_with(".TH FOO 1\n.SH NAME\nfoo \\- do a foo thing\n")); 44 | /// ``` 45 | #[derive(Debug, PartialEq, Eq, Default)] 46 | pub struct Roff { 47 | lines: Vec, 48 | } 49 | 50 | impl Roff { 51 | /// Instantiate a `Roff` 52 | pub fn new() -> Self { 53 | Default::default() 54 | } 55 | 56 | /// Append a control line. 57 | /// 58 | /// The line consist of the name of a built-in command or macro, 59 | /// and some number of arguments. Arguments that contain spaces 60 | /// will be enclosed with double quotation marks. 61 | pub fn control<'a>( 62 | &mut self, 63 | name: impl Into, 64 | args: impl IntoIterator, 65 | ) -> &mut Self { 66 | self.lines.push(Line::control( 67 | name.into(), 68 | args.into_iter().map(|s| s.to_owned()).collect(), 69 | )); 70 | self 71 | } 72 | 73 | /// Append a text line. 74 | /// 75 | /// The line will be rendered in a way that ensures it can't be 76 | /// interpreted as a control line. The caller does not need to 77 | /// ensure, for example, that the line doesn't start with a 78 | /// period ("`.`") or an apostrophe ("`'`"). 79 | pub fn text(&mut self, inlines: impl Into>) -> &mut Self { 80 | self.lines.push(Line::text(inlines.into())); 81 | self 82 | } 83 | 84 | /// Render as ROFF source text that can be fed to a ROFF implementation. 85 | pub fn render(&self) -> String { 86 | let mut buf = vec![]; 87 | self.to_writer(&mut buf).unwrap(); // writing to a Vec always works 88 | String::from_utf8(buf) 89 | .expect("output is utf8 if all input is utf8 and our API guarantees that") 90 | } 91 | 92 | /// Write to a writer. 93 | pub fn to_writer(&self, w: &mut dyn Write) -> Result<(), std::io::Error> { 94 | w.write_all(APOSTROPHE_PREABMLE.as_bytes())?; 95 | for line in self.lines.iter() { 96 | line.render(w, Apostrophes::Handle)?; 97 | } 98 | Ok(()) 99 | } 100 | 101 | /// Render without handling apostrophes specially. 102 | /// 103 | /// You probably want [`render`](Roff::render) or 104 | /// [`to_writer`](Roff::to_writer) instead of this method. 105 | /// 106 | /// Without special handling, apostrophes get typeset as right 107 | /// single quotes, including in words like "don't". In most 108 | /// situations, such as in manual pages, that's unwanted. The 109 | /// other methods handle apostrophes specially to prevent it, but 110 | /// for completeness, and for testing, this method is provided to 111 | /// avoid it. 112 | pub fn to_roff(&self) -> String { 113 | let mut buf = vec![]; 114 | for line in self.lines.iter() { 115 | // Writing to a Vec always works, so we discard any error. 116 | line.render(&mut buf, Apostrophes::DontHandle).unwrap(); 117 | } 118 | String::from_utf8(buf) 119 | .expect("output is utf8 if all input is utf8 and our API guarantees that") 120 | } 121 | } 122 | 123 | impl> From for Roff { 124 | fn from(other: I) -> Self { 125 | let mut r = Roff::new(); 126 | r.text([other.into()]); 127 | r 128 | } 129 | } 130 | 131 | impl> FromIterator for Roff { 132 | fn from_iter>(iter: I) -> Self { 133 | let mut r = Roff::new(); 134 | for i in iter { 135 | r.lines.extend(i.into().lines); 136 | } 137 | r 138 | } 139 | } 140 | 141 | impl> Extend for Roff { 142 | fn extend>(&mut self, iter: T) { 143 | for i in iter { 144 | self.lines.extend(i.into().lines); 145 | } 146 | } 147 | } 148 | 149 | /// A part of a text line. 150 | /// 151 | /// Text will be escaped for ROFF. No inline escape sequences will be 152 | /// passed to ROFF. The text may contain newlines, but leading periods 153 | /// will be escaped so that they won't be interpreted by ROFF as 154 | /// control lines. 155 | /// 156 | /// Note that the strings stored in the variants are stored as they're 157 | /// received from the API user. The `Line::render` function handles 158 | /// escaping etc. 159 | #[derive(Debug, PartialEq, Eq, Clone)] 160 | pub enum Inline { 161 | /// Text in the "roman" font, which is the normal font if nothing 162 | /// else is specified. 163 | Roman(String), 164 | 165 | /// Text in the italic (slanted) font. 166 | Italic(String), 167 | 168 | /// Text in a bold face font. 169 | Bold(String), 170 | 171 | /// A hard line break. This is an inline element so it's easy to 172 | /// insert a line break in a paragraph. 173 | LineBreak, 174 | } 175 | 176 | /// Turn a string slice into inline text in the roman font. 177 | /// 178 | /// This is equivalent to the [roman] function, but may be more 179 | /// convenient to use. 180 | impl> From for Inline { 181 | fn from(s: S) -> Self { 182 | roman(s) 183 | } 184 | } 185 | 186 | /// Return some inline text in the "roman" font. 187 | /// 188 | /// The roman font is the normal font, if no other font is chosen. 189 | pub fn roman(input: impl Into) -> Inline { 190 | Inline::Roman(input.into()) 191 | } 192 | 193 | /// Return some inline text in the bold font. 194 | pub fn bold(input: impl Into) -> Inline { 195 | Inline::Bold(input.into()) 196 | } 197 | 198 | /// Return some inline text in the italic font. 199 | pub fn italic(input: impl Into) -> Inline { 200 | Inline::Italic(input.into()) 201 | } 202 | 203 | /// Return an inline element for a hard line break. 204 | pub fn line_break() -> Inline { 205 | Inline::LineBreak 206 | } 207 | 208 | /// A line in a ROFF document. 209 | #[derive(Debug, PartialEq, Eq, Clone)] 210 | pub(crate) enum Line { 211 | /// A control line. 212 | Control { 213 | /// Name of control request or macro being invoked. 214 | name: String, 215 | 216 | /// Arguments on control line. 217 | args: Vec, 218 | }, 219 | 220 | /// A text line. 221 | Text(Vec), 222 | } 223 | 224 | impl Line { 225 | /// Append a control line. 226 | pub(crate) fn control(name: String, args: Vec) -> Self { 227 | Self::Control { name, args } 228 | } 229 | 230 | /// Append a text line, consisting of inline elements. 231 | pub(crate) fn text(parts: Vec) -> Self { 232 | Self::Text(parts) 233 | } 234 | 235 | /// Generate a ROFF line. 236 | /// 237 | /// All the ROFF code generation and special handling happens here. 238 | fn render( 239 | &self, 240 | out: &mut dyn Write, 241 | handle_apostrophes: Apostrophes, 242 | ) -> Result<(), std::io::Error> { 243 | match self { 244 | Self::Control { name, args } => { 245 | write!(out, ".{name}")?; 246 | for arg in args { 247 | write!(out, " {}", &escape_spaces(arg))?; 248 | } 249 | } 250 | Self::Text(inlines) => { 251 | let mut at_line_start = true; 252 | for inline in inlines.iter() { 253 | // We need to handle line breaking specially: it 254 | // introduces a control line to the ROFF, and the 255 | // leading period of that mustn't be escaped. 256 | match inline { 257 | Inline::LineBreak => { 258 | if at_line_start { 259 | writeln!(out, ".br")?; 260 | } else { 261 | writeln!(out, "\n.br")?; 262 | } 263 | } 264 | Inline::Roman(text) | Inline::Italic(text) | Inline::Bold(text) => { 265 | let mut text = escape_inline(text); 266 | if handle_apostrophes == Apostrophes::Handle { 267 | text = escape_apostrophes(&text); 268 | }; 269 | let text = escape_leading_cc(&text); 270 | if let Inline::Bold(_) = inline { 271 | write!(out, r"\fB{text}\fR")?; 272 | } else if let Inline::Italic(_) = inline { 273 | write!(out, r"\fI{text}\fR")?; 274 | } else { 275 | if at_line_start && starts_with_cc(&text) { 276 | // Line would start with a period, so we 277 | // insert a non-printable, zero-width glyph to 278 | // prevent it from being interpreted as such. 279 | // We only do that when it's needed, though, 280 | // to avoid making the output ugly. 281 | // 282 | // Note that this isn't handled by 283 | // escape_leading_cc, as it 284 | // doesn't know when an inline 285 | // element is at the start of a 286 | // line. 287 | write!(out, r"\&").unwrap(); 288 | } 289 | write!(out, "{text}")?; 290 | } 291 | } 292 | } 293 | at_line_start = false; 294 | } 295 | } 296 | }; 297 | writeln!(out)?; 298 | Ok(()) 299 | } 300 | } 301 | 302 | /// Does line start with a control character? 303 | fn starts_with_cc(line: &str) -> bool { 304 | line.starts_with('.') || line.starts_with('\'') 305 | } 306 | 307 | /// This quotes strings with spaces. This doesn't handle strings with 308 | /// quotes in any way: there doesn't seem to a way to escape them. 309 | fn escape_spaces(w: &str) -> String { 310 | if w.contains(' ') { 311 | format!("\"{w}\"") 312 | } else { 313 | w.to_owned() 314 | } 315 | } 316 | 317 | /// Prevent leading periods or apostrophes on lines to be interpreted 318 | /// as control lines. Note that this needs to be done for apostrophes 319 | /// whether they need special handling for typesetting or not: a 320 | /// leading apostrophe on a line indicates a control line. 321 | fn escape_leading_cc(s: &str) -> String { 322 | s.replace("\n.", "\n\\&.").replace("\n'", "\n\\&'") 323 | } 324 | 325 | /// Escape anything that may be interpreted by the roff processor in a 326 | /// text line: dashes and backslashes are escaped with a backslash. 327 | /// Apostrophes are not handled. 328 | fn escape_inline(text: &str) -> String { 329 | text.replace('\\', r"\\").replace('-', r"\-") 330 | } 331 | 332 | /// Handle apostrophes. 333 | fn escape_apostrophes(text: &str) -> String { 334 | text.replace('\'', APOSTROPHE) 335 | } 336 | 337 | #[derive(Eq, PartialEq)] 338 | enum Apostrophes { 339 | Handle, 340 | DontHandle, 341 | } 342 | 343 | /// Use the apostrophe string variable. 344 | const APOSTROPHE: &str = r"\*(Aq"; 345 | 346 | /// A preamble added to the start of rendered output. 347 | /// 348 | /// This defines a string variable that contains an apostrophe. For 349 | /// historical reasons, there seems to be no other portable way to 350 | /// represent apostrophes across various implementations of the ROFF 351 | /// language. In implementations that produce output like `PostScript` 352 | /// or PDF, an apostrophe gets typeset as a right single quote, which 353 | /// looks different from an apostrophe. For terminal output ("ASCII"), 354 | /// such as when using nroff, an apostrophe looks indistinguishable 355 | /// from a right single quote. For manual pages, and similar content, 356 | /// an apostrophe is more generally desired than the right single 357 | /// quote, so we convert all apostrophe characters in input text into 358 | /// a use of the string variable defined in the preamble. 359 | /// 360 | /// The special handling of apostrophes is avoided in the 361 | /// [`to_roff`](Roff::to_roff) method, but it's used in the 362 | /// [`render`](Roff::render) and [`to_writer`](Roff::to_writer) 363 | /// methods. 364 | /// 365 | /// See: 366 | const APOSTROPHE_PREABMLE: &str = r#".ie \n(.g .ds Aq \(aq 367 | .el .ds Aq ' 368 | "#; 369 | 370 | #[cfg(test)] 371 | mod test { 372 | use super::*; 373 | 374 | #[test] 375 | fn escape_dash() { 376 | assert_eq!(r"\-", escape_inline("-")); 377 | } 378 | 379 | #[test] 380 | fn escape_backslash() { 381 | assert_eq!(r"\\x", escape_inline(r"\x")); 382 | } 383 | 384 | #[test] 385 | fn escape_backslash_and_dash() { 386 | assert_eq!(r"\\\-", escape_inline(r"\-")); 387 | } 388 | 389 | #[test] 390 | fn escapes_leading_control_chars() { 391 | assert_eq!("foo\n\\&.bar\n\\&'yo", escape_leading_cc("foo\n.bar\n'yo")); 392 | } 393 | 394 | #[test] 395 | fn escape_plain() { 396 | assert_eq!("abc", escape_inline("abc")); 397 | } 398 | 399 | #[test] 400 | fn render_roman() { 401 | let text = Roff::new().text([roman("foo")]).to_roff(); 402 | assert_eq!(text, "foo\n"); 403 | } 404 | 405 | #[test] 406 | fn render_dash() { 407 | let text = Roff::new().text([roman("foo-bar")]).to_roff(); 408 | assert_eq!(text, "foo\\-bar\n"); 409 | } 410 | 411 | #[test] 412 | fn render_italic() { 413 | let text = Roff::new().text([italic("foo")]).to_roff(); 414 | assert_eq!(text, "\\fIfoo\\fR\n"); 415 | } 416 | 417 | #[test] 418 | fn render_bold() { 419 | let text = Roff::new().text([bold("foo")]).to_roff(); 420 | assert_eq!(text, "\\fBfoo\\fR\n"); 421 | } 422 | 423 | #[test] 424 | fn render_text() { 425 | let text = Roff::new().text([roman("roman")]).to_roff(); 426 | assert_eq!(text, "roman\n"); 427 | } 428 | 429 | #[test] 430 | fn render_text_with_leading_period() { 431 | let text = Roff::new().text([roman(".roman")]).to_roff(); 432 | assert_eq!(text, "\\&.roman\n"); 433 | } 434 | 435 | #[test] 436 | fn render_text_with_newline_period() { 437 | let text = Roff::new().text([roman("foo\n.roman")]).to_roff(); 438 | assert_eq!(text, "foo\n\\&.roman\n"); 439 | } 440 | #[test] 441 | fn render_line_break() { 442 | let text = Roff::new() 443 | .text([roman("roman"), Inline::LineBreak, roman("more")]) 444 | .to_roff(); 445 | assert_eq!(text, "roman\n.br\nmore\n"); 446 | } 447 | 448 | #[test] 449 | fn render_control() { 450 | let text = Roff::new().control("foo", ["bar", "foo and bar"]).to_roff(); 451 | assert_eq!(text, ".foo bar \"foo and bar\"\n"); 452 | } 453 | } 454 | 455 | #[doc = include_str!("../README.md")] 456 | #[cfg(doctest)] 457 | pub struct ReadmeDoctests; 458 | --------------------------------------------------------------------------------