├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── gh-pages.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── RELEASING.md ├── benchmarks └── Dockerfile ├── completion ├── bash_tealdeer ├── fish_tealdeer └── zsh_tealdeer ├── deer.png ├── deer.svg ├── docs ├── .gitignore ├── README.md ├── book.toml └── src │ ├── SUMMARY.md │ ├── config.md │ ├── config_directories.md │ ├── config_display.md │ ├── config_style.md │ ├── config_updates.md │ ├── deer.png │ ├── deer.svg │ ├── installing.md │ ├── intro.md │ ├── screenshot-custom.png │ ├── screenshot-default.png │ ├── tips_and_tricks.md │ ├── usage.md │ ├── usage.txt │ └── usage_custom_pages.md ├── rustfmt.toml ├── scripts └── upload-asset.sh ├── src ├── cache.rs ├── cli.rs ├── config.rs ├── extensions.rs ├── formatter.rs ├── line_iterator.rs ├── main.rs ├── output.rs ├── types.rs └── utils.rs └── tests ├── cache ├── pages.ja │ └── common │ │ └── apt.md └── pages │ └── common │ ├── git-checkout.md │ ├── inkscape-v1.md │ ├── inkscape-v2.md │ └── which.md ├── custom-pages └── inkscape-v2.patch.md ├── lib.rs ├── rendered ├── apt.ja.expected ├── inkscape-default-no-color.expected ├── inkscape-default.expected ├── inkscape-patched-no-color.expected └── inkscape-with-config.expected └── style-config.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.md eol=lf 4 | *.expected eol=lf 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - "v*.x" 7 | pull_request: 8 | schedule: 9 | - cron: '30 3 * * 2' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | test: 14 | name: run tests 15 | strategy: 16 | matrix: 17 | platform: [ubuntu-latest, macos-latest, windows-latest] 18 | toolchain: [stable, 1.75.0] 19 | runs-on: ${{ matrix.platform }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: dtolnay/rust-toolchain@master 23 | with: 24 | toolchain: ${{ matrix.toolchain }} 25 | - name: Build with default features 26 | run: cargo build 27 | - name: Build with logging and Rustls with webpki roots 28 | run: cargo build --features logging,rustls-with-webpki-roots --no-default-features 29 | - name: Build with native TLS backend 30 | # expects runners have the proper Native SSL library 31 | run: cargo build --features native-tls --no-default-features 32 | - name: Run tests 33 | run: cargo test -- --test-threads 1 34 | 35 | clippy: 36 | name: run clippy lints 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: dtolnay/rust-toolchain@master 41 | with: 42 | toolchain: stable 43 | components: clippy 44 | - name: run clippy lints 45 | run: cargo clippy --features logging 46 | 47 | fmt: 48 | name: run rustfmt 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: dtolnay/rust-toolchain@master 53 | with: 54 | toolchain: stable 55 | components: rustfmt 56 | - name: run rustfmt 57 | run: cargo fmt --all -- --check 58 | 59 | docs: 60 | name: build docs 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Setup mdBook 65 | uses: peaceiris/actions-mdbook@v2 66 | with: 67 | mdbook-version: '0.4.4' 68 | - name: Setup toolchain 69 | uses: dtolnay/rust-toolchain@master 70 | with: 71 | toolchain: stable 72 | - name: Build 73 | run: cargo build 74 | - name: Ensure that docs can be built 75 | run: cd docs && mdbook build 76 | - name: Generate usage string 77 | run: cargo run -- --help > docs/src/usage-actual.txt 78 | - name: Ensure that usage string is up to date 79 | run: diff docs/src/usage{,-actual}.txt 80 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | on: 3 | push: 4 | tags: 5 | - "v[1-9]*" # push events matching `v` followed by anything larger than 0, e.g. v1.0, v20.15.10 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Setup mdBook 14 | uses: peaceiris/actions-mdbook@v2 15 | with: 16 | mdbook-version: '0.4.4' 17 | 18 | - run: cd docs && mdbook build 19 | 20 | - name: Deploy 21 | uses: peaceiris/actions-gh-pages@v4 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | publish_dir: ./docs/book 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" # push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | jobs: 8 | create-release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Create release for tag 13 | if: startsWith(github.ref, 'refs/tags/') 14 | run: | 15 | source ./scripts/upload-asset.sh 16 | # Create: 17 | create_release ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }} ${GITHUB_REF#refs/*/} "Tealdeer version ${GITHUB_REF#refs/*/v}.\n\nFor the full changelog, see https://github.com/tealdeer-rs/tealdeer/blob/main/CHANGELOG.md.\n\nBinaries were generated automatically in CI, and are therefore unsigned. For a fully trusted release, please build from source." 18 | 19 | upload-completions: 20 | needs: 21 | - create-release 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | target: ["bash", "fish", "zsh"] 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Upload completion 29 | if: startsWith(github.ref, 'refs/tags/') 30 | run: | 31 | source ./scripts/upload-asset.sh 32 | # Upload: 33 | upload_release_file ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }} ${GITHUB_REF#refs/*/} completion/${{ matrix.target }}_tealdeer completions_${{ matrix.target }} 34 | 35 | upload-license: 36 | needs: 37 | - create-release 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | target: ["MIT", "APACHE"] 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Upload license 45 | if: startsWith(github.ref, 'refs/tags/') 46 | run: | 47 | source ./scripts/upload-asset.sh 48 | # Upload: 49 | upload_release_file ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }} ${GITHUB_REF#refs/*/} LICENSE-${{ matrix.target }} LICENSE-${{ matrix.target }}.txt 50 | 51 | build-linux: 52 | runs-on: ubuntu-latest 53 | strategy: 54 | matrix: 55 | include: 56 | - arch: "x86_64" 57 | libc: "musl" 58 | - arch: "aarch64" 59 | libc: "musl" 60 | - arch: "i686" 61 | libc: "musl" 62 | - arch: "armv7" 63 | libc: "musleabihf" 64 | - arch: "arm" 65 | libc: "musleabi" 66 | - arch: "arm" 67 | libc: "musleabihf" 68 | steps: 69 | - uses: actions/checkout@v4 70 | - name: Pull Docker image 71 | run: docker pull messense/rust-musl-cross:${{ matrix.arch }}-${{ matrix.libc }} 72 | - name: Build in Docker 73 | run: docker run --rm -i -v "$(pwd)":/home/rust/src messense/rust-musl-cross:${{ matrix.arch }}-${{ matrix.libc }} cargo build --release 74 | - name: Strip binary 75 | run: docker run --rm -i -v "$(pwd)":/home/rust/src messense/rust-musl-cross:${{ matrix.arch }}-${{ matrix.libc }} musl-strip -s /home/rust/src/target/${{ matrix.arch }}-unknown-linux-${{ matrix.libc }}/release/tldr 76 | - uses: actions/upload-artifact@v4 77 | with: 78 | name: "tealdeer-linux-${{ matrix.arch }}-${{ matrix.libc }}" 79 | path: "target/${{ matrix.arch }}-unknown-linux-${{ matrix.libc }}/release/tldr" 80 | 81 | build-macos: 82 | runs-on: macos-latest 83 | strategy: 84 | matrix: 85 | include: 86 | - arch: "x86_64" 87 | - arch: "aarch64" 88 | steps: 89 | - uses: actions/checkout@v4 90 | - name: Setup toolchain 91 | uses: dtolnay/rust-toolchain@master 92 | with: 93 | toolchain: stable 94 | targets: "${{ matrix.arch }}-apple-darwin" 95 | - name: Build 96 | run: cargo build --release --target ${{ matrix.arch }}-apple-darwin --no-default-features --features webpki-roots 97 | - uses: actions/upload-artifact@v4 98 | with: 99 | name: "tealdeer-macos-${{ matrix.arch }}" 100 | path: "target/${{ matrix.arch }}-apple-darwin/release/tldr" 101 | 102 | build-windows: 103 | runs-on: windows-latest 104 | steps: 105 | - uses: actions/checkout@v4 106 | - name: Setup toolchain 107 | uses: dtolnay/rust-toolchain@master 108 | with: 109 | toolchain: stable 110 | - name: Build 111 | run: cargo build --release --target x86_64-pc-windows-msvc 112 | - uses: actions/upload-artifact@v4 113 | with: 114 | name: "tealdeer-windows-x86_64-msvc" 115 | path: "target/x86_64-pc-windows-msvc/release/tldr.exe" 116 | 117 | upload-release: 118 | needs: 119 | - create-release 120 | - build-linux 121 | - build-macos 122 | - build-windows 123 | runs-on: ubuntu-latest 124 | strategy: 125 | matrix: 126 | target: 127 | - linux-x86_64-musl 128 | - linux-aarch64-musl 129 | - linux-i686-musl 130 | - linux-armv7-musleabihf 131 | - linux-arm-musleabi 132 | - linux-arm-musleabihf 133 | - macos-x86_64 134 | - macos-aarch64 135 | - windows-x86_64-msvc 136 | steps: 137 | - uses: actions/checkout@v4 138 | - uses: actions/download-artifact@v4 139 | - name: Upload binary 140 | if: startsWith(github.ref, 'refs/tags/') 141 | run: | 142 | source ./scripts/upload-asset.sh 143 | 144 | # Move/rename file 145 | mkdir out && cd out 146 | if [[ "${{ matrix.target }}" == *windows* ]]; then 147 | src="../tealdeer-${{ matrix.target }}/tldr.exe" 148 | filename="tealdeer-${{ matrix.target }}.exe" 149 | else 150 | src="../tealdeer-${{ matrix.target }}/tldr" 151 | filename="tealdeer-${{ matrix.target }}" 152 | fi 153 | cp $src $filename 154 | 155 | # Create checksum 156 | sha256sum "$filename" > "$filename.sha256" 157 | 158 | # Upload: 159 | upload_release_file ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }} ${GITHUB_REF#refs/*/} $filename $filename 160 | upload_release_file ${{ secrets.GITHUB_TOKEN }} ${{ github.repository }} ${GITHUB_REF#refs/*/} $filename.sha256 $filename.sha256 161 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | *.swp 3 | *.tar.gz 4 | tldr-master/ 5 | dist-*/ 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project follows semantic versioning. 4 | 5 | Possible log types: 6 | 7 | - `[added]` for new features. 8 | - `[changed]` for changes in existing functionality. 9 | - `[deprecated]` for once-stable features removed in upcoming releases. 10 | - `[removed]` for deprecated features removed in this release. 11 | - `[fixed]` for any bug fixes. 12 | - `[security]` to invite users to upgrade in case of vulnerabilities. 13 | - `[docs]` for documentation changes. 14 | - `[chore]` for maintenance work. 15 | 16 | ### [v1.7.2][v1.7.2] (2025-03-18) 17 | 18 | This patch release updates the `zip` dependency to mitigate a potential security 19 | vulnerability. A successful attack against tealdeer users would require 20 | manipulation of the tldr pages archive downloaded during an update. As the 21 | archive is downloaded from a trusted source (the tldr-pages organization), it 22 | seems very unlikely that running a version of tealdeer prior to 1.7.2 poses a 23 | security risk. Nevertheless, it cannot hurt to rule out any chance of an attack 24 | by updating tealdeer to version 1.7.2. 25 | 26 | For more details, please see https://github.com/advisories/GHSA-94vh-gphv-8pm8. 27 | 28 | - [security] Require `zip >= 2.3.0` 29 | - [chore] Run CI on backport branches and on dispatch 30 | 31 | ### [v1.7.1][v1.7.1] (2024-11-14) 32 | 33 | This patch release updates the `yansi` dependency to version 1, so that the 34 | previous versions of `yansi` can be removed from the package sets of Linux 35 | distributions. This change should not impact the behavior of tealdeer. 36 | 37 | Changes: 38 | 39 | - [chore] Upgrade yansi: 0.5.1 -> 1.0.1 ([#389]) 40 | 41 | Contributors to this version: 42 | 43 | - [Blair Noctis][@nc7s] 44 | 45 | Thanks! 46 | 47 | ### [v1.7.0][v1.7.0] (2024-10-02) 48 | 49 | It's been 24 months since the last release, time for tealdeer 1.7.0! Thanks to 50 | 16 individual contributors, a few nice changes and features are included in 51 | this release. 52 | 53 | One change is that you can **query multiple platforms at once**. For example: 54 | 55 | tldr --platform openbsd --platform linux df 56 | 57 | This will show the `df` page for OpenBSD (if available), followed by Linux (if 58 | available), with fallback to the current platform on which tealdeer runs. 59 | 60 | What's that `openbsd` thing up there? Yes, there's now **support for the BSD 61 | platforms `freebsd`, `netbsd` and `openbsd`**. 62 | 63 | And since we're already talking about platform support: Our **binary releases 64 | now include builds for ARM64 (aka `aarch64`) on macOS (Apple Silicon, M1/M2/M3) 65 | and Linux**. _(Keep in mind that binary releases are generated in CI and are 66 | unsigned. For a trusted build, please compile from source.)_ 67 | 68 | There's also a breaking change for the folks using [custom pages and 69 | patches](https://tealdeer-rs.github.io/tealdeer/usage_custom_pages.html): These 70 | files now use a `.md` extension. Old files will continue to work, but will 71 | result a deprecation warning being printed when used. 72 | 73 | On a personal note, this will be the last release from me 74 | ([Danilo](https://github.com/dbrgn/)) as primary maintainer of tealdeer. For 75 | details, see [#376](https://github.com/tealdeer-rs/tealdeer/issues/376). 76 | 77 | Changes: 78 | 79 | - [added] Allow querying multiple platforms ([#300]) 80 | - [added] Add BSD platform support ([#354]) 81 | - [added] Allow building with native-tls in addition to rustls ([#303]) 82 | - [changed] Change custom page files to use a `.md` file extension ([#322]) 83 | - [changed] Update to clap v4 for doing command line parsing ([#298]) 84 | - [changed] Performance optimization in LineIterator ([#314]) 85 | - [changed] Performance optimizations by tweaking Cargo flags ([#355]) 86 | - [changed] Include completions in published crate ([#333]) 87 | - [changed] Minimal supported Rust version is now 1.75 ([#298]) 88 | - [fixed] Fix bash/zsh/fish completions when cache is empty ([#327], [#331]) 89 | - [docs] Publish docs only when tagging a release ([#362]) 90 | - [docs] List Scoop and Debian packages ([#305], [#315]) 91 | - [docs] Add "Tips and Tricks" chapter to user manual ([#342]) 92 | - [docs] Various docs improvements ([#293]) 93 | - [chore] Improvements to CI workflows ([#324]) 94 | - [chore] Update Cargo.toml license field following SPDX 2.1 ([#336]) 95 | - [chore] Dependency updates 96 | 97 | Contributors to this version: 98 | 99 | - [Adam Henley][@adamazing] 100 | - [Andrea Frigido][@frisoft] 101 | - [Blair Noctis][@nc7s] 102 | - [Danilo Bargen][@dbrgn] 103 | - [Felix Yan][@felixonmars] 104 | - [Iliia Maleki][@iliya-malecki] 105 | - [JJ Style][@jj-style] 106 | - [K.B.Dharun Krishna][@kbdharun] 107 | - [Linus Walker][@Walker-00] 108 | - [Mohit Raj][@agrmohit] 109 | - [Nicolai Fröhlich][@nifr] 110 | - [Niklas Mohrin][@niklasmohrin] 111 | - [@qknogxxb][@qknogxxb] 112 | - [@tveness][@tveness] 113 | - [Y.D.X.][@YDX-2147483647] 114 | - [Zacchary Dempsey-Plante][@zedseven] 115 | 116 | Thanks! 117 | 118 | 119 | ### [v1.6.1][v1.6.1] (2022-10-24) 120 | 121 | Changes: 122 | 123 | - [fixed] Fix path source for custom pages dir ([#297]) 124 | - [chore] Update dependendencies ([#299]) 125 | 126 | Contributors to this version: 127 | 128 | - [Cyrus Yip][@CyrusYip] 129 | - [Danilo Bargen][@dbrgn] 130 | 131 | Thanks! 132 | 133 | 134 | ### [v1.6.0][v1.6.0] (2022-10-02) 135 | 136 | It's been 9 months since the last release already! This is not a huge update 137 | feature-wise, but it still contains a few nice new improvements and a few 138 | bugfixes, contributed by 11 different people. The most important new feature is 139 | probably the option to override the cache directory through the config file. 140 | The `TEALDEER_CACHE_DIR` env variable is now deprecated. 141 | 142 | A note to packagers: Shell completions have been moved to the `completion/` 143 | subdirectory! Packaging scripts might need to be updated. 144 | 145 | Changes: 146 | 147 | - [added] Allow overriding cache directory through config ([#276]) 148 | - [added] Add `--no-auto-update` CLI flag ([#257]) 149 | - [added] Show note about auto-updates when cache is missing ([#254]) 150 | - [added] Add support for android platform ([#274]) 151 | - [added] Add custom pages to list output ([#285]) 152 | - [fixed] Cache: Return error if HTTP client cannot be created ([#247]) 153 | - [fixed] Handle cache download errors ([#253]) 154 | - [fixed] Do not page output of `tldr --update` ([#231]) 155 | - [fixed] Create macOS release builds with bundled root certificates ([#272]) 156 | - [fixed] Clean up and fix shell completions ([#262]) 157 | - [deprecated] The `TEALDEER_CACHE_DIR` env variable is now deprecated ([#276]) 158 | - [removed] The `--config-path` command was removed, use `--show-paths` instead ([#290]) 159 | - [removed] The `-o/--os` command was removed, use `-p/--platform` instead ([#290]) 160 | - [removed] The `-m/--markdown` command was removed, use `-r/--raw` instead ([#290]) 161 | - [chore] Move shell completion scripts to their own directory ([#259]) 162 | - [chore] Update dependencies ([#271], [#287], [#291]) 163 | - [chore] Use anyhow for error handling ([#249]) 164 | - [chore] Switch to Rust 2021 edition ([#284]) 165 | 166 | Contributors to this version: 167 | 168 | - [@bagohart][@bagohart] 169 | - [@cyqsimon][@cyqsimon] 170 | - [Danilo Bargen][@dbrgn] 171 | - [Danny Mösch][@SimplyDanny] 172 | - [Evan Lloyd New-Schmidt][@newsch] 173 | - [Hans Gaiser][@hgaiser] 174 | - [Kian-Meng Ang][@kianmeng] 175 | - [Marcin Puc][@tranzystorek-io] 176 | - [Niklas Mohrin][@niklasmohrin] 177 | - [Olav de Haas][@Olavhaasie] 178 | - [Simon Perdrisat][@gagarine] 179 | 180 | Thanks! 181 | 182 | 183 | ### [v1.5.0][v1.5.0] (2021-12-31) 184 | 185 | This is quite a big release with many new features. In the 15 months since the 186 | last release, 59 pull requests from 16 different contributors were merged! 187 | 188 | The highlights: 189 | 190 | - **Custom pages and patches**: You can now create your own local-only tldr 191 | pages. But not just that, you can also extend existing upstream pages with 192 | your own examples. For more details, see 193 | [the docs](https://tealdeer-rs.github.io/tealdeer/usage_custom_pages.html). 194 | - **Change argument parsing from docopt to clap**: We replaced docopt.rs as 195 | argument parsing library with clap v3, resulting in almost 1 MiB smaller 196 | binaries and a 22% speed increase when rendering a tldr page. 197 | - **Multi-language support**: You can now override the language with `-L/--language`. 198 | - **A new `--show-paths` command**: By running `tldr --show-paths`, you can list 199 | the currently used config dir, cache dir, upstream pages dir and custom pages dir. 200 | - **Compliance with the tldr client spec v1.5**: We renamed `-o/--os` to 201 | `-p/--platform` and implemented transparent lowercasing of the page names. 202 | - **Docs**: The README based documentation has reached its limits. There are 203 | now new mdbook based docs over at 204 | [tealdeer-rs.github.io/tealdeer/](https://tealdeer-rs.github.io/tealdeer/), we hope these 205 | make using tealdeer easier. Of course, documentation improvements are 206 | welcome! Also, if you're confused about how to use a certain feature, feel 207 | free to open an issue, this way we can improve the docs. 208 | 209 | Note that the MSRV (Minimal Supported Rust Version) of the project 210 | [changed][i190]: 211 | 212 | > When publishing a tealdeer release, the Rust version required to build it 213 | > should be stable for at least a month. 214 | 215 | Changes: 216 | 217 | - [added] Support custom pages and patches ([#142][i142]) 218 | - [added] Multi-language support ([#125][i125], [#161][i161]) 219 | - [added] Add support for ANSI code and RGB colors ([#148][i148]) 220 | - [added] Implement new `--show-paths` command ([#162][i162]) 221 | - [added] Support for italic text styling ([#197][i197]) 222 | - [added] Allow SunOS platform override ([#176][i176]) 223 | - [added] Automatically lowercase page names before lookup ([#227][i227]) 224 | - [added] Add "macos" alias for "osx" ([#215][i215]) 225 | - [fixed] Consider only standalone command names for styling ([#157][i157]) 226 | - [fixed] Fixed and improved zsh completions ([#168][i168]) 227 | - [fixed] Create cache directory path if it does not exist ([#174][i174]) 228 | - [fixed] Use default style if user-defined style is missing ([#210][i210]) 229 | - [changed] Switch from docopt to clap for argument parsing ([#108][i108]) 230 | - [changed] Switch from OpenSSL to Rustls ([#187][i187]) 231 | - [changed] Performance improvements ([#187][i187]) 232 | - [changed] Send all progress logging messages to stderr ([#171][i171]) 233 | - [changed] Rename `-o/--os` to `-p/--platform` ([#217][i217]) 234 | - [changed] Rename `-m/--markdown` to `-r/--raw` ([#108][i108]) 235 | - [deprecated] The `--config-path` command is deprecated, use `--show-paths` instead ([#162][i162]) 236 | - [deprecated] The `-o/--os` command is deprecated, use `-p/--platform` instead ([#217][i217]) 237 | - [deprecated] The `-m/--markdown` command is deprecated, use `-r/--raw` instead ([#108][i108]) 238 | - [docs] New docs at [tealdeer-rs.github.io/tealdeer/](https://tealdeer-rs.github.io/tealdeer/) 239 | - [docs] Add comparative benchmarks with hyperfine ([#163][i163], [README](https://github.com/tealdeer-rs/tealdeer#goals)) 240 | - [chore] Download tldr pages archive from their website, not from GitHub ([#213][i213]) 241 | - [chore] Bump MSRV to 1.54 and change MSRV policy ([#190][i190]) 242 | - [chore] The `master` branch was renamed to `main` 243 | - [chore] All release binaries are now generated in CI. Binaries for macOS and Windows are also provided. ([#240][i240]) 244 | - [chore] Update all dependencies 245 | 246 | Contributors to this version: 247 | 248 | - [@bl-ue][@bl-ue] 249 | - [Cameron Tod][@cam8001] 250 | - [Dalton][@dmaahs2017] 251 | - [Danilo Bargen][@dbrgn] 252 | - [Danny Mösch][@SimplyDanny] 253 | - [Marcin Puc][@tranzystorek-io] 254 | - [Michael Cho][@cho-m] 255 | - [MS_Y][@black7375] 256 | - [Niklas Mohrin][@niklasmohrin] 257 | - [Rithvik Vibhu][@rithvikvibhu] 258 | - [rnd][@0ndorio] 259 | - [Sondre Nilsen][@sondr3] 260 | - [Tomás Farías Santana][@tomasfarias] 261 | - [Tsvetomir Bonev][@invakid404] 262 | - [@tveness][@tveness] 263 | - [ギャラ][@laxect] 264 | 265 | Thanks! 266 | 267 | Last but not least, [Niklas Mohrin][@niklasmohrin] has joined the project as 268 | co-maintainer. Thank you for your help! 269 | 270 | 271 | ### [v1.4.1][v1.4.1] (2020-09-04) 272 | 273 | - [fixed] Syntax error in zsh completion file ([#138][i138]) 274 | 275 | Contributors to this version: 276 | 277 | - [Danilo Bargen][@dbrgn] 278 | - [Bruno A. Muciño][@mucinoab] 279 | - [Francesco][@BachoSeven] 280 | 281 | Thanks! 282 | 283 | 284 | ### [v1.4.0][v1.4.0] (2020-09-03) 285 | 286 | - [added] Configurable automatic cache updates ([#115][i115]) 287 | - [added] Improved color detection and support for `--color` argument and 288 | `NO_COLOR` env variable ([#111][i111]) 289 | - [changed] Make `--list` option comply with official spec ([#112][i112]) 290 | - [changed] Move cache age warning to stderr ([#113][i113]) 291 | 292 | Contributors to this version: 293 | 294 | - [Atul Bhosale][@Atul9] 295 | - [Danilo Bargen][@dbrgn] 296 | - [Danny Mösch][@SimplyDanny] 297 | - [Ilaï Deutel][@ilai-deutel] 298 | - [Kornel][@kornelski] 299 | - [@LovecraftianHorror][@LovecraftianHorror] 300 | - [@michaeldel][@michaeldel] 301 | - [Niklas Mohrin][@niklasmohrin] 302 | 303 | Thanks! 304 | 305 | 306 | ### [v1.3.0][v1.3.0] (2020-02-28) 307 | 308 | - [added] New config option for compact output mode ([#89][i89]) 309 | - [added] New -m/--markdown parameter for raw rendering ([#95][i95]) 310 | - [added] Provide zsh autocompletion ([#86][i86]) 311 | - [changed] Require at least Rust 1.39 to build (previous: 1.32) 312 | - [changed] Switch to GitHub actions, CI testing now covers Windows as well ([#99][i99]) 313 | - [changed] Tweak the "outdated cache" warning message ([#97][i97]) 314 | - [changed] General maintenance: Upgrade dependencies, fix linter warnings 315 | - [fixed] Fix Fish autocompletion on macOS ([#87][i87]) 316 | - [fixed] Fix compilation on Windows by disabling pager ([#99][i99]) 317 | 318 | Contributors to this version: 319 | 320 | - [Bruno Heridet][@Delapouite] 321 | - [Danilo Bargen][@dbrgn] 322 | - [Hugo Locurcio][@Calinou] 323 | - [Isak Johansson][@Plommonsorbet] 324 | - [James Doyle][@james2doyle] 325 | - [Jesús Trinidad Díaz Ramírez][@jesdazrez] 326 | - [@korrat][@korrat] 327 | - [Marc-André Renaud][@ma-renaud] 328 | 329 | Thanks! 330 | 331 | 332 | ### [v1.2.0][v1.2.0] (2019-08-10) 333 | 334 | - [added] Add Windows support ([#77][i77]) 335 | - [added] Add support for spaces in commands ([#75][i75]) 336 | - [added] Add support for Fish-based autocompletion ([#71][i71]) 337 | - [added] Add pager support ([#44][i44]) 338 | - [added] Print detected OS with `-v` / `--version` ([#57][i57]) 339 | - [changed] OS detection: Treat BSDs as "osx" ([#58][i58]) 340 | - [changed] Move from curl to reqwest ([#61][i61]) 341 | - [changed] Move to Rust 2018, require Rust 1.32 ([#69][i69] / [#84][i84]) 342 | - [fixed] Add (back) support for proxies ([#68][i68]) 343 | 344 | Contributors to this version: 345 | 346 | - [Bar Hatsor][@Bassets] 347 | - [Danilo Bargen][@dbrgn] 348 | - [Gabriel Martinez][@mystal] 349 | - [Ivan Smirnov][@aldanor] 350 | - [Jan Christian Grünhage][@jcgruenhage] 351 | - [Jonathan Dahan][@jedahan] 352 | - [Juan D. Vega][@jdvr] 353 | - [Natalie Pendragon][@natpen] 354 | - [Raphael Das Gupta][@das-g] 355 | 356 | Thanks! 357 | 358 | 359 | ### [v1.1.0][v1.1.0] (2018-10-22) 360 | 361 | - [added] Configuration file support ([#43][i43]) 362 | - [added] Allow configuration of colors/style ([#43][i43]) 363 | - [added] New `--quiet` / `-q` option to suppress most non-error messages ([#48][i48]) 364 | - [changed] Require at least Rust 1.28 to build (previous: 1.19) 365 | - [fixed] Fix building on systems with openssl 1.1.1 ([#47][i47]) 366 | 367 | Contributors to this version: 368 | 369 | - [Danilo Bargen][@dbrgn] 370 | - [@equal-l2][@equal-l2] 371 | - [Jonathan Dahan][@jedahan] 372 | - [Lukas Bergdoll][@Voultapher] 373 | 374 | Thanks! 375 | 376 | 377 | ### [v1.0.0][v1.0.0] (2018-02-11) 378 | 379 | - [added] Include bash completions ([#34][i34]) 380 | - [changed] Update all dependencies 381 | - [changed] Require at least Rust 1.19 to build (previous: 1.9) 382 | - [changed] Improved unit/integration testing 383 | 384 | 385 | ### v0.4.0 (2016-11-25) 386 | 387 | - [added] Support for new page format 388 | - [changed] Update all dependencies 389 | 390 | 391 | ### v0.3.0 (2016-08-01) 392 | 393 | - [changed] Update curl dependency 394 | 395 | 396 | ### v0.2.0 (2016-04-16) 397 | 398 | - First crates.io release 399 | 400 | 401 | 402 | [@0ndorio]: https://github.com/0ndorio 403 | [@adamazing]: https://github.com/adamazing 404 | [@agrmohit]: https://github.com/agrmohit 405 | [@aldanor]: https://github.com/aldanor 406 | [@Atul9]: https://github.com/Atul9 407 | [@BachoSeven]: https://github.com/BachoSeven 408 | [@bagohart]: https://github.com/bagohart 409 | [@Bassets]: https://github.com/Bassets 410 | [@black7375]: https://github.com/black7375 411 | [@bl-ue]: https://github.com/bl-ue 412 | [@Calinou]: https://github.com/Calinou 413 | [@cam8001]: https://github.com/cam8001 414 | [@cho-m]: https://github.com/cho-m 415 | [@cyqsimon]: https://github.com/cyqsimon 416 | [@CyrusYip]: https://github.com/CyrusYip 417 | [@das-g]: https://github.com/das-g 418 | [@dbrgn]: https://github.com/dbrgn 419 | [@Delapouite]: https://github.com/Delapouite 420 | [@dmaahs2017]: https://github.com/dmaahs2017 421 | [@equal-l2]: https://github.com/equal-l2 422 | [@felixonmars]: https://github.com/felixonmars 423 | [@frisoft]: https://github.com/frisoft 424 | [@gagarine]: https://github.com/gagarine 425 | [@hgaiser]: https://github.com/hgaiser 426 | [@ilai-deutel]: https://github.com/ilai-deutel 427 | [@iliya-malecki]: https://github.com/iliya-malecki 428 | [@invakid404]: https://github.com/invakid404 429 | [@james2doyle]: https://github.com/james2doyle 430 | [@jcgruenhage]: https://github.com/jcgruenhage 431 | [@jdvr]: https://github.com/jdvr 432 | [@jedahan]: https://github.com/jedahan 433 | [@jesdazrez]: https://github.com/jesdazrez 434 | [@jj-style]: https://github.com/jj-style 435 | [@kbdharun]: https://github.com/kbdharun 436 | [@kianmeng]: https://github.com/kianmeng 437 | [@kornelski]: https://github.com/kornelski 438 | [@korrat]: https://github.com/korrat 439 | [@laxect]: https://github.com/laxect 440 | [@LovecraftianHorror]: https://github.com/LovecraftianHorror 441 | [@ma-renaud]: https://github.com/ma-renaud 442 | [@michaeldel]: https://github.com/michaeldel 443 | [@mucinoab]: https://github.com/mucinoab 444 | [@mystal]: https://github.com/mystal 445 | [@natpen]: https://github.com/natpen 446 | [@nc7s]: https://github.com/nc7s 447 | [@newsch]: https://github.com/newsch 448 | [@nifr]: https://github.com/nifr 449 | [@niklasmohrin]: https://github.com/niklasmohrin 450 | [@Olavhaasie]: https://github.com/Olavhaasie 451 | [@Plommonsorbet]: https://github.com/Plommonsorbet 452 | [@qknogxxb]: https://github.com/qknogxxb 453 | [@rithvikvibhu]: https://github.com/rithvikvibhu 454 | [@SimplyDanny]: https://github.com/SimplyDanny 455 | [@sondr3]: https://github.com/sondr3 456 | [@tomasfarias]: https://github.com/tomasfarias 457 | [@tranzystorek-io]: https://github.com/tranzystorek-io 458 | [@tveness]: https://github.com/tveness 459 | [@Voultapher]: https://github.com/Voultapher 460 | [@Walker-00]: https://github.com/Walker-00 461 | [@YDX-2147483647]: https://github.com/YDX-2147483647 462 | [@zedseven]: https://github.com/zedseven 463 | 464 | [v1.0.0]: https://github.com/tealdeer-rs/tealdeer/compare/v0.4.0...v1.0.0 465 | [v1.1.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.0.0...v1.1.0 466 | [v1.2.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.1.0...v1.2.0 467 | [v1.3.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.2.0...v1.3.0 468 | [v1.4.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.3.0...v1.4.0 469 | [v1.4.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.4.0...v1.4.1 470 | [v1.5.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.4.1...v1.5.0 471 | [v1.6.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.5.0...v1.6.0 472 | [v1.6.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.6.0...v1.6.1 473 | [v1.7.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.6.1...v1.7.0 474 | [v1.7.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.0...v1.7.1 475 | [v1.7.2]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.1...v1.7.2 476 | 477 | [i34]: https://github.com/tealdeer-rs/tealdeer/issues/34 478 | [i43]: https://github.com/tealdeer-rs/tealdeer/issues/43 479 | [i44]: https://github.com/tealdeer-rs/tealdeer/issues/44 480 | [i47]: https://github.com/tealdeer-rs/tealdeer/issues/47 481 | [i48]: https://github.com/tealdeer-rs/tealdeer/issues/48 482 | [i57]: https://github.com/tealdeer-rs/tealdeer/issues/57 483 | [i58]: https://github.com/tealdeer-rs/tealdeer/issues/58 484 | [i61]: https://github.com/tealdeer-rs/tealdeer/issues/61 485 | [i68]: https://github.com/tealdeer-rs/tealdeer/issues/68 486 | [i69]: https://github.com/tealdeer-rs/tealdeer/issues/69 487 | [i71]: https://github.com/tealdeer-rs/tealdeer/issues/71 488 | [i75]: https://github.com/tealdeer-rs/tealdeer/issues/75 489 | [i77]: https://github.com/tealdeer-rs/tealdeer/issues/77 490 | [i84]: https://github.com/tealdeer-rs/tealdeer/issues/84 491 | [i86]: https://github.com/tealdeer-rs/tealdeer/issues/86 492 | [i87]: https://github.com/tealdeer-rs/tealdeer/issues/87 493 | [i89]: https://github.com/tealdeer-rs/tealdeer/issues/89 494 | [i95]: https://github.com/tealdeer-rs/tealdeer/issues/95 495 | [i97]: https://github.com/tealdeer-rs/tealdeer/issues/97 496 | [i99]: https://github.com/tealdeer-rs/tealdeer/issues/99 497 | [i108]: https://github.com/tealdeer-rs/tealdeer/pull/108 498 | [i111]: https://github.com/tealdeer-rs/tealdeer/issues/111 499 | [i112]: https://github.com/tealdeer-rs/tealdeer/issues/112 500 | [i113]: https://github.com/tealdeer-rs/tealdeer/issues/113 501 | [i115]: https://github.com/tealdeer-rs/tealdeer/issues/115 502 | [i125]: https://github.com/tealdeer-rs/tealdeer/pull/125 503 | [i138]: https://github.com/tealdeer-rs/tealdeer/issues/138 504 | [i142]: https://github.com/tealdeer-rs/tealdeer/pull/142 505 | [i148]: https://github.com/tealdeer-rs/tealdeer/pull/148 506 | [i157]: https://github.com/tealdeer-rs/tealdeer/pull/157 507 | [i161]: https://github.com/tealdeer-rs/tealdeer/pull/161 508 | [i162]: https://github.com/tealdeer-rs/tealdeer/pull/162 509 | [i163]: https://github.com/tealdeer-rs/tealdeer/pull/163 510 | [i168]: https://github.com/tealdeer-rs/tealdeer/pull/168 511 | [i171]: https://github.com/tealdeer-rs/tealdeer/pull/171 512 | [i174]: https://github.com/tealdeer-rs/tealdeer/pull/174 513 | [i176]: https://github.com/tealdeer-rs/tealdeer/pull/176 514 | [i187]: https://github.com/tealdeer-rs/tealdeer/pull/187 515 | [i190]: https://github.com/tealdeer-rs/tealdeer/issues/190 516 | [i197]: https://github.com/tealdeer-rs/tealdeer/pull/197 517 | [i210]: https://github.com/tealdeer-rs/tealdeer/pull/210 518 | [i213]: https://github.com/tealdeer-rs/tealdeer/pull/213 519 | [i215]: https://github.com/tealdeer-rs/tealdeer/pull/215 520 | [i217]: https://github.com/tealdeer-rs/tealdeer/pull/217 521 | [i227]: https://github.com/tealdeer-rs/tealdeer/pull/227 522 | [#231]: https://github.com/tealdeer-rs/tealdeer/pull/231 523 | [i240]: https://github.com/tealdeer-rs/tealdeer/pull/240 524 | [#247]: https://github.com/tealdeer-rs/tealdeer/pull/247 525 | [#249]: https://github.com/tealdeer-rs/tealdeer/pull/249 526 | [#253]: https://github.com/tealdeer-rs/tealdeer/pull/253 527 | [#254]: https://github.com/tealdeer-rs/tealdeer/pull/254 528 | [#257]: https://github.com/tealdeer-rs/tealdeer/pull/257 529 | [#259]: https://github.com/tealdeer-rs/tealdeer/pull/259 530 | [#262]: https://github.com/tealdeer-rs/tealdeer/pull/262 531 | [#271]: https://github.com/tealdeer-rs/tealdeer/pull/271 532 | [#272]: https://github.com/tealdeer-rs/tealdeer/pull/272 533 | [#274]: https://github.com/tealdeer-rs/tealdeer/pull/274 534 | [#276]: https://github.com/tealdeer-rs/tealdeer/pull/276 535 | [#284]: https://github.com/tealdeer-rs/tealdeer/pull/284 536 | [#285]: https://github.com/tealdeer-rs/tealdeer/pull/285 537 | [#287]: https://github.com/tealdeer-rs/tealdeer/pull/287 538 | [#290]: https://github.com/tealdeer-rs/tealdeer/pull/290 539 | [#291]: https://github.com/tealdeer-rs/tealdeer/pull/291 540 | [#293]: https://github.com/tealdeer-rs/tealdeer/pull/293 541 | [#297]: https://github.com/tealdeer-rs/tealdeer/pull/297 542 | [#298]: https://github.com/tealdeer-rs/tealdeer/pull/298 543 | [#299]: https://github.com/tealdeer-rs/tealdeer/pull/299 544 | [#300]: https://github.com/tealdeer-rs/tealdeer/pull/300 545 | [#303]: https://github.com/tealdeer-rs/tealdeer/pull/303 546 | [#305]: https://github.com/tealdeer-rs/tealdeer/pull/305 547 | [#314]: https://github.com/tealdeer-rs/tealdeer/pull/314 548 | [#315]: https://github.com/tealdeer-rs/tealdeer/pull/315 549 | [#322]: https://github.com/tealdeer-rs/tealdeer/pull/322 550 | [#324]: https://github.com/tealdeer-rs/tealdeer/pull/324 551 | [#327]: https://github.com/tealdeer-rs/tealdeer/pull/327 552 | [#331]: https://github.com/tealdeer-rs/tealdeer/pull/331 553 | [#333]: https://github.com/tealdeer-rs/tealdeer/pull/333 554 | [#336]: https://github.com/tealdeer-rs/tealdeer/pull/336 555 | [#342]: https://github.com/tealdeer-rs/tealdeer/pull/342 556 | [#354]: https://github.com/tealdeer-rs/tealdeer/pull/354 557 | [#355]: https://github.com/tealdeer-rs/tealdeer/pull/355 558 | [#362]: https://github.com/tealdeer-rs/tealdeer/pull/362 559 | [#389]: https://github.com/tealdeer-rs/tealdeer/pull/389 560 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = [ 3 | "Danilo Bargen ", 4 | "Niklas Mohrin ", 5 | ] 6 | description = "Fetch and show tldr help pages for many CLI commands. Full featured offline client with caching support." 7 | homepage = "https://github.com/tealdeer-rs/tealdeer/" 8 | license = "MIT OR Apache-2.0" 9 | name = "tealdeer" 10 | readme = "README.md" 11 | repository = "https://github.com/tealdeer-rs/tealdeer/" 12 | documentation = "https://tealdeer-rs.github.io/tealdeer/" 13 | version = "1.7.2" 14 | include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"] 15 | rust-version = "1.75" 16 | edition = "2021" 17 | 18 | [[bin]] 19 | name = "tldr" 20 | path = "src/main.rs" 21 | 22 | [dependencies] 23 | anyhow = "1" 24 | app_dirs = { version = "2", package = "app_dirs2" } 25 | clap = { version = "4", features = ["std", "derive", "help", "usage", "cargo", "error-context", "color", "wrap_help"], default-features = false } 26 | env_logger = { version = "0.11", optional = true } 27 | log = "0.4" 28 | serde = "1.0.21" 29 | serde_derive = "1.0.21" 30 | ureq = { version = "3.0.8", default-features = false, features = ["gzip"] } 31 | toml = "0.8.19" 32 | walkdir = "2.0.1" 33 | yansi = "1" 34 | zip = { version = "2.3.0", default-features = false, features = ["deflate"] } 35 | 36 | [target.'cfg(not(windows))'.dependencies] 37 | pager = "0.16" 38 | 39 | [dev-dependencies] 40 | assert_cmd = "2.0.1" 41 | escargot = "0.5" 42 | predicates = "3.1.2" 43 | tempfile = "3.1.0" 44 | filetime = "0.2.10" 45 | 46 | [features] 47 | default = ["native-tls", "rustls-with-webpki-roots", "rustls-with-native-roots"] 48 | logging = ["env_logger"] 49 | 50 | # At least one of variants for `ureq` HTTP client must be selected. 51 | native-tls = ["ureq/native-tls", "ureq/platform-verifier"] 52 | rustls-with-webpki-roots = ["ureq/rustls"] # ureq uses WebPKI roots by default 53 | rustls-with-native-roots = ["ureq/rustls", "ureq/platform-verifier"] 54 | 55 | ignore-online-tests = [] 56 | 57 | [profile.release] 58 | strip = true 59 | opt-level = 3 60 | lto = true 61 | codegen-units = 1 62 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015-2021 Danilo Bargen and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tealdeer 2 | 3 | ![teal deer](docs/src/deer.png) 4 | 5 | |Crate|CI (Linux/macOS/Windows)| 6 | |:---:|:---:| 7 | |[![Crates.io][crates-io-badge]][crates-io]|[![GitHub CI][github-actions-badge]][github-actions]| 8 | 9 | A very fast implementation of [tldr](https://github.com/tldr-pages/tldr) in 10 | Rust: Simplified, example based and community-driven man pages. 11 | 12 | Screenshot of tldr command 13 | 14 | If you pronounce "tldr" in English, it sounds somewhat like "tealdeer". Hence the project name :) 15 | 16 | In case you're in a hurry and just want to quickly try tealdeer, you can find static 17 | binaries on the [GitHub releases page](https://github.com/tealdeer-rs/tealdeer/releases/)! 18 | 19 | 20 | ## Docs (Installing, Usage, Configuration) 21 | 22 | User documentation is available at ! 23 | 24 | The docs are generated using [mdbook](https://rust-lang.github.io/mdBook/index.html). 25 | They can be edited through the markdown files in the `docs/src/` directory. 26 | 27 | 28 | ## Goals 29 | 30 | High level project goals: 31 | 32 | - [x] Download and cache pages 33 | - [x] Don't require a network connection for anything besides updating the cache 34 | - [x] Command line interface similar or equivalent to the [NodeJS client][node-gh] 35 | - [x] Comply with the [tldr client specification][client-spec] 36 | - [x] Advanced highlighting and configuration 37 | - [x] Be fast 38 | 39 | A tool like `tldr` should be as frictionless as possible to use and show the 40 | output as fast as possible. 41 | 42 | We think that `tealdeer` reaches these goals. We put together a (more or less) 43 | reproducible benchmark that compiles a handful of clients from source and 44 | measures the execution times on a cold disk cache. The benchmarking is run in a 45 | Docker container using sharkdp's [`hyperfine`][hyperfine-gh] 46 | ([Dockerfile][benchmark-dockerfile]). 47 | 48 | | Client (50 runs, 17.10.2021) | Programming Language | Mean in ms | Deviation in ms | Comments | 49 | | :---: | :---: | :---: | :---: | :---: | 50 | | [`outfieldr`][outfieldr-gh] | Zig | 9.1 | 0.5 | no user configuration | 51 | | `tealdeer` | Rust | 13.2 | 0.5 | | 52 | | [`fast-tldr`][fast-tldr-gh] | Haskell | 17.0 | 0.6 | no example highlighting | 53 | | [`tldr-hs`][hs-gh] | Haskell | 25.1 | 0.5 | no example highlighting | 54 | | [`tldr-bash`][bash-gh] | Bash | 30.0 | 0.8 | | 55 | | [`tldr-c`][c-gh] | C | 38.4 | 1.0 | | 56 | | [`tldr-python-client`][python-gh] | Python | 87.0 | 2.4 | | 57 | | [`tldr-node-client`][node-gh] | JavaScript / NodeJS | 407.1 | 12.9 | | 58 | 59 | As you can see, `tealdeer` is one of the fastest of the tested clients. 60 | However, we strive for useful features and code quality over raw performance, 61 | even if that means that we don't come out on top in this friendly competition. 62 | That said, we are still optimizing the code, for example when the `outfieldr` 63 | developers [suggested to switch][outfieldr-comment-tls] to a native TLS 64 | implementation instead of the native libraries. 65 | 66 | ## Development 67 | 68 | Creating a debug build with logging enabled: 69 | 70 | $ cargo build --features logging 71 | 72 | Release build without logging: 73 | 74 | $ cargo build --release 75 | 76 | To enable the log output, set the `RUST_LOG` env variable: 77 | 78 | $ export RUST_LOG=tldr=debug 79 | 80 | To run tests: 81 | 82 | $ cargo test 83 | 84 | To run lints: 85 | 86 | $ rustup component add clippy 87 | $ cargo clean && cargo clippy 88 | 89 | 90 | ## MSRV (Minimally Supported Rust Version) 91 | 92 | When publishing a tealdeer release, the Rust version required to build it 93 | should be stable for at least a month. 94 | 95 | 96 | ## License 97 | 98 | Licensed under either of 99 | 100 | * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or 101 | http://www.apache.org/licenses/LICENSE-2.0) 102 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 103 | http://opensource.org/licenses/MIT) at your option. 104 | 105 | 106 | ### Contribution 107 | 108 | Unless you explicitly state otherwise, any contribution intentionally submitted 109 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall 110 | be dual licensed as above, without any additional terms or conditions. 111 | 112 | Thanks to @severen for coming up with the name "tealdeer"! 113 | 114 | 115 | [node-gh]: https://github.com/tldr-pages/tldr-node-client 116 | [c-gh]: https://github.com/tldr-pages/tldr-c-client 117 | [hs-gh]: https://github.com/psibi/tldr-hs 118 | [fast-tldr-gh]: https://github.com/gutjuri/fast-tldr 119 | [bash-gh]: https://4e4.win/tldr 120 | [outfieldr-gh]: https://gitlab.com/ve-nt/outfieldr 121 | [python-gh]: https://github.com/tldr-pages/tldr-python-client 122 | 123 | [benchmark-dockerfile]: https://github.com/tealdeer-rs/tealdeer/blob/main/benchmarks/Dockerfile 124 | [client-spec]: https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md 125 | [hyperfine-gh]: https://github.com/sharkdp/hyperfine 126 | [outfieldr-comment-tls]: https://github.com/tealdeer-rs/tealdeer/issues/129#issuecomment-833596765 127 | 128 | 129 | [github-actions]: https://github.com/tealdeer-rs/tealdeer/actions?query=branch%3Amain 130 | [github-actions-badge]: https://github.com/tealdeer-rs/tealdeer/actions/workflows/ci.yml/badge.svg?branch=main 131 | [crates-io]: https://crates.io/crates/tealdeer 132 | [crates-io-badge]: https://img.shields.io/crates/v/tealdeer.svg 133 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | Run linting: 4 | 5 | $ cargo clean && cargo clippy 6 | 7 | Set variables: 8 | 9 | $ export VERSION=X.Y.Z 10 | $ export GPG_KEY=20EE002D778AE197EF7D0D2CB993FF98A90C9AB1 11 | 12 | Update version numbers: 13 | 14 | $ vim Cargo.toml 15 | $ cargo update -p tealdeer 16 | 17 | Update docs: 18 | 19 | $ cargo run -- --help > docs/src/usage.txt 20 | 21 | Update changelog: 22 | 23 | $ vim CHANGELOG.md 24 | 25 | Commit & tag: 26 | 27 | $ git commit -S${GPG_KEY} -m "Release v${VERSION}" 28 | $ git tag -s -u ${GPG_KEY} v${VERSION} -m "Version ${VERSION}" 29 | 30 | Publish: 31 | 32 | $ cargo publish 33 | $ git push && git push --tags 34 | 35 | Then publish the release on GitHub. 36 | -------------------------------------------------------------------------------- /benchmarks/Dockerfile: -------------------------------------------------------------------------------- 1 | # Benchmark Dockerfile for tealdeer 2 | # 3 | # To run the benchmarks, execute 4 | # 5 | # docker build --pull -t tldr-benchmark . 6 | # docker run --privileged --rm -it tldr-benchmark 7 | # 8 | # as root in the directory of this Dockerfile. This will build the compared 9 | # clients and benchmark them with `hyperfine` at the end. 10 | # 11 | # The `--privileged` flag is needed to drop the disk caches before every run. If 12 | # you want to test with hot caches or don't want to use this flag, you will have 13 | # to remove the `--prepare` line from the `hyperfine` command at the end of this 14 | # file and rebuild the image. 15 | 16 | ################################################################################ 17 | 18 | FROM rust AS tealdeer-builder 19 | 20 | WORKDIR /build 21 | RUN git clone https://github.com/tealdeer-rs/tealdeer.git \ 22 | && cd tealdeer \ 23 | && cargo build --release \ 24 | && mkdir /build-outputs \ 25 | && cp target/release/tldr /build-outputs/tealdeer 26 | 27 | ################################################################################ 28 | 29 | FROM ubuntu:latest AS tldr-c-builder 30 | 31 | WORKDIR /build 32 | RUN apt-get update && apt-get install -y build-essential git && rm -rf /var/lib/apt/lists/* 33 | RUN git clone https://github.com/tldr-pages/tldr-c-client.git \ 34 | && cd tldr-c-client \ 35 | && DEBIAN_FRONTEND=noninteractive ./deps.sh \ 36 | && make \ 37 | && mkdir /build-outputs /deps \ 38 | && cp tldr /build-outputs/tldr-c \ 39 | && cp deps.sh /deps/tldr-c-deps.sh 40 | 41 | ################################################################################ 42 | 43 | FROM haskell AS haskell-builder 44 | 45 | WORKDIR /build 46 | 47 | RUN git clone https://github.com/psibi/tldr-hs.git \ 48 | && cd tldr-hs \ 49 | && stack build --install-ghc 50 | 51 | RUN git clone https://github.com/gutjuri/fast-tldr \ 52 | && cd fast-tldr \ 53 | && stack build --install-ghc 54 | 55 | RUN mkdir /build-outputs \ 56 | && find tldr-hs/.stack-work/dist -type f -iname tldr -exec mv '{}' /build-outputs/tldr-hs \; \ 57 | && find fast-tldr/.stack-work/dist -type f -iname tldr -exec mv '{}' /build-outputs/fast-tldr \; 58 | 59 | ################################################################################ 60 | 61 | FROM node:slim AS node-builder 62 | 63 | WORKDIR /build-outputs 64 | RUN npm install tldr \ 65 | && cp $(which node) . \ 66 | && echo './node -- ./node_modules/.bin/tldr "$@"' > tldr-node \ 67 | && chmod +x tldr-node 68 | 69 | ################################################################################ 70 | 71 | FROM euantorano/zig:0.8.0 AS zig-builder 72 | 73 | WORKDIR /build 74 | RUN apk add git \ 75 | && git clone https://gitlab.com/ve-nt/outfieldr.git \ 76 | && cd outfieldr \ 77 | && git submodule init \ 78 | && git submodule update \ 79 | && zig build -Drelease-safe \ 80 | && mkdir /build-outputs \ 81 | && cp bin/tldr /build-outputs/outfieldr 82 | 83 | ################################################################################ 84 | 85 | FROM ubuntu:latest AS benchmark 86 | 87 | ENV LANG="en_US.UTF-8" 88 | 89 | WORKDIR /deps 90 | RUN apt-get update && apt-get install -y wget unzip python3 python3-venv && rm -rf /var/lib/apt/lists/* 91 | COPY --from=tldr-c-builder /deps/* ./ 92 | RUN for file in *; do DEBIAN_FRONTEND=noninteractive sh $file; done 93 | 94 | WORKDIR /clients 95 | COPY --from=tealdeer-builder /build-outputs/* ./ 96 | COPY --from=tldr-c-builder /build-outputs/* ./ 97 | COPY --from=haskell-builder /build-outputs/* ./ 98 | RUN wget -qO tldr-bash https://4e4.win/tldr && chmod +x tldr-bash 99 | COPY --from=node-builder /build-outputs/node /build-outputs/tldr-node ./ 100 | COPY --from=node-builder /build-outputs/node_modules/ ./node_modules/ 101 | COPY --from=zig-builder /build-outputs/* ./ 102 | 103 | # python is really hard to isolate in a package, using pyinstaller didn't really work either, so for now we just use it like this 104 | RUN python3 -m venv tldr-python \ 105 | && cd tldr-python \ 106 | && bash -c 'source bin/activate; pip install wheel; pip install tldr; deactivate' \ 107 | && cd .. \ 108 | && echo '#!/bin/bash' > tldr-python.bash \ 109 | && echo 'source tldr-python/bin/activate; tldr $@' >> tldr-python.bash \ 110 | && chmod +x tldr-python.bash 111 | 112 | # Update all the individual caches 113 | RUN bash -c 'mkdir -p /caches/{tealdeer,tldr-c,tldr-hs,fast-tldr,tldr-bash,tldr-node,tldr-python,outfieldr/.local/share}' \ 114 | && TEALDEER_CACHE_DIR=/caches/tealdeer ./tealdeer -u \ 115 | && TLDR_CACHE_DIR=/caches/tldr-c ./tldr-c -u \ 116 | && XDG_DATA_HOME=/caches/tldr-hs ./tldr-hs -u \ 117 | && XDG_DATA_HOME=/caches/fast-tldr ./fast-tldr -u \ 118 | && XDG_DATA_HOME=/caches/tldr-bash ./tldr-bash -u \ 119 | && HOME=/caches/tldr-node ./tldr-node -u \ 120 | && HOME=/caches/tldr-python ./tldr-python.bash -u \ 121 | && HOME=/caches/outfieldr ./outfieldr -u 122 | 123 | WORKDIR /tools 124 | RUN wget -q https://github.com/sharkdp/hyperfine/releases/download/v1.11.0/hyperfine_1.11.0_amd64.deb && dpkg -i hyperfine_1.11.0_amd64.deb 125 | 126 | ENV PAGE="tar" 127 | WORKDIR /clients 128 | CMD hyperfine \ 129 | --warmup 10 \ 130 | --runs 50 \ 131 | --prepare 'sync; echo 3 | tee /proc/sys/vm/drop_caches' \ 132 | "TEALDEER_CACHE_DIR=/caches/tealdeer ./tealdeer $PAGE" \ 133 | "TLDR_CACHE_DIR=/caches/tldr-c ./tldr-c $PAGE" \ 134 | "XDG_DATA_HOME=/caches/tldr-hs ./tldr-hs $PAGE" \ 135 | "XDG_DATA_HOME=/caches/fast-tldr ./fast-tldr $PAGE" \ 136 | "XDG_DATA_HOME=/caches/tldr-bash TLDR_LESS=0 ./tldr-bash $PAGE" \ 137 | "HOME=/caches/tldr-python ./tldr-python.bash $PAGE" \ 138 | "HOME=/caches/outfieldr ./outfieldr $PAGE" \ 139 | "HOME=/caches/tldr-node ./tldr-node $PAGE" 140 | -------------------------------------------------------------------------------- /completion/bash_tealdeer: -------------------------------------------------------------------------------- 1 | # tealdeer bash completion 2 | 3 | _tealdeer() 4 | { 5 | local cur prev words cword 6 | _init_completion || return 7 | 8 | case $prev in 9 | -h|--help|-v|--version|-l|--list|-u|--update|--no-auto-update|-c|--clear-cache|--pager|-r|--raw|--show-paths|--seed-config|-q|--quiet) 10 | return 11 | ;; 12 | -f|--render) 13 | _filedir 14 | return 15 | ;; 16 | -p|--platform) 17 | COMPREPLY=( $(compgen -W 'linux macos sunos windows android freebsd netbsd openbsd' -- "${cur}") ) 18 | return 19 | ;; 20 | --color) 21 | COMPREPLY=( $(compgen -W 'always auto never' -- "${cur}") ) 22 | return 23 | ;; 24 | esac 25 | 26 | if [[ $cur == -* ]]; then 27 | COMPREPLY=( $( compgen -W '$( _parse_help "$1" )' -- "$cur" ) ) 28 | return 29 | fi 30 | if tldrlist=$(tldr -l 2>/dev/null); then 31 | COMPREPLY=( $(compgen -W '$( echo "$tldrlist" | tr -d , )' -- "${cur}") ) 32 | fi 33 | } 34 | 35 | complete -F _tealdeer tldr 36 | -------------------------------------------------------------------------------- /completion/fish_tealdeer: -------------------------------------------------------------------------------- 1 | # 2 | # Completions for the tealdeer implementation of tldr 3 | # https://github.com/tealdeer-rs/tealdeer/ 4 | # 5 | 6 | complete -c tldr -s h -l help -d 'Print the help message.' -f 7 | complete -c tldr -s v -l version -d 'Show version information.' -f 8 | complete -c tldr -s l -l list -d 'List all commands in the cache.' -f 9 | complete -c tldr -s f -l render -d 'Render a specific markdown file.' -r 10 | complete -c tldr -s p -l platform -d 'Override the operating system.' -xa 'linux macos sunos windows android freebsd netbsd openbsd' 11 | complete -c tldr -s L -l language -d 'Override the language' -x 12 | complete -c tldr -s u -l update -d 'Update the local cache.' -f 13 | complete -c tldr -l no-auto-update -d 'If auto update is configured, disable it for this run.' -f 14 | complete -c tldr -s c -l clear-cache -d 'Clear the local cache.' -f 15 | complete -c tldr -l pager -d 'Use a pager to page output.' -f 16 | complete -c tldr -s r -l raw -d 'Display the raw markdown instead of rendering it.' -f 17 | complete -c tldr -s q -l quiet -d 'Suppress informational messages.' -f 18 | complete -c tldr -l show-paths -d 'Show file and directory paths used by tealdeer.' -f 19 | complete -c tldr -l seed-config -d 'Create a basic config.' -f 20 | complete -c tldr -l color -d 'Controls when to use color.' -xa 'always auto never' 21 | 22 | function __tealdeer_entries 23 | if set entries (tldr --list 2>/dev/null) 24 | string replace -a -i -r "\,\s" "\n" $entries 25 | end 26 | end 27 | 28 | complete -f -c tldr -a '(__tealdeer_entries)' 29 | -------------------------------------------------------------------------------- /completion/zsh_tealdeer: -------------------------------------------------------------------------------- 1 | #compdef tldr 2 | 3 | _applications() { 4 | local -a commands 5 | if commands=(${(uonzf)"$(tldr --list 2>/dev/null)"//:/\\:}); then 6 | _describe -t commands 'command' commands 7 | fi 8 | } 9 | 10 | _tealdeer() { 11 | local I="-h --help -v --version" 12 | integer ret=1 13 | local -a args 14 | 15 | args+=( 16 | "($I -l --list)"{-l,--list}"[List all commands in the cache]" 17 | "($I -f --render)"{-f,--render}"[Render a specific markdown file]:file:_files" 18 | "($I -p --platform)"{-p,--platform}'[Override the operating system]:platform:(( 19 | linux 20 | macos 21 | sunos 22 | windows 23 | android 24 | freebsd 25 | netbsd 26 | openbsd 27 | ))' 28 | "($I -L --language)"{-L,--language}"[Override the language settings]:lang" 29 | "($I -u --update)"{-u,--update}"[Update the local cache]" 30 | "($I)--no-auto-update[If auto update is configured, disable it for this run]" 31 | "($I -c --clear-cache)"{-c,--clear-cache}"[Clear the local cache]" 32 | "($I)--pager[Use a pager to page output]" 33 | "($I -r --raw)"{-r,--raw}"[Display the raw markdown instead of rendering it]" 34 | "($I -q --quiet)"{-q,--quiet}"[Suppress informational messages]" 35 | "($I)--show-paths[Show file and directory paths used by tealdeer]" 36 | "($I)--seed-config[Create a basic config]" 37 | "($I)--color[Controls when to use color]:when:(( 38 | always 39 | auto 40 | never 41 | ))" 42 | '(- *)'{-h,--help}'[Display help]' 43 | '(- *)'{-v,--version}'[Show version information]' 44 | '1: :_applications' 45 | ) 46 | 47 | _arguments $args[@] && ret=0 48 | return ret 49 | } 50 | 51 | _tealdeer 52 | -------------------------------------------------------------------------------- /deer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tealdeer-rs/tealdeer/9bb95ad11dcc8ce8bf1e79bd1b1c95a84d148c64/deer.png -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Tealdeer Docs 2 | 3 | To build the docs, install [mdbook](https://github.com/rust-lang/mdBook). 4 | 5 | You can build the HTML with `mdbook build`. 6 | 7 | To serve the docs on `localhost:3000` and watch for changes, use `mdbook 8 | serve`. 9 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Danilo Bargen"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | title = "Tealdeer User Manual" 7 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Introduction](./intro.md) 4 | 5 | - [Installing](./installing.md) 6 | - [Usage](./usage.md) 7 | - [Custom Pages and Patches](./usage_custom_pages.md) 8 | - [Configuration](./config.md) 9 | - [Section: \[display\]](./config_display.md) 10 | - [Section: \[style\]](./config_style.md) 11 | - [Section: \[updates\]](./config_updates.md) 12 | - [Section: \[directories\]](./config_directories.md) 13 | - [Tips and Tricks](./tips_and_tricks.md) 14 | -------------------------------------------------------------------------------- /docs/src/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Tealdeer can be customized with a config file in [TOML 4 | format](https://toml.io/) called `config.toml`. 5 | 6 | ## Configfile Path 7 | 8 | The configuration file path follows OS conventions (e.g. 9 | `$XDG_CONFIG_HOME/tealdeer/config.toml` on Linux). The paths can be queried 10 | with the following command: 11 | 12 | $ tldr --show-paths 13 | 14 | Creating the config file can be done manually or with the help of `tldr`: 15 | 16 | $ tldr --seed-config 17 | 18 | On Linux, this will usually be `~/.config/tealdeer/config.toml`. 19 | 20 | ## Config Example 21 | 22 | Here's an example configuration file. Note that this example does not contain 23 | all possible config options. For details on the things that can be configured, 24 | please refer to the subsections of this documentation page 25 | ([display](config_display.html), [style](config_style.html), 26 | [updates](config_updates.html) or [directories](config_directories.html)). 27 | 28 | ```toml 29 | [display] 30 | compact = false 31 | use_pager = true 32 | 33 | [style.command_name] 34 | foreground = "red" 35 | 36 | [style.example_text] 37 | foreground = "green" 38 | 39 | [style.example_code] 40 | foreground = "blue" 41 | 42 | [style.example_variable] 43 | foreground = "blue" 44 | underline = true 45 | 46 | [updates] 47 | auto_update = true 48 | ``` 49 | 50 | ## Override Config Directory 51 | 52 | The directory where the configuration file resides may be overwritten by the 53 | environment variable `TEALDEER_CONFIG_DIR`. Remember to use an absolute path. 54 | Variable expansion will not be performed on the path. 55 | -------------------------------------------------------------------------------- /docs/src/config_directories.md: -------------------------------------------------------------------------------- 1 | # Section: \[directories\] 2 | 3 | This section allows overriding some directory paths. 4 | 5 | ## `cache_dir` 6 | 7 | Override the cache directory. Remember to use an absolute path. Variable 8 | expansion will not be performed on the path. If the directory does not yet 9 | exist, it will be created. 10 | 11 | [directories] 12 | cache_dir = "/home/myuser/.tealdeer-cache/" 13 | 14 | If no `cache_dir` is specified, tealdeer will fall back to a location that 15 | follows OS conventions. On Linux, it will usually be at `~/.cache/tealdeer/`. 16 | Use `tldr --show-paths` to show the path that is being used. 17 | 18 | ## `custom_pages_dir` 19 | 20 | Set the directory to be used to look up [custom 21 | pages](usage_custom_pages.html). Remember to use an absolute path. Variable 22 | expansion will not be performed on the path. 23 | 24 | [directories] 25 | custom_pages_dir = "/home/myuser/custom-tldr-pages/" 26 | -------------------------------------------------------------------------------- /docs/src/config_display.md: -------------------------------------------------------------------------------- 1 | # Section: \[display\] 2 | 3 | In the `display` section you can configure the output format. 4 | 5 | ## `use_pager` 6 | 7 | Specifies whether the pager should be used by default or not (default `false`). 8 | 9 | [display] 10 | use_pager = true 11 | 12 | When enabled, `less -R` is used as pager. To override the pager command used, 13 | set the `PAGER` environment variable. 14 | 15 | NOTE: This feature is not available on Windows. 16 | 17 | ## `compact` 18 | 19 | Set this to enforce more compact output, where empty lines are stripped out 20 | (default `false`). 21 | 22 | [display] 23 | compact = true 24 | -------------------------------------------------------------------------------- /docs/src/config_style.md: -------------------------------------------------------------------------------- 1 | # Section: \[style\] 2 | 3 | Using the config file, the style (e.g. colors or underlines) can be customized. 4 | 5 | Screenshot of customized version 6 | 7 | ## Style Targets 8 | 9 | - `description`: The initial description text 10 | - `command_name`: The command name as part of the example code 11 | - `example_text`: The text that describes an example 12 | - `example_code`: The example itself (except the `command_name` and `example_variable`) 13 | - `example_variable`: The variables in the example 14 | 15 | ## Attributes 16 | 17 | - `foreground` (color string, ANSI code, or RGB, see below) 18 | - `background` (color string, ANSI code, or RGB, see below) 19 | - `underline` (`true` or `false`) 20 | - `bold` (`true` or `false`) 21 | - `italic` (`true` or `false`) 22 | 23 | Colors can be specified in one of three ways: 24 | 25 | - Color string (`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`): 26 | 27 | Example: 28 | 29 | foreground = "green" 30 | 31 | - 256 color ANSI code (*tealdeer v1.5.0+*) 32 | 33 | Example: 34 | 35 | foreground = { ansi = 4 } 36 | 37 | - 24-bit RGB color (*tealdeer v1.5.0+*) 38 | 39 | Example: 40 | 41 | background = { rgb = { r = 255, g = 255, b = 255 } } 42 | -------------------------------------------------------------------------------- /docs/src/config_updates.md: -------------------------------------------------------------------------------- 1 | # Section: \[updates\] 2 | 3 | ## Automatic updates 4 | 5 | Tealdeer can refresh the cache automatically when it is outdated. This 6 | behavior can be configured in the `updates` section and is disabled by 7 | default. 8 | 9 | ### `auto_update` 10 | 11 | Specifies whether the auto-update feature should be enabled (defaults to 12 | `false`). 13 | 14 | [updates] 15 | auto_update = true 16 | 17 | ### `auto_update_interval_hours` 18 | 19 | Duration, since the last cache update, after which the cache will be 20 | refreshed (defaults to 720 hours). This parameter is ignored if `auto_update` 21 | is set to `false`. 22 | 23 | [updates] 24 | auto_update = true 25 | auto_update_interval_hours = 24 26 | 27 | ### archive_source 28 | 29 | URL for the location of the tldr pages archive. By default the pages are 30 | fetched from the latest `tldr-pages/tldr` GitHub release. 31 | 32 | [updates] 33 | archive_source = https://my-company.example.com/tldr/ 34 | 35 | ### `tls_backend` 36 | 37 | Specifies which TLS backend to use. Try changing this setting if you encounter certificate errors. 38 | 39 | Available options: 40 | - `rustls-with-native-roots` - [Rustls][rustls] (a TLS library in Rust) with native roots 41 | - `rustls-with-webpki-roots` - Rustls with [WebPKI][rustls-webpki] roots 42 | - `native-tls` - Native TLS 43 | - SChannel on Windows 44 | - Secure Transport on macOS 45 | - OpenSSL on other platforms 46 | 47 | [updates] 48 | tls_backend = "native-tls" 49 | 50 | 51 | [rustls]: https://github.com/rustls/rustls 52 | [rustls-webpki]: https://github.com/rustls/webpki 53 | -------------------------------------------------------------------------------- /docs/src/deer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tealdeer-rs/tealdeer/9bb95ad11dcc8ce8bf1e79bd1b1c95a84d148c64/docs/src/deer.png -------------------------------------------------------------------------------- /docs/src/installing.md: -------------------------------------------------------------------------------- 1 | # Installing 2 | 3 | There are a few different ways to install tealdeer: 4 | 5 | - Through [package managers](#package-managers) 6 | - Through [static binaries](#static-binaries-linux) 7 | - Through [cargo install](#through-cargo-install) 8 | - By [building from source](#build-from-source) 9 | 10 | Additionally, when not using system packages, you can [manually install 11 | autocompletions](#autocompletion). 12 | 13 | ## Package Managers 14 | 15 | Tealdeer has been added to a few package managers: 16 | 17 | - Arch Linux: [`tealdeer`](https://archlinux.org/packages/extra/x86_64/tealdeer/) 18 | - Debian: [`tealdeer`](https://tracker.debian.org/tealdeer) 19 | - Fedora: [`tealdeer`](https://src.fedoraproject.org/rpms/rust-tealdeer) 20 | - FreeBSD: [`sysutils/tealdeer`](https://www.freshports.org/sysutils/tealdeer/) 21 | - Funtoo: [`app-misc/tealdeer`](https://github.com/funtoo/core-kit/tree/1.4-release/app-misc/tealdeer) 22 | - Homebrew: [`tealdeer`](https://formulae.brew.sh/formula/tealdeer) 23 | - MacPorts: [`tealdeer`](https://ports.macports.org/port/tealdeer/) 24 | - NetBSD: [`sysutils/tealdeer`](https://pkgsrc.se/sysutils/tealdeer) 25 | - Nix: [`tealdeer`](https://search.nixos.org/packages?query=tealdeer) 26 | - openSUSE: [`tealdeer`](https://software.opensuse.org/package/tealdeer?search_term=tealdeer) 27 | - Scoop: [`tealdeer`](https://github.com/ScoopInstaller/Main/blob/master/bucket/tealdeer.json) 28 | - Solus: [`tealdeer`](https://packages.getsol.us/shannon/t/tealdeer/) 29 | - Void Linux: [`tealdeer`](https://github.com/void-linux/void-packages/tree/master/srcpkgs/tealdeer) 30 | 31 | ## Static Binaries (Linux) 32 | 33 | Static binary builds (currently for Linux only) are available on the 34 | [GitHub releases page](https://github.com/tealdeer-rs/tealdeer/releases). 35 | Simply download the binary for your platform and run it! 36 | 37 | ## Through `cargo install` 38 | 39 | Build and install the tool via cargo... 40 | 41 | $ cargo install tealdeer 42 | 43 | ## Build From Source 44 | 45 | Release build: 46 | 47 | $ cargo build --release 48 | 49 | Release build with bundled CA roots: 50 | 51 | $ cargo build --release --no-default-features --features rustls-with-webpki-roots 52 | 53 | Debug build with logging support: 54 | 55 | $ cargo build --features logging 56 | 57 | (To enable logging at runtime, export the `RUST_LOG=tldr=debug` env variable.) 58 | 59 | ## Autocompletion 60 | 61 | Shell completion scripts are located in the folder `completion`. 62 | Just copy them to their designated location: 63 | 64 | - *Bash*: `cp completion/bash_tealdeer /usr/share/bash-completion/completions/tldr` 65 | - *Fish*: `cp completion/fish_tealdeer ~/.config/fish/completions/tldr.fish` 66 | - *Zsh*: `cp completion/zsh_tealdeer /usr/share/zsh/site-functions/_tldr` 67 | -------------------------------------------------------------------------------- /docs/src/intro.md: -------------------------------------------------------------------------------- 1 | # Tealdeer: Introduction 2 | 3 | Tealdeer is a very fast implementation of 4 | [tldr](https://github.com/tldr-pages/tldr) in Rust: Simplified, example based 5 | and community-driven man pages. 6 | 7 | ![Screenshot](screenshot-default.png) 8 | 9 | This documentation shows how to install, use and configure tealdeer. 10 | 11 | ## Links 12 | 13 | - [GitHub Project Page](https://github.com/tealdeer-rs/tealdeer) 14 | - [TLDR Pages Project](https://tldr.sh/) 15 | -------------------------------------------------------------------------------- /docs/src/screenshot-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tealdeer-rs/tealdeer/9bb95ad11dcc8ce8bf1e79bd1b1c95a84d148c64/docs/src/screenshot-custom.png -------------------------------------------------------------------------------- /docs/src/screenshot-default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tealdeer-rs/tealdeer/9bb95ad11dcc8ce8bf1e79bd1b1c95a84d148c64/docs/src/screenshot-default.png -------------------------------------------------------------------------------- /docs/src/tips_and_tricks.md: -------------------------------------------------------------------------------- 1 | # Tips and Tricks 2 | 3 | This page features some example use cases of Tealdeer. 4 | 5 | ## Showing a random page on shell start 6 | 7 | To display a randomly selected page, you can invoke `tldr` twice: One time to 8 | select a page and a second time to display this page. To randomly select a page, 9 | we use `shuf` from the GNU coreutils: 10 | 11 | ```bash 12 | tldr --quiet $(tldr --quiet --list | shuf -n1) 13 | ``` 14 | 15 | You can also add the above command to your `.bashrc` (or similar shell 16 | configuration file) to display a random page every time you start a new shell 17 | session. 18 | 19 | ## Displaying all pages with their summary 20 | 21 | If you want to extend the output of `tldr --list` with the first line summary of 22 | each page, you can run the following Python script: 23 | 24 | ```python 25 | #!/usr/bin/env python3 26 | 27 | import subprocess 28 | 29 | commands = subprocess.run( 30 | ["tldr", "--quiet", "--list"], 31 | capture_output=True, 32 | encoding="utf-8", 33 | ).stdout.splitlines() 34 | 35 | for command in commands: 36 | output = subprocess.run( 37 | ["tldr", "--quiet", command], 38 | capture_output=True, 39 | encoding="utf-8", 40 | ).stdout 41 | description = output.lstrip().split("\n\n")[0] 42 | description = " ".join(description.split()) 43 | print(f"{command} => {description}") 44 | ``` 45 | 46 | Note that there are a lot of pages and the script will run Tealdeer once for 47 | every page, so the script may take a couple of seconds to finish. 48 | 49 | ## Extending this chapter 50 | 51 | If you have an interesting setup with Tealdeer, feel free to share your 52 | configuration on [our Github repository](https://github.com/tealdeer-rs/tealdeer). 53 | -------------------------------------------------------------------------------- /docs/src/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Tealdeer is straightforward to use, through the binary named `tldr`. 4 | 5 | You can view the available options using `tldr --help`: 6 | 7 | 8 | ``` 9 | {{#include usage.txt}} 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/src/usage.txt: -------------------------------------------------------------------------------- 1 | tealdeer 1.7.2: A fast TLDR client 2 | Danilo Bargen , Niklas Mohrin 3 | 4 | Usage: tldr [OPTIONS] [COMMAND]... 5 | 6 | Arguments: 7 | [COMMAND]... The command to show (e.g. `tar` or `git log`) 8 | 9 | Options: 10 | -l, --list List all commands in the cache 11 | --edit-page Edit custom page with `EDITOR` 12 | --edit-patch Edit custom patch with `EDITOR` 13 | -f, --render Render a specific markdown file 14 | -p, --platform Override the operating system, can be specified multiple times in order 15 | of preference [possible values: linux, macos, sunos, windows, android, 16 | freebsd, netbsd, openbsd, common] 17 | -L, --language Override the language 18 | -u, --update Update the local cache 19 | --no-auto-update If auto update is configured, disable it for this run 20 | -c, --clear-cache Clear the local cache 21 | --config-path Override config file location 22 | --pager Use a pager to page output 23 | -r, --raw Display the raw markdown instead of rendering it 24 | -q, --quiet Suppress informational messages 25 | --show-paths Show file and directory paths used by tealdeer 26 | --seed-config Create a basic config 27 | --color Control whether to use color [possible values: always, auto, never] 28 | -v, --version Print the version 29 | -h, --help Print help 30 | 31 | To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/. 32 | -------------------------------------------------------------------------------- /docs/src/usage_custom_pages.md: -------------------------------------------------------------------------------- 1 | # Custom Pages and Patches 2 | 3 | > ⚠️ **Breaking change in version 1.7.0:** The file name extension for custom 4 | > pages and patches was changed: 5 | > 6 | > - `.page` → `.page.md` 7 | > - `.patch` → `.patch.md` 8 | > 9 | > If you have custom pages or patches, you need to rename them. 10 | 11 | Tealdeer allows creating new custom pages, overriding existing pages as well as 12 | extending existing pages. 13 | 14 | The directory, where these custom pages and patches can be placed, follows OS 15 | conventions. On Linux for instance, the default location is 16 | `~/.local/share/tealdeer/pages/`. To print the path used on your system, simply 17 | run `tldr --show-paths`. 18 | 19 | The custom pages directory can be [overridden by the config 20 | file](config_directories.html). 21 | 22 | ## Custom Pages 23 | 24 | To document internal command line tools, or if you want to replace an existing 25 | tldr page with one that's better suited for you, place a file with the name 26 | `.page.md` in the custom pages directory. When calling `tldr `, 27 | your custom page will be shown instead of the upstream version in the cache. 28 | 29 | Path: 30 | 31 | $CUSTOM_PAGES_DIR/.page.md 32 | 33 | Example: 34 | 35 | ~/.local/share/tealdeer/pages/ufw.page.md 36 | 37 | ## Custom Patches 38 | 39 | Sometimes you don't want to fully replace an existing upstream page, but just 40 | want to extend it with your own examples that you frequently need. In this 41 | case, use a file called `.patch.md`, it will be appended to existing 42 | pages. 43 | 44 | Path: 45 | 46 | $CUSTOM_PAGES_DIR/.patch.md 47 | 48 | Example: 49 | 50 | ~/.local/share/tealdeer/pages/ufw.patch.md 51 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Empty file, use defaults and disregard global settings 2 | -------------------------------------------------------------------------------- /scripts/upload-asset.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Upload artifacts to GitHub Actions. 4 | # 5 | # Based on: https://gist.github.com/schell/2fe896953b6728cc3c5d8d5f9f3a17a3 6 | # 7 | # Requires curl and jq on PATH 8 | 9 | # Args: 10 | # token: GitHub API user token 11 | # repo: GitHub username/reponame 12 | # tag: Name of the tag for which to create a release 13 | # description: Release description 14 | create_release() { 15 | # Args 16 | token=$1 17 | repo=$2 18 | tag=$3 19 | description=$4 20 | echo "Creating release:" 21 | echo " repo=$repo" 22 | echo " tag=$tag" 23 | echo "" 24 | 25 | # Create release 26 | http_code=$( 27 | curl -s -o create.json -w '%{http_code}' \ 28 | --header "Accept: application/vnd.github.v3+json" \ 29 | --header "Authorization: Bearer $token" \ 30 | --header "Content-Type:application/json" \ 31 | "https://api.github.com/repos/$repo/releases" \ 32 | -d '{"tag_name":"'"$tag"'","name":"'"${tag/v/Version }"'","draft":true,"body":"'"${description/\"/\\\"}"'"}' 33 | ) 34 | if [ "$http_code" == "201" ]; then 35 | echo "Release for tag $tag created." 36 | else 37 | echo "Asset upload failed with code '$http_code'." 38 | return 1 39 | fi 40 | } 41 | 42 | # Args: 43 | # token: GitHub API user token 44 | # repo: GitHub username/reponame 45 | # tag: Name of the tag for which to upload the assets 46 | # file: Path to the asset file to upload 47 | # name: Name to use for the uploaded asset 48 | upload_release_file() { 49 | # Args 50 | token=$1 51 | repo=$2 52 | tag=$3 53 | file=$4 54 | name=$5 55 | echo "Uploading:" 56 | echo " repo=$repo" 57 | echo " tag=$tag" 58 | echo " file=$file" 59 | echo " name=$name" 60 | echo "" 61 | 62 | # Determine upload URL of latest draft release for the specified tag 63 | upload_url=$( 64 | curl -s \ 65 | --header "Accept: application/vnd.github.v3+json" \ 66 | --header "Authorization: Bearer $token" \ 67 | "https://api.github.com/repos/$repo/releases" \ 68 | | jq -r '[.[] | select(.tag_name == "'"$tag"'" and .draft)][0].upload_url' \ 69 | | cut -d"{" -f'1' 70 | ) 71 | echo "Determined upload URL: $upload_url" 72 | http_code=$( 73 | curl -s -o upload.json -w '%{http_code}' \ 74 | --request POST \ 75 | --header "Accept: application/vnd.github.v3+json" \ 76 | --header "Authorization: Bearer $token" \ 77 | --header "Content-Type: application/octet-stream" \ 78 | --data-binary "@$file" "$upload_url?name=$name" 79 | ) 80 | if [ "$http_code" == "201" ]; then 81 | echo "Asset $name uploaded:" 82 | jq -r .browser_download_url upload.json 83 | else 84 | echo "Asset upload failed with code '$http_code':" 85 | cat upload.json 86 | return 1 87 | fi 88 | } 89 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsStr, 3 | fs::{self, File}, 4 | io::{BufReader, Cursor, Read}, 5 | path::{Path, PathBuf}, 6 | time::{Duration, SystemTime}, 7 | }; 8 | 9 | use anyhow::{ensure, Context, Result}; 10 | use log::debug; 11 | use ureq::tls::{RootCerts, TlsConfig, TlsProvider}; 12 | use ureq::Agent; 13 | use walkdir::{DirEntry, WalkDir}; 14 | use zip::ZipArchive; 15 | 16 | use crate::{config::TlsBackend, types::PlatformType, utils::print_warning}; 17 | 18 | pub static TLDR_PAGES_DIR: &str = "tldr-pages"; 19 | static TLDR_OLD_PAGES_DIR: &str = "tldr-master"; 20 | 21 | #[derive(Debug)] 22 | pub struct Cache { 23 | cache_dir: PathBuf, 24 | enable_styles: bool, 25 | tls_backend: TlsBackend, 26 | } 27 | 28 | #[derive(Debug)] 29 | pub struct PageLookupResult { 30 | pub page_path: PathBuf, 31 | pub patch_path: Option, 32 | } 33 | 34 | impl PageLookupResult { 35 | pub fn with_page(page_path: PathBuf) -> Self { 36 | Self { 37 | page_path, 38 | patch_path: None, 39 | } 40 | } 41 | 42 | pub fn with_optional_patch(mut self, patch_path: Option) -> Self { 43 | self.patch_path = patch_path; 44 | self 45 | } 46 | 47 | /// Create a buffered reader that sequentially reads from the page and the 48 | /// patch, as if they were concatenated. 49 | /// 50 | /// This will return an error if either the page file or the patch file 51 | /// cannot be opened. 52 | pub fn reader(&self) -> Result>> { 53 | // Open page file 54 | let page_file = File::open(&self.page_path) 55 | .with_context(|| format!("Could not open page file at {}", self.page_path.display()))?; 56 | 57 | // Open patch file 58 | let patch_file_opt = match &self.patch_path { 59 | Some(path) => Some( 60 | File::open(path) 61 | .with_context(|| format!("Could not open patch file at {}", path.display()))?, 62 | ), 63 | None => None, 64 | }; 65 | 66 | // Create chained reader from file(s) 67 | // 68 | // Note: It might be worthwhile to create our own struct that accepts 69 | // the page and patch files and that will read them sequentially, 70 | // because it avoids the boxing below. However, the performance impact 71 | // would first need to be shown to be significant using a benchmark. 72 | Ok(BufReader::new(if let Some(patch_file) = patch_file_opt { 73 | Box::new(page_file.chain(&b"\n"[..]).chain(patch_file)) as Box 74 | } else { 75 | Box::new(page_file) as Box 76 | })) 77 | } 78 | } 79 | 80 | pub enum CacheFreshness { 81 | /// The cache is still fresh (less than `MAX_CACHE_AGE` old) 82 | Fresh, 83 | /// The cache is stale and should be updated 84 | Stale(Duration), 85 | /// The cache is missing 86 | Missing, 87 | } 88 | 89 | impl Cache { 90 | pub fn new

(cache_dir: P, enable_styles: bool, tls_backend: TlsBackend) -> Self 91 | where 92 | P: Into, 93 | { 94 | Self { 95 | cache_dir: cache_dir.into(), 96 | enable_styles, 97 | tls_backend, 98 | } 99 | } 100 | 101 | pub fn cache_dir(&self) -> &Path { 102 | &self.cache_dir 103 | } 104 | 105 | /// Make sure that the cache directory exists and is a directory. 106 | /// If necessary, create the directory. 107 | fn ensure_cache_dir_exists(&self) -> Result<()> { 108 | // Check whether `cache_dir` exists and is a directory 109 | let (cache_dir_exists, cache_dir_is_dir) = self 110 | .cache_dir 111 | .metadata() 112 | .map_or((false, false), |md| (true, md.is_dir())); 113 | ensure!( 114 | !cache_dir_exists || cache_dir_is_dir, 115 | "Cache directory path `{}` is not a directory", 116 | self.cache_dir.display(), 117 | ); 118 | 119 | if !cache_dir_exists { 120 | // If missing, try to create the complete directory path 121 | fs::create_dir_all(&self.cache_dir).with_context(|| { 122 | format!( 123 | "Cache directory path `{}` cannot be created", 124 | self.cache_dir.display(), 125 | ) 126 | })?; 127 | eprintln!( 128 | "Successfully created cache directory path `{}`.", 129 | self.cache_dir.display(), 130 | ); 131 | } 132 | 133 | Ok(()) 134 | } 135 | 136 | fn pages_dir(&self) -> PathBuf { 137 | self.cache_dir.join(TLDR_PAGES_DIR) 138 | } 139 | 140 | /// Update the pages cache from the specified URL. 141 | pub fn update(&self, archive_source: &str) -> Result<()> { 142 | self.ensure_cache_dir_exists()?; 143 | 144 | let archive_url = format!("{archive_source}/tldr.zip"); 145 | 146 | let client = Self::build_client(self.tls_backend)?; 147 | // First, download the compressed data 148 | let bytes: Vec = Self::download(&client, &archive_url)?; 149 | 150 | // Decompress the response body into an `Archive` 151 | let mut archive = ZipArchive::new(Cursor::new(bytes)) 152 | .context("Could not decompress downloaded ZIP archive")?; 153 | 154 | // Clear cache directory 155 | // Note: This is not the best solution. Ideally we would download the 156 | // archive to a temporary directory and then swap the two directories. 157 | // But renaming a directory doesn't work across filesystems and Rust 158 | // does not yet offer a recursive directory copying function. So for 159 | // now, we'll use this approach. 160 | self.clear() 161 | .context("Could not clear the cache directory")?; 162 | 163 | // Extract archive into pages dir 164 | archive 165 | .extract(self.pages_dir()) 166 | .context("Could not unpack compressed data")?; 167 | 168 | Ok(()) 169 | } 170 | 171 | /// Return the duration since the cache directory was last modified. 172 | pub fn last_update(&self) -> Option { 173 | if let Ok(metadata) = fs::metadata(self.pages_dir()) { 174 | if let Ok(mtime) = metadata.modified() { 175 | let now = SystemTime::now(); 176 | return now.duration_since(mtime).ok(); 177 | } 178 | } 179 | None 180 | } 181 | 182 | /// Return the freshness of the cache (fresh, stale or missing). 183 | pub fn freshness(&self) -> CacheFreshness { 184 | match self.last_update() { 185 | Some(ago) if ago > crate::config::MAX_CACHE_AGE => CacheFreshness::Stale(ago), 186 | Some(_) => CacheFreshness::Fresh, 187 | None => CacheFreshness::Missing, 188 | } 189 | } 190 | 191 | /// Return the platform directory. 192 | fn get_platform_dir(platform: PlatformType) -> &'static str { 193 | match platform { 194 | PlatformType::Linux => "linux", 195 | PlatformType::OsX => "osx", 196 | PlatformType::SunOs => "sunos", 197 | PlatformType::Windows => "windows", 198 | PlatformType::Android => "android", 199 | PlatformType::FreeBsd => "freebsd", 200 | PlatformType::NetBsd => "netbsd", 201 | PlatformType::OpenBsd => "openbsd", 202 | PlatformType::Common => "common", 203 | } 204 | } 205 | 206 | /// Check for pages for a given platform in one of the given languages. 207 | fn find_page_for_platform( 208 | page_name: &str, 209 | pages_dir: &Path, 210 | platform: &str, 211 | language_dirs: &[String], 212 | ) -> Option { 213 | language_dirs 214 | .iter() 215 | .map(|lang_dir| pages_dir.join(lang_dir).join(platform).join(page_name)) 216 | .find(|path| path.exists() && path.is_file()) 217 | } 218 | 219 | /// Look up custom patch (.patch.md). If it exists, store it in a variable. 220 | fn find_patch(patch_name: &str, custom_pages_dir: Option<&Path>) -> Option { 221 | custom_pages_dir 222 | .map(|custom_dir| custom_dir.join(patch_name)) 223 | .filter(|path| path.exists() && path.is_file()) 224 | } 225 | 226 | /// Search for a page and return the path to it. 227 | pub fn find_page( 228 | &self, 229 | name: &str, 230 | languages: &[String], 231 | custom_pages_dir: Option<&Path>, 232 | platforms: &[PlatformType], 233 | ) -> Option { 234 | let page_filename = format!("{name}.md"); 235 | let patch_filename = format!("{name}.patch.md"); 236 | let custom_filename = format!("{name}.page.md"); 237 | 238 | // Determine directory paths 239 | let pages_dir = self.pages_dir(); 240 | let lang_dirs: Vec = languages 241 | .iter() 242 | .map(|lang| { 243 | if lang == "en" { 244 | String::from("pages") 245 | } else { 246 | format!("pages.{lang}") 247 | } 248 | }) 249 | .collect(); 250 | 251 | // Look up custom page (.page.md). If it exists, return it directly 252 | if let Some(config_dir) = custom_pages_dir { 253 | // TODO: Remove this check 1 year after version 1.7.0 was released 254 | self.check_for_old_custom_pages(config_dir); 255 | 256 | let custom_page = config_dir.join(custom_filename); 257 | if custom_page.exists() && custom_page.is_file() { 258 | return Some(PageLookupResult::with_page(custom_page)); 259 | } 260 | } 261 | 262 | let patch_path = Self::find_patch(&patch_filename, custom_pages_dir); 263 | 264 | // Try to find a platform specific path next, in the order supplied by the user, and append custom patch to it. 265 | for &platform in platforms { 266 | let platform_dir = Cache::get_platform_dir(platform); 267 | if let Some(page) = 268 | Self::find_page_for_platform(&page_filename, &pages_dir, platform_dir, &lang_dirs) 269 | { 270 | return Some(PageLookupResult::with_page(page).with_optional_patch(patch_path)); 271 | } 272 | } 273 | 274 | None 275 | } 276 | 277 | /// Return the available pages. 278 | pub fn list_pages( 279 | &self, 280 | custom_pages_dir: Option<&Path>, 281 | platforms: &[PlatformType], 282 | ) -> Vec { 283 | // Determine platforms directory and platform 284 | let platforms_dir = self.pages_dir().join("pages"); 285 | let platform_dirs: Vec<&'static str> = platforms 286 | .iter() 287 | .map(|&p| Self::get_platform_dir(p)) 288 | .collect(); 289 | 290 | // Closure that allows the WalkDir instance to traverse platform 291 | // relevant page directories, but not others. 292 | let should_walk = |entry: &DirEntry| -> bool { 293 | let file_type = entry.file_type(); 294 | let Some(file_name) = entry.file_name().to_str() else { 295 | return false; 296 | }; 297 | if file_type.is_dir() { 298 | return platform_dirs.contains(&file_name); 299 | } else if file_type.is_file() { 300 | return true; 301 | } 302 | false 303 | }; 304 | 305 | let to_stem = |entry: DirEntry| -> Option { 306 | entry 307 | .path() 308 | .file_stem() 309 | .and_then(OsStr::to_str) 310 | .map(str::to_string) 311 | }; 312 | 313 | let to_stem_custom = |entry: DirEntry| -> Option { 314 | entry 315 | .path() 316 | .file_name() 317 | .and_then(OsStr::to_str) 318 | .and_then(|s| s.strip_suffix(".page.md")) 319 | .map(str::to_string) 320 | }; 321 | 322 | // Recursively walk through platform specific directory 323 | let mut pages = WalkDir::new(platforms_dir) 324 | .min_depth(1) // Skip root directory 325 | .into_iter() 326 | .filter_entry(should_walk) // Filter out pages for other architectures 327 | .filter_map(Result::ok) // Convert results to options, filter out errors 328 | .filter_map(|e| { 329 | let extension = e.path().extension().unwrap_or_default(); 330 | if e.file_type().is_file() && extension == "md" { 331 | to_stem(e) 332 | } else { 333 | None 334 | } 335 | }) 336 | .collect::>(); 337 | 338 | if let Some(custom_pages_dir) = custom_pages_dir { 339 | let is_page = |entry: &DirEntry| -> bool { 340 | entry.file_type().is_file() 341 | && entry 342 | .path() 343 | .file_name() 344 | .and_then(OsStr::to_str) 345 | .is_some_and(|file_name| file_name.ends_with(".page.md")) 346 | }; 347 | 348 | let custom_pages = WalkDir::new(custom_pages_dir) 349 | .min_depth(1) 350 | .max_depth(1) 351 | .into_iter() 352 | .filter_entry(is_page) 353 | .filter_map(Result::ok) 354 | .filter_map(to_stem_custom); 355 | 356 | pages.extend(custom_pages); 357 | } 358 | 359 | pages.sort(); 360 | pages.dedup(); 361 | pages 362 | } 363 | 364 | /// Delete the cache directory 365 | /// 366 | /// Returns true if the cache was deleted and false if the cache dir did 367 | /// not exist. 368 | pub fn clear(&self) -> Result { 369 | if !self.cache_dir.exists() { 370 | return Ok(false); 371 | } 372 | ensure!( 373 | self.cache_dir.is_dir(), 374 | "Cache path ({}) is not a directory.", 375 | self.cache_dir.display(), 376 | ); 377 | 378 | // Delete old tldr-pages cache location as well if present 379 | // TODO: To be removed in the future 380 | for pages_dir_name in [TLDR_PAGES_DIR, TLDR_OLD_PAGES_DIR] { 381 | let pages_dir = self.cache_dir.join(pages_dir_name); 382 | 383 | if pages_dir.exists() { 384 | fs::remove_dir_all(&pages_dir).with_context(|| { 385 | format!( 386 | "Could not remove the cache directory at {}", 387 | pages_dir.display() 388 | ) 389 | })?; 390 | } 391 | } 392 | 393 | Ok(true) 394 | } 395 | 396 | /// Check for old custom pages (without .md suffix) and print a warning. 397 | fn check_for_old_custom_pages(&self, custom_pages_dir: &Path) { 398 | let old_custom_pages_exist = WalkDir::new(custom_pages_dir) 399 | .min_depth(1) 400 | .max_depth(1) 401 | .into_iter() 402 | .filter_entry(|entry| entry.file_type().is_file()) 403 | .any(|entry| { 404 | if let Ok(entry) = entry { 405 | let extension = entry.path().extension(); 406 | if let Some(extension) = extension { 407 | extension == "page" || extension == "patch" 408 | } else { 409 | false 410 | } 411 | } else { 412 | false 413 | } 414 | }); 415 | if old_custom_pages_exist { 416 | print_warning( 417 | self.enable_styles, 418 | &format!( 419 | "Custom pages using the old naming convention were found in {}.\n\ 420 | Please rename them to follow the new convention:\n\ 421 | - `.page` → `.page.md`\n\ 422 | - `.patch` → `.patch.md`", 423 | custom_pages_dir.display() 424 | ), 425 | ); 426 | } 427 | } 428 | } 429 | 430 | impl Cache { 431 | fn build_client(tls_backend: TlsBackend) -> Result { 432 | let tls_builder = match tls_backend { 433 | #[cfg(feature = "native-tls")] 434 | TlsBackend::NativeTls => TlsConfig::builder() 435 | .provider(TlsProvider::NativeTls) 436 | .root_certs(RootCerts::PlatformVerifier), 437 | #[cfg(feature = "rustls-with-webpki-roots")] 438 | TlsBackend::RustlsWithWebpkiRoots => TlsConfig::builder() 439 | .provider(TlsProvider::Rustls) 440 | .root_certs(RootCerts::WebPki), 441 | #[cfg(feature = "rustls-with-native-roots")] 442 | TlsBackend::RustlsWithNativeRoots => TlsConfig::builder() 443 | .provider(TlsProvider::Rustls) 444 | .root_certs(RootCerts::PlatformVerifier), 445 | }; 446 | let config = Agent::config_builder() 447 | .tls_config(tls_builder.build()) 448 | .build(); 449 | 450 | Ok(config.into()) 451 | } 452 | 453 | /// Download the archive from the specified URL. 454 | fn download(client: &Agent, archive_url: &str) -> Result> { 455 | let response = client 456 | .get(archive_url) 457 | .call() 458 | .with_context(|| format!("Could not download tldr pages from {archive_url}"))?; 459 | let mut buf: Vec = Vec::new(); 460 | response.into_body().into_reader().read_to_end(&mut buf)?; 461 | debug!("{} bytes downloaded", buf.len()); 462 | Ok(buf) 463 | } 464 | } 465 | 466 | /// Unit Tests for cache module 467 | #[cfg(test)] 468 | mod tests { 469 | use super::*; 470 | 471 | use std::{ 472 | fs::File, 473 | io::{Read, Write}, 474 | }; 475 | 476 | #[test] 477 | fn test_reader_with_patch() { 478 | // Write test files 479 | let dir = tempfile::tempdir().unwrap(); 480 | let page_path = dir.path().join("test.page.md"); 481 | let patch_path = dir.path().join("test.patch.md"); 482 | { 483 | let mut f1 = File::create(&page_path).unwrap(); 484 | f1.write_all(b"Hello\n").unwrap(); 485 | let mut f2 = File::create(&patch_path).unwrap(); 486 | f2.write_all(b"World").unwrap(); 487 | } 488 | 489 | // Create chained reader from lookup result 490 | let lr = PageLookupResult::with_page(page_path).with_optional_patch(Some(patch_path)); 491 | let mut reader = lr.reader().unwrap(); 492 | 493 | // Read into a Vec 494 | let mut buf = Vec::new(); 495 | reader.read_to_end(&mut buf).unwrap(); 496 | 497 | assert_eq!(&buf, b"Hello\n\nWorld"); 498 | } 499 | 500 | #[test] 501 | fn test_reader_without_patch() { 502 | // Write test file 503 | let dir = tempfile::tempdir().unwrap(); 504 | let page_path = dir.path().join("test.page.md"); 505 | { 506 | let mut f = File::create(&page_path).unwrap(); 507 | f.write_all(b"Hello\n").unwrap(); 508 | } 509 | 510 | // Create chained reader from lookup result 511 | let lr = PageLookupResult::with_page(page_path); 512 | let mut reader = lr.reader().unwrap(); 513 | 514 | // Read into a Vec 515 | let mut buf = Vec::new(); 516 | reader.read_to_end(&mut buf).unwrap(); 517 | 518 | assert_eq!(&buf, b"Hello\n"); 519 | } 520 | 521 | #[test] 522 | #[cfg(feature = "native-tls")] 523 | fn test_create_https_client_with_native_tls() { 524 | Cache::build_client(TlsBackend::NativeTls).expect("fails to build a client."); 525 | } 526 | 527 | #[test] 528 | #[cfg(feature = "rustls-with-webpki-roots")] 529 | fn test_create_https_client_with_rustls() { 530 | Cache::build_client(TlsBackend::RustlsWithWebpkiRoots).expect("fails to build a client."); 531 | } 532 | 533 | #[test] 534 | #[cfg(feature = "rustls-with-native-roots")] 535 | fn test_create_https_client_with_rustls_with_native_roots() { 536 | Cache::build_client(TlsBackend::RustlsWithNativeRoots).expect("fails to build a client."); 537 | } 538 | } 539 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | //! Definition of the CLI arguments and options. 2 | 3 | use std::path::PathBuf; 4 | 5 | use clap::{arg, builder::ArgAction, command, ArgGroup, Parser}; 6 | 7 | use crate::types::{ColorOptions, PlatformType}; 8 | 9 | // Note: flag names are specified explicitly in clap attributes 10 | // to improve readability and allow contributors to grep names like "clear-cache" 11 | #[derive(Parser, Debug)] 12 | #[command( 13 | about = "A fast TLDR client", 14 | version, 15 | disable_version_flag = true, 16 | author, 17 | help_template = "{before-help}{name} {version}: {about-with-newline}{author-with-newline} 18 | {usage-heading} {usage} 19 | 20 | {all-args}{after-help}", 21 | after_help = "To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/.", 22 | arg_required_else_help = true, 23 | help_expected = true, 24 | group = ArgGroup::new("command_or_file").args(&["command", "render"]), 25 | )] 26 | pub(crate) struct Cli { 27 | /// The command to show (e.g. `tar` or `git log`) 28 | #[arg(num_args(1..))] 29 | pub command: Vec, 30 | 31 | /// List all commands in the cache 32 | #[arg(short = 'l', long = "list")] 33 | pub list: bool, 34 | 35 | /// Edit custom page with `EDITOR` 36 | #[arg(long, requires = "command")] 37 | pub edit_page: bool, 38 | 39 | /// Edit custom patch with `EDITOR` 40 | #[arg(long, requires = "command", conflicts_with = "edit_page")] 41 | pub edit_patch: bool, 42 | 43 | /// Render a specific markdown file 44 | #[arg( 45 | short = 'f', 46 | long = "render", 47 | value_name = "FILE", 48 | conflicts_with = "command" 49 | )] 50 | pub render: Option, 51 | 52 | /// Override the operating system, can be specified multiple times in order of preference 53 | #[arg( 54 | short = 'p', 55 | long = "platform", 56 | value_name = "PLATFORM", 57 | action = ArgAction::Append, 58 | )] 59 | pub platforms: Option>, 60 | 61 | /// Override the language 62 | #[arg(short = 'L', long = "language")] 63 | pub language: Option, 64 | 65 | /// Update the local cache 66 | #[arg(short = 'u', long = "update")] 67 | pub update: bool, 68 | 69 | /// If auto update is configured, disable it for this run 70 | #[arg(long = "no-auto-update", requires = "command_or_file")] 71 | pub no_auto_update: bool, 72 | 73 | /// Clear the local cache 74 | #[arg(short = 'c', long = "clear-cache")] 75 | pub clear_cache: bool, 76 | 77 | /// Override config file location 78 | #[arg(long = "config-path", value_name = "FILE")] 79 | pub config_path: Option, 80 | 81 | /// Use a pager to page output 82 | #[arg(long = "pager", requires = "command_or_file")] 83 | pub pager: bool, 84 | 85 | /// Display the raw markdown instead of rendering it 86 | #[arg(short = 'r', long = "raw", requires = "command_or_file")] 87 | pub raw: bool, 88 | 89 | /// Suppress informational messages 90 | #[arg(short = 'q', long = "quiet")] 91 | pub quiet: bool, 92 | 93 | /// Show file and directory paths used by tealdeer 94 | #[arg(long = "show-paths")] 95 | pub show_paths: bool, 96 | 97 | /// Create a basic config 98 | #[arg(long = "seed-config")] 99 | pub seed_config: bool, 100 | 101 | /// Control whether to use color 102 | #[arg(long = "color", value_name = "WHEN")] 103 | pub color: Option, 104 | 105 | /// Print the version 106 | // Note: We override the version flag because clap uses `-V` by default, 107 | // while TLDR specification requires `-v` to be used. 108 | #[arg(short = 'v', long = "version", action = ArgAction::Version)] 109 | pub version: (), 110 | } 111 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, fmt, 3 | fs::{self, File}, 4 | io::{ErrorKind, Read, Write}, 5 | path::{Path, PathBuf}, 6 | time::Duration, 7 | }; 8 | 9 | use anyhow::{anyhow, bail, ensure, Context, Result}; 10 | use app_dirs::{get_app_root, AppDataType}; 11 | use serde::Serialize as _; 12 | use serde_derive::{Deserialize, Serialize}; 13 | use yansi::{Color, Style}; 14 | 15 | use crate::types::PathSource; 16 | 17 | pub const CONFIG_FILE_NAME: &str = "config.toml"; 18 | pub const MAX_CACHE_AGE: Duration = Duration::from_secs(2_592_000); // 30 days 19 | const DEFAULT_UPDATE_INTERVAL_HOURS: u64 = MAX_CACHE_AGE.as_secs() / 3600; // 30 days 20 | const SUPPORTED_TLS_BACKENDS: &[RawTlsBackend] = &[ 21 | #[cfg(feature = "native-tls")] 22 | RawTlsBackend::NativeTls, 23 | #[cfg(feature = "rustls-with-webpki-roots")] 24 | RawTlsBackend::RustlsWithWebpkiRoots, 25 | #[cfg(feature = "rustls-with-native-roots")] 26 | RawTlsBackend::RustlsWithNativeRoots, 27 | ]; 28 | 29 | fn default_underline() -> bool { 30 | false 31 | } 32 | 33 | fn default_bold() -> bool { 34 | false 35 | } 36 | 37 | fn default_italic() -> bool { 38 | false 39 | } 40 | 41 | #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] 42 | #[serde(rename_all = "lowercase")] 43 | pub enum RawColor { 44 | Black, 45 | Red, 46 | Green, 47 | Yellow, 48 | Blue, 49 | Magenta, 50 | Purple, // Backwards compatibility with ansi_term (until tealdeer 1.5.0) 51 | Cyan, 52 | White, 53 | Ansi(u8), 54 | Rgb { r: u8, g: u8, b: u8 }, 55 | } 56 | 57 | impl From for Color { 58 | fn from(raw_color: RawColor) -> Self { 59 | match raw_color { 60 | RawColor::Black => Self::Black, 61 | RawColor::Red => Self::Red, 62 | RawColor::Green => Self::Green, 63 | RawColor::Yellow => Self::Yellow, 64 | RawColor::Blue => Self::Blue, 65 | RawColor::Magenta | RawColor::Purple => Self::Magenta, 66 | RawColor::Cyan => Self::Cyan, 67 | RawColor::White => Self::White, 68 | RawColor::Ansi(num) => Self::Fixed(num), 69 | RawColor::Rgb { r, g, b } => Self::Rgb(r, g, b), 70 | } 71 | } 72 | } 73 | 74 | #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 75 | struct RawStyle { 76 | pub foreground: Option, 77 | pub background: Option, 78 | #[serde(default = "default_underline")] 79 | pub underline: bool, 80 | #[serde(default = "default_bold")] 81 | pub bold: bool, 82 | #[serde(default = "default_italic")] 83 | pub italic: bool, 84 | } 85 | 86 | #[allow(clippy::derivable_impls)] // Explicitly control defaults 87 | impl Default for RawStyle { 88 | fn default() -> Self { 89 | Self { 90 | foreground: None, 91 | background: None, 92 | underline: false, 93 | bold: false, 94 | italic: false, 95 | } 96 | } 97 | } 98 | 99 | impl From for Style { 100 | fn from(raw_style: RawStyle) -> Self { 101 | let mut style = Self::default(); 102 | 103 | if let Some(foreground) = raw_style.foreground { 104 | style = style.fg(Color::from(foreground)); 105 | } 106 | 107 | if let Some(background) = raw_style.background { 108 | style = style.bg(Color::from(background)); 109 | } 110 | 111 | if raw_style.underline { 112 | style = style.underline(); 113 | } 114 | 115 | if raw_style.bold { 116 | style = style.bold(); 117 | } 118 | 119 | if raw_style.italic { 120 | style = style.italic(); 121 | } 122 | 123 | style 124 | } 125 | } 126 | 127 | #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] 128 | struct RawStyleConfig { 129 | #[serde(default)] 130 | pub description: RawStyle, 131 | #[serde(default)] 132 | pub command_name: RawStyle, 133 | #[serde(default)] 134 | pub example_text: RawStyle, 135 | #[serde(default)] 136 | pub example_code: RawStyle, 137 | #[serde(default)] 138 | pub example_variable: RawStyle, 139 | } 140 | 141 | impl From for StyleConfig { 142 | fn from(raw_style_config: RawStyleConfig) -> Self { 143 | Self { 144 | command_name: raw_style_config.command_name.into(), 145 | description: raw_style_config.description.into(), 146 | example_text: raw_style_config.example_text.into(), 147 | example_code: raw_style_config.example_code.into(), 148 | example_variable: raw_style_config.example_variable.into(), 149 | } 150 | } 151 | } 152 | 153 | #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] 154 | struct RawDisplayConfig { 155 | #[serde(default)] 156 | pub compact: bool, 157 | #[serde(default)] 158 | pub use_pager: bool, 159 | } 160 | 161 | impl From for DisplayConfig { 162 | fn from(raw_display_config: RawDisplayConfig) -> Self { 163 | Self { 164 | compact: raw_display_config.compact, 165 | use_pager: raw_display_config.use_pager, 166 | } 167 | } 168 | } 169 | 170 | /// Serde doesn't support default values yet (tracking issue: 171 | /// ), so we need to wrap 172 | /// `DEFAULT_UPDATE_INTERVAL_HOURS` in a function to be able to use 173 | /// `#[serde(default = ...)]` 174 | const fn default_auto_update_interval_hours() -> u64 { 175 | DEFAULT_UPDATE_INTERVAL_HOURS 176 | } 177 | 178 | fn default_archive_source() -> String { 179 | "https://github.com/tldr-pages/tldr/releases/latest/download/".to_owned() 180 | } 181 | 182 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 183 | struct RawUpdatesConfig { 184 | #[serde(default)] 185 | pub auto_update: bool, 186 | #[serde(default = "default_auto_update_interval_hours")] 187 | pub auto_update_interval_hours: u64, 188 | #[serde(default = "default_archive_source")] 189 | pub archive_source: String, 190 | #[serde(default)] 191 | pub tls_backend: RawTlsBackend, 192 | } 193 | 194 | impl Default for RawUpdatesConfig { 195 | fn default() -> Self { 196 | Self { 197 | auto_update: false, 198 | auto_update_interval_hours: DEFAULT_UPDATE_INTERVAL_HOURS, 199 | archive_source: default_archive_source(), 200 | tls_backend: RawTlsBackend::default(), 201 | } 202 | } 203 | } 204 | 205 | impl TryFrom for UpdatesConfig { 206 | type Error = anyhow::Error; 207 | 208 | fn try_from(raw_updates_config: RawUpdatesConfig) -> Result { 209 | let tls_backend = match raw_updates_config.tls_backend { 210 | #[cfg(feature = "native-tls")] 211 | RawTlsBackend::NativeTls => TlsBackend::NativeTls, 212 | #[cfg(feature = "rustls-with-webpki-roots")] 213 | RawTlsBackend::RustlsWithWebpkiRoots => TlsBackend::RustlsWithWebpkiRoots, 214 | #[cfg(feature = "rustls-with-native-roots")] 215 | RawTlsBackend::RustlsWithNativeRoots => TlsBackend::RustlsWithNativeRoots, 216 | // when compiling without all TLS backend features, we want to handle config error. 217 | #[allow(unreachable_patterns)] 218 | _ => return Err(anyhow!( 219 | "Unsupported TLS backend: {}. This tealdeer build has support for the following options: {}", 220 | raw_updates_config.tls_backend, 221 | SUPPORTED_TLS_BACKENDS.iter().map(std::string::ToString::to_string).collect::>().join(", ") 222 | )) 223 | }; 224 | 225 | Ok(Self { 226 | auto_update: raw_updates_config.auto_update, 227 | auto_update_interval: Duration::from_secs( 228 | raw_updates_config.auto_update_interval_hours * 3600, 229 | ), 230 | archive_source: raw_updates_config.archive_source, 231 | tls_backend, 232 | }) 233 | } 234 | } 235 | 236 | #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] 237 | struct RawDirectoriesConfig { 238 | #[serde(default)] 239 | pub cache_dir: Option, 240 | #[serde(default)] 241 | pub custom_pages_dir: Option, 242 | } 243 | 244 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 245 | #[serde(default)] 246 | struct RawConfig { 247 | style: RawStyleConfig, 248 | display: RawDisplayConfig, 249 | updates: RawUpdatesConfig, 250 | directories: RawDirectoriesConfig, 251 | } 252 | 253 | impl RawConfig { 254 | fn new() -> Self { 255 | Self::default() 256 | } 257 | 258 | fn load(mut config: impl Read) -> Result { 259 | let mut content = String::new(); 260 | config 261 | .read_to_string(&mut content) 262 | .context("Failed to read from config file")?; 263 | toml::from_str(&content).context("Failed to parse TOML config file") 264 | } 265 | } 266 | 267 | impl Default for RawConfig { 268 | fn default() -> Self { 269 | let mut raw_config = RawConfig { 270 | style: RawStyleConfig::default(), 271 | display: RawDisplayConfig::default(), 272 | updates: RawUpdatesConfig::default(), 273 | directories: RawDirectoriesConfig::default(), 274 | }; 275 | 276 | // Set default config 277 | raw_config.style.example_text.foreground = Some(RawColor::Green); 278 | raw_config.style.command_name.foreground = Some(RawColor::Cyan); 279 | raw_config.style.example_code.foreground = Some(RawColor::Cyan); 280 | raw_config.style.example_variable.foreground = Some(RawColor::Cyan); 281 | raw_config.style.example_variable.underline = true; 282 | 283 | raw_config 284 | } 285 | } 286 | 287 | #[derive(Copy, Clone, Default, Debug, PartialEq, Eq)] 288 | pub struct StyleConfig { 289 | pub description: Style, 290 | pub command_name: Style, 291 | pub example_text: Style, 292 | pub example_code: Style, 293 | pub example_variable: Style, 294 | } 295 | 296 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 297 | pub struct DisplayConfig { 298 | pub compact: bool, 299 | pub use_pager: bool, 300 | } 301 | 302 | #[derive(Clone, Debug, PartialEq, Eq)] 303 | pub struct UpdatesConfig { 304 | pub auto_update: bool, 305 | pub auto_update_interval: Duration, 306 | pub archive_source: String, 307 | pub tls_backend: TlsBackend, 308 | } 309 | 310 | #[derive(Clone, Debug, PartialEq, Eq)] 311 | pub struct PathWithSource { 312 | pub path: PathBuf, 313 | pub source: PathSource, 314 | } 315 | 316 | impl PathWithSource { 317 | pub fn path(&self) -> &Path { 318 | &self.path 319 | } 320 | } 321 | 322 | impl fmt::Display for PathWithSource { 323 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 324 | write!(f, "{} ({})", self.path.display(), self.source) 325 | } 326 | } 327 | 328 | #[derive(Clone, Debug, PartialEq, Eq)] 329 | pub struct DirectoriesConfig { 330 | pub cache_dir: PathWithSource, 331 | pub custom_pages_dir: Option, 332 | } 333 | 334 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] 335 | #[serde(rename_all = "kebab-case")] 336 | pub enum RawTlsBackend { 337 | /// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise) 338 | NativeTls, 339 | /// Rustls with `WebPKI` roots. 340 | RustlsWithWebpkiRoots, 341 | /// Rustls with native roots. 342 | RustlsWithNativeRoots, 343 | } 344 | 345 | impl Default for RawTlsBackend { 346 | fn default() -> Self { 347 | *SUPPORTED_TLS_BACKENDS.first().unwrap() 348 | } 349 | } 350 | 351 | impl std::fmt::Display for RawTlsBackend { 352 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 353 | self.serialize(f) 354 | } 355 | } 356 | 357 | /// Allows choosing a `reqwest`'s TLS backend. Available TLS backends: 358 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] 359 | pub enum TlsBackend { 360 | /// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise) 361 | #[cfg(feature = "native-tls")] 362 | NativeTls, 363 | /// Rustls with `WebPKI` roots. 364 | #[cfg(feature = "rustls-with-webpki-roots")] 365 | RustlsWithWebpkiRoots, 366 | /// Rustls with native roots. 367 | #[cfg(feature = "rustls-with-native-roots")] 368 | RustlsWithNativeRoots, 369 | } 370 | 371 | #[derive(Clone, Debug, PartialEq, Eq)] 372 | pub struct Config { 373 | pub style: StyleConfig, 374 | pub display: DisplayConfig, 375 | pub updates: UpdatesConfig, 376 | pub directories: DirectoriesConfig, 377 | pub file_path: PathWithSource, 378 | } 379 | 380 | impl Config { 381 | /// Convert a `RawConfig` to a high-level `Config`. 382 | /// 383 | /// For this, some values need to be converted to other types and some 384 | /// defaults need to be set (sometimes based on env variables). 385 | fn from_raw(raw_config: RawConfig, config_file_path: PathWithSource) -> Result { 386 | let style = raw_config.style.into(); 387 | let display = raw_config.display.into(); 388 | let updates = raw_config.updates.try_into()?; 389 | let relative_path_root = config_file_path 390 | .path() 391 | .parent() 392 | .context("Failed to get config directory")?; 393 | 394 | // Determine directories config. For this, we need to take some 395 | // additional factory into account, like env variables, or the 396 | // user config. 397 | let cache_dir_env_var = "TEALDEER_CACHE_DIR"; 398 | let cache_dir = if let Ok(env_var) = env::var(cache_dir_env_var) { 399 | // For backwards compatibility reasons, the cache directory can be 400 | // overridden using an env variable. This is deprecated and will be 401 | // phased out in the future. 402 | eprintln!("Warning: The ${cache_dir_env_var} env variable is deprecated, use the `cache_dir` option in the config file instead."); 403 | PathWithSource { 404 | path: PathBuf::from(env_var), 405 | source: PathSource::EnvVar, 406 | } 407 | } else if let Some(config_value) = raw_config.directories.cache_dir { 408 | // If the user explicitly configured a cache directory, use that. 409 | PathWithSource { 410 | // Resolve possible relative path. It would be nicer to clean up the path, but Rust stdlib 411 | // does not give any method for that that does not need the paths to exist. 412 | path: relative_path_root.join(config_value), 413 | source: PathSource::ConfigFile, 414 | } 415 | } else if let Ok(default_dir) = get_app_root(AppDataType::UserCache, &crate::APP_INFO) { 416 | // Otherwise, fall back to the default user cache directory. 417 | PathWithSource { 418 | path: default_dir, 419 | source: PathSource::OsConvention, 420 | } 421 | } else { 422 | // If everything fails, give up 423 | bail!("Could not determine user cache directory"); 424 | }; 425 | let custom_pages_dir = raw_config 426 | .directories 427 | .custom_pages_dir 428 | .map(|path| PathWithSource { 429 | // Resolve possible relative path. 430 | path: relative_path_root.join(path), 431 | source: PathSource::ConfigFile, 432 | }) 433 | .or_else(|| { 434 | get_app_root(AppDataType::UserData, &crate::APP_INFO) 435 | .map(|path| { 436 | // Note: The `join("")` call ensures that there's a trailing slash 437 | PathWithSource { 438 | path: path.join("pages").join(""), 439 | source: PathSource::OsConvention, 440 | } 441 | }) 442 | .ok() 443 | }); 444 | let directories = DirectoriesConfig { 445 | cache_dir, 446 | custom_pages_dir, 447 | }; 448 | 449 | Ok(Self { 450 | style, 451 | display, 452 | updates, 453 | directories, 454 | file_path: config_file_path, 455 | }) 456 | } 457 | 458 | /// Load and read the config file from the given path into 459 | /// a [Config] and return it. 460 | /// 461 | /// path: The path to the config file. 462 | pub fn load(path: &Path) -> Result { 463 | let raw_config = RawConfig::load(File::open(path)?)?; 464 | 465 | let config = Self::from_raw( 466 | raw_config, 467 | PathWithSource { 468 | path: path.into(), 469 | source: PathSource::Cli, 470 | }, 471 | ) 472 | .context("Could not process raw config")?; 473 | 474 | Ok(config) 475 | } 476 | 477 | /// Load and read the config file from the default path into 478 | /// a [Config] and return it. 479 | pub fn load_default_path() -> Result { 480 | // Determine path 481 | let config_file_path = 482 | get_default_config_path().context("Could not determine config path")?; 483 | 484 | let raw_config = match File::open(config_file_path.path()) { 485 | Ok(file) => RawConfig::load(file)?, 486 | Err(e) if e.kind() == ErrorKind::NotFound => RawConfig::default(), 487 | Err(e) => { 488 | return Err(e).context(format!( 489 | "Failed to open config file at {}", 490 | config_file_path.path().display() 491 | )); 492 | } 493 | }; 494 | let config = 495 | Self::from_raw(raw_config, config_file_path).context("Could not process raw config")?; 496 | 497 | Ok(config) 498 | } 499 | } 500 | 501 | /// Return the path to the config directory. 502 | /// 503 | /// The config dir path can be overridden using the `TEALDEER_CONFIG_DIR` env 504 | /// variable. Otherwise, the user config directory is returned. 505 | /// 506 | /// Note that this function does not verify whether the directory at that 507 | /// location exists, or is a directory. 508 | pub fn get_config_dir() -> Result<(PathBuf, PathSource)> { 509 | // Allow overriding the config directory by setting the 510 | // $TEALDEER_CONFIG_DIR env variable. 511 | if let Ok(value) = env::var("TEALDEER_CONFIG_DIR") { 512 | return Ok((PathBuf::from(value), PathSource::EnvVar)); 513 | } 514 | 515 | // Otherwise, fall back to the user config directory. 516 | let dirs = get_app_root(AppDataType::UserConfig, &crate::APP_INFO) 517 | .context("Failed to determine the user config directory")?; 518 | Ok((dirs, PathSource::OsConvention)) 519 | } 520 | 521 | /// Return the path to the config file. 522 | /// 523 | /// Note that this function does not verify whether the file at that location 524 | /// exists, or is a file. 525 | pub fn get_default_config_path() -> Result { 526 | let (config_dir, source) = get_config_dir()?; 527 | let config_file_path = config_dir.join(CONFIG_FILE_NAME); 528 | Ok(PathWithSource { 529 | path: config_file_path, 530 | source, 531 | }) 532 | } 533 | 534 | /// Create default config file. 535 | /// path: Can be specified to create the config in that path instead of 536 | /// the default path. 537 | pub fn make_default_config(path: Option<&Path>) -> Result { 538 | let config_file_path = if let Some(p) = path { 539 | p.into() 540 | } else { 541 | let (config_dir, _) = get_config_dir()?; 542 | 543 | // Ensure that config directory exists 544 | if config_dir.exists() { 545 | ensure!( 546 | config_dir.is_dir(), 547 | "Config directory could not be created: {} already exists but is not a directory", 548 | config_dir.to_string_lossy(), 549 | ); 550 | } else { 551 | fs::create_dir_all(&config_dir).context("Could not create config directory")?; 552 | } 553 | 554 | config_dir.join(CONFIG_FILE_NAME) 555 | }; 556 | 557 | // Ensure that a config file doesn't get overwritten 558 | ensure!( 559 | !config_file_path.is_file(), 560 | "A configuration file already exists at {}, no action was taken.", 561 | config_file_path.to_str().unwrap() 562 | ); 563 | 564 | // Create default config 565 | let serialized_config = 566 | toml::to_string(&RawConfig::new()).context("Failed to serialize default config")?; 567 | 568 | // Write default config 569 | let mut config_file = 570 | File::create(&config_file_path).context("Could not create config file")?; 571 | let _wc = config_file 572 | .write(serialized_config.as_bytes()) 573 | .context("Could not write to config file")?; 574 | 575 | Ok(config_file_path) 576 | } 577 | 578 | #[test] 579 | fn test_serialize_deserialize() { 580 | let raw_config = RawConfig::new(); 581 | let serialized = toml::to_string(&raw_config).unwrap(); 582 | let deserialized: RawConfig = toml::from_str(&serialized).unwrap(); 583 | assert_eq!(raw_config, deserialized); 584 | } 585 | 586 | #[test] 587 | fn test_relative_path_resolution() { 588 | let mut raw_config = RawConfig::new(); 589 | raw_config.directories.cache_dir = Some("../cache".into()); 590 | raw_config.directories.custom_pages_dir = Some("../custom_pages".into()); 591 | 592 | let config = Config::from_raw( 593 | raw_config, 594 | PathWithSource { 595 | path: PathBuf::from("/path/to/config/config.toml"), 596 | source: PathSource::OsConvention, 597 | }, 598 | ) 599 | .unwrap(); 600 | 601 | assert_eq!( 602 | config.directories.cache_dir.path(), 603 | Path::new("/path/to/config/../cache") 604 | ); 605 | assert_eq!( 606 | config.directories.custom_pages_dir.unwrap().path(), 607 | Path::new("/path/to/config/../custom_pages") 608 | ); 609 | } 610 | -------------------------------------------------------------------------------- /src/extensions.rs: -------------------------------------------------------------------------------- 1 | use std::mem; 2 | 3 | /// An extension trait to clear duplicates from a collection. 4 | pub(crate) trait Dedup { 5 | fn clear_duplicates(&mut self); 6 | } 7 | 8 | /// Clear duplicates from a collection, keep the first one seen. 9 | /// 10 | /// For small vectors, this will be faster than a `HashSet`. 11 | impl Dedup for Vec { 12 | fn clear_duplicates(&mut self) { 13 | let orig = mem::replace(self, Vec::with_capacity(self.len())); 14 | for item in orig { 15 | if !self.contains(&item) { 16 | self.push(item); 17 | } 18 | } 19 | } 20 | } 21 | 22 | /// Like `str::find`, but starts searching at `start`. 23 | pub(crate) trait FindFrom { 24 | fn find_from(&self, needle: &Self, start: usize) -> Option; 25 | } 26 | 27 | impl FindFrom for str { 28 | fn find_from(&self, needle: &Self, start: usize) -> Option { 29 | self.get(start..) 30 | .and_then(|s| s.find(needle)) 31 | .map(|i| i + start) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/formatter.rs: -------------------------------------------------------------------------------- 1 | //! Functions related to formatting and printing lines from a `Tokenizer`. 2 | 3 | use log::debug; 4 | 5 | use crate::{extensions::FindFrom, types::LineType}; 6 | 7 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 8 | /// Represents a snippet from a page of a specific highlighting class. 9 | pub enum PageSnippet<'a> { 10 | CommandName(&'a str), 11 | Variable(&'a str), 12 | NormalCode(&'a str), 13 | Description(&'a str), 14 | Text(&'a str), 15 | Linebreak, 16 | } 17 | 18 | impl PageSnippet<'_> { 19 | pub fn is_empty(&self) -> bool { 20 | use PageSnippet::*; 21 | 22 | match self { 23 | CommandName(s) | Variable(s) | NormalCode(s) | Description(s) | Text(s) => s.is_empty(), 24 | Linebreak => false, 25 | } 26 | } 27 | } 28 | 29 | /// Parse the content of each line yielded by `lines` and yield `HighLightingSnippet`s accordingly. 30 | pub fn highlight_lines( 31 | lines: L, 32 | process_snippet: &mut F, 33 | keep_empty_lines: bool, 34 | ) -> Result<(), E> 35 | where 36 | L: Iterator, 37 | F: for<'snip> FnMut(PageSnippet<'snip>) -> Result<(), E>, 38 | { 39 | let mut command = String::new(); 40 | for line in lines { 41 | match line { 42 | LineType::Empty => { 43 | if keep_empty_lines { 44 | process_snippet(PageSnippet::Linebreak)?; 45 | } 46 | } 47 | LineType::Title(title) => { 48 | debug!("Ignoring title"); 49 | 50 | // This is safe as long as the parsed title is only the command, 51 | // and the iterator yields values in order of appearance. 52 | command = title; 53 | debug!("Detected command name: {}", &command); 54 | } 55 | LineType::Description(text) => process_snippet(PageSnippet::Description(&text))?, 56 | LineType::ExampleText(text) => process_snippet(PageSnippet::Text(&text))?, 57 | LineType::ExampleCode(text) => { 58 | process_snippet(PageSnippet::NormalCode(" "))?; 59 | highlight_code(&command, &text, process_snippet)?; 60 | process_snippet(PageSnippet::Linebreak)?; 61 | } 62 | 63 | LineType::Other(text) => debug!("Unknown line type: {:?}", text), 64 | } 65 | } 66 | process_snippet(PageSnippet::Linebreak)?; 67 | Ok(()) 68 | } 69 | 70 | /// Highlight code examples including user variables in {{ curly braces }}. 71 | fn highlight_code<'a, E>( 72 | command: &'a str, 73 | text: &'a str, 74 | process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>, 75 | ) -> Result<(), E> { 76 | let variable_splits = text 77 | .split("}}") 78 | .map(|s| s.split_once("{{").unwrap_or((s, ""))); 79 | for (code_segment, variable) in variable_splits { 80 | highlight_code_segment(command, code_segment, process_snippet)?; 81 | process_snippet(PageSnippet::Variable(variable))?; 82 | } 83 | Ok(()) 84 | } 85 | 86 | /// Yields `NormalCode` and `CommandName` in alternating order according to the occurrences of 87 | /// `command_name` in `segment`. Variables are not detected here, see `highlight_code` 88 | /// instead. 89 | fn highlight_code_segment<'a, E>( 90 | command_name: &'a str, 91 | mut segment: &'a str, 92 | process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>, 93 | ) -> Result<(), E> { 94 | if !command_name.is_empty() { 95 | let mut search_start = 0; 96 | while let Some(match_start) = segment.find_from(command_name, search_start) { 97 | let match_end = match_start + command_name.len(); 98 | if is_freestanding_substring(segment, (match_start, match_end)) { 99 | process_snippet(PageSnippet::NormalCode(&segment[..match_start]))?; 100 | process_snippet(PageSnippet::CommandName(command_name))?; 101 | segment = &segment[match_end..]; 102 | search_start = 0; 103 | } else { 104 | search_start = segment[match_start..] 105 | .char_indices() 106 | .nth(1) 107 | .map_or(segment.len(), |(i, _)| match_start + i); 108 | } 109 | } 110 | } 111 | process_snippet(PageSnippet::NormalCode(segment))?; 112 | Ok(()) 113 | } 114 | 115 | /// Checks whether the characters right before and after the substring (given by half-open index interval) are whitespace (if they exist). 116 | fn is_freestanding_substring(surrounding: &str, substring: (usize, usize)) -> bool { 117 | let (start, end) = substring; 118 | // "okay" meaning or 119 | let char_before_is_okay = surrounding[..start] 120 | .chars() 121 | .last() 122 | .filter(|prev_char| !prev_char.is_whitespace()) 123 | .is_none(); 124 | let char_after_is_okay = surrounding[end..] 125 | .chars() 126 | .next() 127 | .filter(|next_char| !next_char.is_whitespace()) 128 | .is_none(); 129 | char_before_is_okay && char_after_is_okay 130 | } 131 | 132 | #[cfg(test)] 133 | mod tests { 134 | use super::*; 135 | use PageSnippet::*; 136 | 137 | #[test] 138 | fn test_is_freestanding_substring() { 139 | assert!(is_freestanding_substring("I love tldr", (0, 1))); 140 | assert!(is_freestanding_substring("I love tldr", (2, 6))); 141 | assert!(is_freestanding_substring("I love tldr", (7, 11))); 142 | 143 | assert!(is_freestanding_substring("tldr", (0, 4))); 144 | assert!(is_freestanding_substring("tldr ", (0, 4))); 145 | assert!(is_freestanding_substring(" tldr", (1, 5))); 146 | assert!(is_freestanding_substring(" tldr ", (1, 5))); 147 | 148 | assert!(!is_freestanding_substring("tldr", (1, 3))); 149 | assert!(!is_freestanding_substring("tldr ", (1, 4))); 150 | assert!(!is_freestanding_substring(" tldr", (1, 4))); 151 | 152 | assert!(is_freestanding_substring( 153 | " épicé ", 154 | (1, " épicé".len()) // note the missing trailing space 155 | )); 156 | assert!(!is_freestanding_substring( 157 | " épicé ", 158 | (1, " épic".len()) // note the missing trailing space and character 159 | )); 160 | } 161 | 162 | fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec> { 163 | let mut yielded = Vec::new(); 164 | let mut process_snippet = |snip: PageSnippet<'a>| { 165 | if !snip.is_empty() { 166 | yielded.push(snip); 167 | } 168 | Ok::<(), ()>(()) 169 | }; 170 | 171 | highlight_code_segment(cmd, segment, &mut process_snippet) 172 | .expect("highlight code segment failed"); 173 | yielded 174 | } 175 | 176 | #[test] 177 | fn test_highlight_code_segment() { 178 | assert!(run("make", "").is_empty()); 179 | assert_eq!( 180 | &run("make", "make all CC=clang -q"), 181 | &[CommandName("make"), NormalCode(" all CC=clang -q")] 182 | ); 183 | assert_eq!( 184 | &run("make", " make money --always-make"), 185 | &[ 186 | NormalCode(" "), 187 | CommandName("make"), 188 | NormalCode(" money --always-make") 189 | ] 190 | ); 191 | assert_eq!( 192 | &run("git commit", "git commit -m 'git commit'"), 193 | &[CommandName("git commit"), NormalCode(" -m 'git commit'"),] 194 | ); 195 | } 196 | 197 | #[test] 198 | fn test_i18n() { 199 | assert_eq!( 200 | &run("mäke", "mäke höhlenrätselbücher"), 201 | &[CommandName("mäke"), NormalCode(" höhlenrätselbücher")] 202 | ); 203 | assert_eq!( 204 | &run( 205 | "Müll", 206 | "1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich" 207 | ), 208 | &[ 209 | NormalCode("1000 Gründe warum "), 210 | CommandName("Müll"), 211 | NormalCode(" heute größer ist als "), 212 | CommandName("Müll"), 213 | NormalCode(" früher, ärgerlich") 214 | ] 215 | ); 216 | assert_eq!( 217 | &run( 218 | "übergang", 219 | "die Zustandsübergangsfunktion übergang Änderungen", 220 | ), 221 | &[ 222 | NormalCode("die Zustandsübergangsfunktion "), 223 | CommandName("übergang"), 224 | NormalCode(" Änderungen") 225 | ], 226 | ); 227 | } 228 | 229 | #[test] 230 | fn test_empty_command() { 231 | let segment = "some code"; 232 | let snippets = [NormalCode(segment)]; 233 | 234 | assert_eq!(run("", segment), snippets); 235 | assert_eq!(run(" ", segment), snippets); 236 | assert_eq!(run(" \t ", segment), snippets); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/line_iterator.rs: -------------------------------------------------------------------------------- 1 | //! Code to split a `BufRead` instance into an iterator of `LineType`s. 2 | 3 | use std::io::{BufRead, Read}; 4 | 5 | use log::warn; 6 | 7 | use crate::types::LineType; 8 | 9 | #[derive(Debug, PartialEq, Eq)] 10 | pub enum TldrFormat { 11 | /// Not yet clear 12 | Undecided, 13 | /// The original format 14 | V1, 15 | /// The new format (see ) 16 | V2, 17 | } 18 | 19 | /// A `LineIterator` is initialized with a `BufReader` instance that contains the 20 | /// entire Tldr page. It then implements `Iterator`. 21 | #[derive(Debug)] 22 | pub struct LineIterator { 23 | /// An instance of `R: BufRead`. 24 | reader: R, 25 | /// Whether the first line has already been processed or not. 26 | first_line: bool, 27 | /// Buffer for the current line. Used internally. 28 | current_line: String, 29 | /// The tldr page format. 30 | format: TldrFormat, 31 | } 32 | 33 | impl LineIterator 34 | where 35 | R: BufRead, 36 | { 37 | pub fn new(reader: R) -> Self { 38 | Self { 39 | reader, 40 | first_line: true, 41 | current_line: String::new(), 42 | format: TldrFormat::Undecided, 43 | } 44 | } 45 | } 46 | 47 | impl Iterator for LineIterator { 48 | type Item = LineType; 49 | 50 | fn next(&mut self) -> Option { 51 | self.current_line.clear(); 52 | let bytes_read = self.reader.read_line(&mut self.current_line); 53 | match bytes_read { 54 | Ok(0) => None, 55 | Err(e) => { 56 | warn!("Could not read line from reader: {:?}", e); 57 | None 58 | } 59 | Ok(_) => { 60 | // Handle new titles 61 | if self.first_line { 62 | if self.current_line.starts_with('#') { 63 | // It's the old format. 64 | self.format = TldrFormat::V1; 65 | } else { 66 | // It's the new format! Drop next line. 67 | if let Err(e) = Read::bytes(&mut self.reader) 68 | .find(|b| matches!(b, Ok(b'\n') | Err(_))) 69 | .transpose() 70 | { 71 | warn!("Could not read line from reader: {:?}", e); 72 | return None; 73 | } 74 | self.first_line = false; 75 | self.format = TldrFormat::V2; 76 | return Some(LineType::Title(self.current_line.trim_end().to_string())); 77 | } 78 | } 79 | self.first_line = false; 80 | 81 | // Convert line to a `LineType` instance 82 | match self.format { 83 | TldrFormat::V1 => Some(LineType::from_v1(&self.current_line[..])), 84 | TldrFormat::V2 => Some(LineType::from(&self.current_line[..])), 85 | TldrFormat::Undecided => panic!("Could not determine page format version"), 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod test { 94 | use super::LineIterator; 95 | use crate::types::LineType; 96 | 97 | #[test] 98 | fn test_first_line_old_format() { 99 | let input = "# The Title\n> Description\n"; 100 | let mut lines = LineIterator::new(input.as_bytes()); 101 | let title = lines.next().unwrap(); 102 | assert_eq!(title, LineType::Title("The Title".to_string())); 103 | let description = lines.next().unwrap(); 104 | assert_eq!( 105 | description, 106 | LineType::Description("Description".to_string()) 107 | ); 108 | } 109 | 110 | #[test] 111 | fn test_first_line_new_format() { 112 | let input = "The Title\n=========\n> Description\n"; 113 | let mut lines = LineIterator::new(input.as_bytes()); 114 | let title = lines.next().unwrap(); 115 | assert_eq!(title, LineType::Title("The Title".to_string())); 116 | let description = lines.next().unwrap(); 117 | assert_eq!( 118 | description, 119 | LineType::Description("Description".to_string()) 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! An implementation of [tldr](https://github.com/tldr-pages/tldr) in Rust. 2 | // 3 | // Copyright (c) 2015-2021 tealdeer developers 4 | // 5 | // Licensed under the Apache License, Version 2.0 or the MIT license 7 | // , at your 8 | // option. All files in the project carrying such notice may not be 9 | // copied, modified, or distributed except according to those terms. 10 | 11 | #![deny(clippy::all)] 12 | #![warn(clippy::pedantic)] 13 | #![allow(clippy::enum_glob_use)] 14 | #![allow(clippy::module_name_repetitions)] 15 | #![allow(clippy::similar_names)] 16 | #![allow(clippy::struct_excessive_bools)] 17 | #![allow(clippy::too_many_lines)] 18 | 19 | #[cfg(not(any( 20 | feature = "native-tls", 21 | feature = "rustls-with-webpki-roots", 22 | feature = "rustls-with-native-roots", 23 | )))] 24 | compile_error!( 25 | "at least one of the features \"native-tls\", \"rustls-with-webpki-roots\" or \"rustls-with-native-roots\" must be enabled" 26 | ); 27 | 28 | use std::{ 29 | env, 30 | fs::create_dir_all, 31 | io::{self, IsTerminal}, 32 | path::Path, 33 | process::{Command, ExitCode}, 34 | }; 35 | 36 | use anyhow::{anyhow, Context, Result}; 37 | use app_dirs::AppInfo; 38 | use clap::Parser; 39 | use config::StyleConfig; 40 | use log::debug; 41 | 42 | mod cache; 43 | mod cli; 44 | mod config; 45 | pub mod extensions; 46 | mod formatter; 47 | mod line_iterator; 48 | mod output; 49 | mod types; 50 | mod utils; 51 | 52 | use crate::{ 53 | cache::{Cache, CacheFreshness, PageLookupResult, TLDR_PAGES_DIR}, 54 | cli::Cli, 55 | config::{get_config_dir, make_default_config, Config, PathWithSource}, 56 | extensions::Dedup, 57 | output::print_page, 58 | types::{ColorOptions, PlatformType}, 59 | utils::{print_error, print_warning}, 60 | }; 61 | 62 | const NAME: &str = "tealdeer"; 63 | const APP_INFO: AppInfo = AppInfo { 64 | name: NAME, 65 | author: NAME, 66 | }; 67 | 68 | /// The cache should be updated if it was explicitly requested, 69 | /// or if an automatic update is due and allowed. 70 | fn should_update_cache(cache: &Cache, args: &Cli, config: &Config) -> bool { 71 | args.update 72 | || (!args.no_auto_update 73 | && config.updates.auto_update 74 | && cache 75 | .last_update() 76 | .map_or(true, |ago| ago >= config.updates.auto_update_interval)) 77 | } 78 | 79 | #[derive(PartialEq)] 80 | enum CheckCacheResult { 81 | CacheFound, 82 | CacheMissing, 83 | } 84 | 85 | /// Check the cache for freshness. If it's stale or missing, show a warning. 86 | fn check_cache(cache: &Cache, args: &Cli, enable_styles: bool) -> CheckCacheResult { 87 | match cache.freshness() { 88 | CacheFreshness::Fresh => CheckCacheResult::CacheFound, 89 | CacheFreshness::Stale(_) if args.quiet => CheckCacheResult::CacheFound, 90 | CacheFreshness::Stale(age) => { 91 | print_warning( 92 | enable_styles, 93 | &format!( 94 | "The cache hasn't been updated for {} days.\n\ 95 | You should probably run `tldr --update` soon.", 96 | age.as_secs() / 24 / 3600 97 | ), 98 | ); 99 | CheckCacheResult::CacheFound 100 | } 101 | CacheFreshness::Missing => { 102 | print_error( 103 | enable_styles, 104 | &anyhow::anyhow!( 105 | "Page cache not found. Please run `tldr --update` to download the cache." 106 | ), 107 | ); 108 | println!("\nNote: You can optionally enable automatic cache updates by adding the"); 109 | println!("following config to your config file:\n"); 110 | println!(" [updates]"); 111 | println!(" auto_update = true\n"); 112 | println!("The path to your config file can be looked up with `tldr --show-paths`."); 113 | println!("To create an initial config file, use `tldr --seed-config`.\n"); 114 | println!("You can find more tips and tricks in our docs:\n"); 115 | println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html"); 116 | CheckCacheResult::CacheMissing 117 | } 118 | } 119 | } 120 | 121 | /// Clear the cache 122 | fn clear_cache(cache: &Cache, quietly: bool) -> Result<()> { 123 | let cache_dir_found = cache.clear().context("Could not clear cache")?; 124 | if !quietly { 125 | let cache_dir = cache.cache_dir().display(); 126 | if cache_dir_found { 127 | eprintln!("Successfully cleared cache at `{cache_dir}`."); 128 | } else { 129 | eprintln!("Cache directory not found at `{cache_dir}`, nothing to do."); 130 | } 131 | } 132 | Ok(()) 133 | } 134 | 135 | /// Update the cache 136 | fn update_cache(cache: &Cache, archive_source: &str, quietly: bool) -> Result<()> { 137 | cache 138 | .update(archive_source) 139 | .context("Could not update cache")?; 140 | if !quietly { 141 | eprintln!("Successfully updated cache."); 142 | } 143 | Ok(()) 144 | } 145 | 146 | /// Show file paths 147 | fn show_paths(config: &Config) { 148 | let config_dir = get_config_dir().map_or_else( 149 | |e| format!("[Error: {e}]"), 150 | |(mut path, source)| { 151 | path.push(""); // Trailing path separator 152 | match path.to_str() { 153 | Some(path) => format!("{path} ({source})"), 154 | None => "[Invalid]".to_string(), 155 | } 156 | }, 157 | ); 158 | let config_path = config.file_path.to_string(); 159 | let cache_dir = config.directories.cache_dir.to_string(); 160 | let pages_dir = { 161 | let mut path = config.directories.cache_dir.path.clone(); 162 | path.push(TLDR_PAGES_DIR); 163 | path.push(""); // Trailing path separator 164 | path.display().to_string() 165 | }; 166 | let custom_pages_dir = match config.directories.custom_pages_dir { 167 | Some(ref path_with_source) => path_with_source.to_string(), 168 | None => "[None]".to_string(), 169 | }; 170 | println!("Config dir: {config_dir}"); 171 | println!("Config path: {config_path}"); 172 | println!("Cache dir: {cache_dir}"); 173 | println!("Pages dir: {pages_dir}"); 174 | println!("Custom pages dir: {custom_pages_dir}"); 175 | } 176 | 177 | fn create_config(path: Option<&Path>) -> Result<()> { 178 | let config_file_path = make_default_config(path).context("Could not create seed config")?; 179 | eprintln!( 180 | "Successfully created seed config file here: {}", 181 | config_file_path.to_str().unwrap() 182 | ); 183 | Ok(()) 184 | } 185 | 186 | #[cfg(feature = "logging")] 187 | fn init_log() { 188 | env_logger::init(); 189 | } 190 | 191 | #[cfg(not(feature = "logging"))] 192 | fn init_log() {} 193 | 194 | fn get_languages(env_lang: Option<&str>, env_language: Option<&str>) -> Vec { 195 | // Language list according to 196 | // https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language 197 | 198 | if env_lang.is_none() { 199 | return vec!["en".to_string()]; 200 | } 201 | let env_lang = env_lang.unwrap(); 202 | 203 | // Create an iterator that contains $LANGUAGE (':' separated list) followed by $LANG (single language) 204 | let locales = env_language.unwrap_or("").split(':').chain([env_lang]); 205 | 206 | let mut lang_list = Vec::new(); 207 | for locale in locales { 208 | // Language plus country code (e.g. `en_US`) 209 | if locale.len() >= 5 && locale.chars().nth(2) == Some('_') { 210 | lang_list.push(&locale[..5]); 211 | } 212 | // Language code only (e.g. `en`) 213 | if locale.len() >= 2 && locale != "POSIX" { 214 | lang_list.push(&locale[..2]); 215 | } 216 | } 217 | 218 | lang_list.push("en"); 219 | lang_list.clear_duplicates(); 220 | lang_list.into_iter().map(str::to_string).collect() 221 | } 222 | 223 | fn get_languages_from_env() -> Vec { 224 | get_languages( 225 | std::env::var("LANG").ok().as_deref(), 226 | std::env::var("LANGUAGE").ok().as_deref(), 227 | ) 228 | } 229 | 230 | fn spawn_editor(custom_pages_dir: &Path, file_name: &str) -> Result<()> { 231 | create_dir_all(custom_pages_dir).context("Failed to create custom pages directory")?; 232 | 233 | let custom_page_path = custom_pages_dir.join(file_name); 234 | let Some(custom_page_path) = custom_page_path.to_str() else { 235 | return Err(anyhow!("`custom_page_path.to_str()` failed")); 236 | }; 237 | let Ok(editor) = env::var("EDITOR") else { 238 | return Err(anyhow!( 239 | "To edit a custom page, please set the `EDITOR` environment variable." 240 | )); 241 | }; 242 | println!("Editing {custom_page_path:?}"); 243 | 244 | let status = Command::new(&editor).arg(custom_page_path).status()?; 245 | if !status.success() { 246 | return Err(anyhow!("{editor} exit with code {:?}", status.code())); 247 | } 248 | Ok(()) 249 | } 250 | 251 | fn main() -> ExitCode { 252 | // Initialize logger 253 | init_log(); 254 | 255 | // Parse arguments 256 | let args = Cli::parse(); 257 | 258 | // Determine the usage of styles 259 | let enable_styles = match args.color.unwrap_or_default() { 260 | // Attempt to use styling if instructed 261 | ColorOptions::Always => { 262 | yansi::enable(); // disable yansi's automatic detection for ANSI support on Windows 263 | true 264 | } 265 | // Enable styling if: 266 | // * NO_COLOR env var isn't set: https://no-color.org/ 267 | // * The output stream is stdout (not being piped) 268 | ColorOptions::Auto => env::var_os("NO_COLOR").is_none() && io::stdout().is_terminal(), 269 | // Disable styling 270 | ColorOptions::Never => false, 271 | }; 272 | 273 | try_main(args, enable_styles).unwrap_or_else(|error| { 274 | print_error(enable_styles, &error); 275 | ExitCode::FAILURE 276 | }) 277 | } 278 | 279 | fn try_main(args: Cli, enable_styles: bool) -> Result { 280 | // Look up config file, if none is found fall back to default config. 281 | debug!("Loading config"); 282 | let mut config = match &args.config_path { 283 | Some(path) if !args.seed_config => { 284 | Config::load(path).context("Could not load config from given path")? 285 | } 286 | _ => Config::load_default_path().context("Could not load config from default path")?, 287 | }; 288 | 289 | // Override styles if needed 290 | if !enable_styles { 291 | config.style = StyleConfig::default(); 292 | } 293 | 294 | let custom_pages_dir = config 295 | .directories 296 | .custom_pages_dir 297 | .as_ref() 298 | .map(PathWithSource::path); 299 | 300 | // Note: According to the TLDR client spec, page names must be transparently 301 | // lowercased before lookup: 302 | // https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#page-names 303 | let command = args.command.join("-").to_lowercase(); 304 | 305 | if args.edit_patch || args.edit_page { 306 | let file_name = if args.edit_patch { 307 | format!("{command}.patch.md") 308 | } else { 309 | format!("{command}.page.md") 310 | }; 311 | 312 | custom_pages_dir 313 | .context("To edit custom pages/patches, please specify a custom pages directory.") 314 | .and_then(|custom_pages_dir| spawn_editor(custom_pages_dir, &file_name))?; 315 | 316 | return Ok(ExitCode::SUCCESS); 317 | } 318 | 319 | // Show various paths 320 | if args.show_paths { 321 | show_paths(&config); 322 | } 323 | 324 | // Create a basic config and exit 325 | if args.seed_config { 326 | create_config(args.config_path.as_deref())?; 327 | return Ok(ExitCode::SUCCESS); 328 | } 329 | 330 | let platforms = compute_platforms(args.platforms.as_ref()); 331 | 332 | // If a local file was passed in, render it and exit 333 | if let Some(file) = args.render { 334 | let path = PageLookupResult::with_page(file); 335 | print_page(&path, args.raw, enable_styles, args.pager, &config)?; 336 | return Ok(ExitCode::SUCCESS); 337 | } 338 | 339 | // Instantiate cache. This will not yet create the cache directory! 340 | let cache = Cache::new( 341 | &config.directories.cache_dir.path, 342 | enable_styles, 343 | config.updates.tls_backend, 344 | ); 345 | 346 | // Clear cache, pass through 347 | if args.clear_cache { 348 | clear_cache(&cache, args.quiet)?; 349 | } 350 | 351 | if should_update_cache(&cache, &args, &config) { 352 | update_cache(&cache, &config.updates.archive_source, args.quiet)?; 353 | } else if (args.list || !args.command.is_empty()) 354 | && check_cache(&cache, &args, enable_styles) == CheckCacheResult::CacheMissing 355 | { 356 | // Cache is needed, but missing 357 | return Ok(ExitCode::FAILURE); 358 | } 359 | 360 | // List cached commands and exit 361 | if args.list { 362 | println!( 363 | "{}", 364 | cache.list_pages(custom_pages_dir, &platforms).join("\n") 365 | ); 366 | 367 | return Ok(ExitCode::SUCCESS); 368 | } 369 | 370 | // Show command from cache 371 | if !command.is_empty() { 372 | // Collect languages 373 | let languages = args 374 | .language 375 | .map_or_else(get_languages_from_env, |lang| vec![lang]); 376 | 377 | // Search for command in cache 378 | let Some(lookup_result) = cache.find_page( 379 | &command, 380 | &languages, 381 | config 382 | .directories 383 | .custom_pages_dir 384 | .as_ref() 385 | .map(PathWithSource::path), 386 | &platforms, 387 | ) else { 388 | if !args.quiet { 389 | print_warning( 390 | enable_styles, 391 | &format!( 392 | "Page `{}` not found in cache.\n\ 393 | Try updating with `tldr --update`, or submit a pull request to:\n\ 394 | https://github.com/tldr-pages/tldr", 395 | &command 396 | ), 397 | ); 398 | } 399 | 400 | return Ok(ExitCode::FAILURE); 401 | }; 402 | 403 | print_page(&lookup_result, args.raw, enable_styles, args.pager, &config)?; 404 | } 405 | 406 | Ok(ExitCode::SUCCESS) 407 | } 408 | 409 | /// Returns the passed or default platform types and appends `PlatformType::Common` as fallback. 410 | fn compute_platforms(platforms: Option<&Vec>) -> Vec { 411 | match platforms { 412 | Some(p) => { 413 | let mut result = p.clone(); 414 | if !result.contains(&PlatformType::Common) { 415 | result.push(PlatformType::Common); 416 | } 417 | result 418 | } 419 | None => vec![PlatformType::current(), PlatformType::Common], 420 | } 421 | } 422 | 423 | #[cfg(test)] 424 | mod test { 425 | use crate::get_languages; 426 | 427 | mod language { 428 | use super::*; 429 | 430 | #[test] 431 | fn missing_lang_env() { 432 | let lang_list = get_languages(None, Some("de:fr")); 433 | assert_eq!(lang_list, ["en"]); 434 | let lang_list = get_languages(None, None); 435 | assert_eq!(lang_list, ["en"]); 436 | } 437 | 438 | #[test] 439 | fn missing_language_env() { 440 | let lang_list = get_languages(Some("de"), None); 441 | assert_eq!(lang_list, ["de", "en"]); 442 | } 443 | 444 | #[test] 445 | fn preference_order() { 446 | let lang_list = get_languages(Some("de"), Some("fr:cn")); 447 | assert_eq!(lang_list, ["fr", "cn", "de", "en"]); 448 | } 449 | 450 | #[test] 451 | fn country_code_expansion() { 452 | let lang_list = get_languages(Some("pt_BR"), None); 453 | assert_eq!(lang_list, ["pt_BR", "pt", "en"]); 454 | } 455 | 456 | #[test] 457 | fn ignore_posix_and_c() { 458 | let lang_list = get_languages(Some("POSIX"), None); 459 | assert_eq!(lang_list, ["en"]); 460 | let lang_list = get_languages(Some("C"), None); 461 | assert_eq!(lang_list, ["en"]); 462 | } 463 | 464 | #[test] 465 | fn no_duplicates() { 466 | let lang_list = get_languages(Some("de"), Some("fr:de:cn:de")); 467 | assert_eq!(lang_list, ["fr", "de", "cn", "en"]); 468 | } 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | //! Functions for printing pages to the terminal 2 | 3 | use std::io::{self, BufRead, Write}; 4 | 5 | use anyhow::{Context, Result}; 6 | use yansi::Paint; 7 | 8 | use crate::{ 9 | cache::PageLookupResult, 10 | config::{Config, StyleConfig}, 11 | formatter::{highlight_lines, PageSnippet}, 12 | line_iterator::LineIterator, 13 | }; 14 | 15 | /// Set up display pager 16 | /// 17 | /// SAFETY: this function may be called multiple times 18 | #[cfg(not(target_os = "windows"))] 19 | fn configure_pager(_: bool) { 20 | use std::sync::Once; 21 | static INIT: Once = Once::new(); 22 | INIT.call_once(|| pager::Pager::with_default_pager("less -R").setup()); 23 | } 24 | 25 | #[cfg(target_os = "windows")] 26 | fn configure_pager(enable_styles: bool) { 27 | use crate::utils::print_warning; 28 | print_warning(enable_styles, "--pager flag not available on Windows!"); 29 | } 30 | 31 | /// Print page by path 32 | pub fn print_page( 33 | lookup_result: &PageLookupResult, 34 | enable_markdown: bool, 35 | enable_styles: bool, 36 | use_pager: bool, 37 | config: &Config, 38 | ) -> Result<()> { 39 | // Create reader from file(s) 40 | let reader = lookup_result.reader()?; 41 | 42 | // Configure pager if applicable 43 | if use_pager || config.display.use_pager { 44 | configure_pager(enable_styles); 45 | } 46 | 47 | // Lock stdout only once, this improves performance considerably 48 | let stdout = io::stdout(); 49 | let mut handle = stdout.lock(); 50 | 51 | if enable_markdown { 52 | // Print the raw markdown of the file. 53 | for line in reader.lines() { 54 | let line = line.context("Error while reading from a page")?; 55 | writeln!(handle, "{line}").context("Could not write to stdout")?; 56 | } 57 | } else { 58 | // Closure that processes a page snippet and writes it to stdout 59 | let mut process_snippet = |snip: PageSnippet<'_>| { 60 | if snip.is_empty() { 61 | Ok(()) 62 | } else { 63 | print_snippet(&mut handle, snip, &config.style).context("Failed to print snippet") 64 | } 65 | }; 66 | 67 | // Print highlighted lines 68 | highlight_lines( 69 | LineIterator::new(reader), 70 | &mut process_snippet, 71 | !config.display.compact, 72 | ) 73 | .context("Could not write to stdout")?; 74 | } 75 | 76 | // We're done outputting data, flush stdout now! 77 | handle.flush().context("Could not flush stdout")?; 78 | 79 | Ok(()) 80 | } 81 | 82 | fn print_snippet( 83 | writer: &mut impl Write, 84 | snip: PageSnippet<'_>, 85 | style: &StyleConfig, 86 | ) -> io::Result<()> { 87 | use PageSnippet::*; 88 | 89 | match snip { 90 | CommandName(s) => write!(writer, "{}", s.paint(style.command_name)), 91 | Variable(s) => write!(writer, "{}", s.paint(style.example_variable)), 92 | NormalCode(s) => write!(writer, "{}", s.paint(style.example_code)), 93 | Description(s) => writeln!(writer, " {}", s.paint(style.description)), 94 | Text(s) => writeln!(writer, " {}", s.paint(style.example_text)), 95 | Linebreak => writeln!(writer), 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | //! Shared types used in tealdeer. 2 | 3 | use std::{fmt, str}; 4 | 5 | use serde_derive::{Deserialize, Serialize}; 6 | 7 | #[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)] 8 | #[serde(rename_all = "lowercase")] 9 | #[allow(dead_code)] 10 | pub enum PlatformType { 11 | Linux, 12 | OsX, 13 | Windows, 14 | SunOs, 15 | Android, 16 | FreeBsd, 17 | NetBsd, 18 | OpenBsd, 19 | Common, 20 | } 21 | 22 | impl fmt::Display for PlatformType { 23 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 24 | match self { 25 | Self::Linux => write!(f, "Linux"), 26 | Self::OsX => write!(f, "macOS / BSD"), 27 | Self::Windows => write!(f, "Windows"), 28 | Self::SunOs => write!(f, "SunOS"), 29 | Self::Android => write!(f, "Android"), 30 | Self::FreeBsd => write!(f, "FreeBSD"), 31 | Self::NetBsd => write!(f, "NetBSD"), 32 | Self::OpenBsd => write!(f, "OpenBSD"), 33 | Self::Common => write!(f, "Common"), 34 | } 35 | } 36 | } 37 | 38 | impl clap::ValueEnum for PlatformType { 39 | fn value_variants<'a>() -> &'a [Self] { 40 | &[ 41 | Self::Linux, 42 | Self::OsX, 43 | Self::SunOs, 44 | Self::Windows, 45 | Self::Android, 46 | Self::FreeBsd, 47 | Self::NetBsd, 48 | Self::OpenBsd, 49 | Self::Common, 50 | ] 51 | } 52 | 53 | fn to_possible_value<'a>(&self) -> Option { 54 | match self { 55 | Self::Linux => Some(clap::builder::PossibleValue::new("linux")), 56 | Self::OsX => Some(clap::builder::PossibleValue::new("macos").alias("osx")), 57 | Self::Windows => Some(clap::builder::PossibleValue::new("windows")), 58 | Self::SunOs => Some(clap::builder::PossibleValue::new("sunos")), 59 | Self::Android => Some(clap::builder::PossibleValue::new("android")), 60 | Self::FreeBsd => Some(clap::builder::PossibleValue::new("freebsd")), 61 | Self::NetBsd => Some(clap::builder::PossibleValue::new("netbsd")), 62 | Self::OpenBsd => Some(clap::builder::PossibleValue::new("openbsd")), 63 | Self::Common => Some(clap::builder::PossibleValue::new("common")), 64 | } 65 | } 66 | } 67 | 68 | impl PlatformType { 69 | #[cfg(target_os = "linux")] 70 | pub fn current() -> Self { 71 | Self::Linux 72 | } 73 | 74 | #[cfg(any(target_os = "macos", target_os = "dragonfly"))] 75 | pub fn current() -> Self { 76 | Self::OsX 77 | } 78 | 79 | #[cfg(target_os = "windows")] 80 | pub fn current() -> Self { 81 | Self::Windows 82 | } 83 | 84 | #[cfg(target_os = "android")] 85 | pub fn current() -> Self { 86 | Self::Android 87 | } 88 | 89 | #[cfg(target_os = "freebsd")] 90 | pub fn current() -> Self { 91 | Self::FreeBsd 92 | } 93 | 94 | #[cfg(target_os = "netbsd")] 95 | pub fn current() -> Self { 96 | Self::NetBsd 97 | } 98 | 99 | #[cfg(target_os = "openbsd")] 100 | pub fn current() -> Self { 101 | Self::OpenBsd 102 | } 103 | 104 | #[cfg(not(any( 105 | target_os = "linux", 106 | target_os = "macos", 107 | target_os = "freebsd", 108 | target_os = "netbsd", 109 | target_os = "openbsd", 110 | target_os = "dragonfly", 111 | target_os = "windows", 112 | target_os = "android", 113 | )))] 114 | pub fn current() -> Self { 115 | Self::Other 116 | } 117 | } 118 | 119 | #[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, clap::ValueEnum)] 120 | #[serde(rename_all = "lowercase")] 121 | pub enum ColorOptions { 122 | Always, 123 | Auto, 124 | Never, 125 | } 126 | 127 | impl Default for ColorOptions { 128 | fn default() -> Self { 129 | Self::Auto 130 | } 131 | } 132 | 133 | #[derive(Debug, Eq, PartialEq)] 134 | pub enum LineType { 135 | Empty, 136 | Title(String), 137 | Description(String), 138 | ExampleText(String), 139 | ExampleCode(String), 140 | Other(String), 141 | } 142 | 143 | impl<'a> From<&'a str> for LineType { 144 | /// Convert a string slice to a `LineType`. Newlines and trailing whitespace are trimmed. 145 | fn from(line: &'a str) -> Self { 146 | let trimmed: &str = line.trim_end(); 147 | let mut chars = trimmed.chars(); 148 | match chars.next() { 149 | None => Self::Empty, 150 | Some('#') => Self::Title( 151 | trimmed 152 | .trim_start_matches(|chr: char| chr == '#' || chr.is_whitespace()) 153 | .into(), 154 | ), 155 | Some('>') => Self::Description( 156 | trimmed 157 | .trim_start_matches(|chr: char| chr == '>' || chr.is_whitespace()) 158 | .into(), 159 | ), 160 | Some(' ') => Self::ExampleCode(trimmed.trim_start_matches(char::is_whitespace).into()), 161 | Some(_) => Self::ExampleText(trimmed.into()), 162 | } 163 | } 164 | } 165 | 166 | impl LineType { 167 | /// Support for old format. 168 | /// TODO: Remove once old format has been phased out! 169 | pub fn from_v1(line: &str) -> Self { 170 | let trimmed = line.trim(); 171 | let mut chars = trimmed.chars(); 172 | match chars.next() { 173 | None => Self::Empty, 174 | Some('#') => Self::Title( 175 | trimmed 176 | .trim_start_matches(|chr: char| chr == '#' || chr.is_whitespace()) 177 | .into(), 178 | ), 179 | Some('>') => Self::Description( 180 | trimmed 181 | .trim_start_matches(|chr: char| chr == '>' || chr.is_whitespace()) 182 | .into(), 183 | ), 184 | Some('-') => Self::ExampleText( 185 | trimmed 186 | .trim_start_matches(|chr: char| chr == '-' || chr.is_whitespace()) 187 | .into(), 188 | ), 189 | Some('`') if chars.last() == Some('`') => Self::ExampleCode( 190 | trimmed 191 | .trim_matches(|chr: char| chr == '`' || chr.is_whitespace()) 192 | .into(), 193 | ), 194 | Some(_) => Self::Other(trimmed.into()), 195 | } 196 | } 197 | } 198 | 199 | /// The reason why a certain path (e.g. config path or cache dir) was chosen. 200 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 201 | pub enum PathSource { 202 | /// OS convention (e.g. XDG on Linux) 203 | OsConvention, 204 | /// Env variable (TEALDEER_*) 205 | EnvVar, 206 | /// Config file 207 | ConfigFile, 208 | /// CLI argument override 209 | Cli, 210 | } 211 | 212 | impl fmt::Display for PathSource { 213 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 214 | write!( 215 | f, 216 | "{}", 217 | match self { 218 | Self::OsConvention => "OS convention", 219 | Self::EnvVar => "env variable", 220 | Self::ConfigFile => "config file", 221 | Self::Cli => "command line argument", 222 | } 223 | ) 224 | } 225 | } 226 | 227 | #[cfg(test)] 228 | mod test { 229 | use super::LineType; 230 | 231 | #[test] 232 | fn test_linetype_from_str() { 233 | assert_eq!(LineType::from(""), LineType::Empty); 234 | assert_eq!(LineType::from(" \n \r"), LineType::Empty); 235 | assert_eq!( 236 | LineType::from("# Hello there"), 237 | LineType::Title("Hello there".into()) 238 | ); 239 | assert_eq!( 240 | LineType::from("> tis a description \n"), 241 | LineType::Description("tis a description".into()) 242 | ); 243 | assert_eq!( 244 | LineType::from("some command "), 245 | LineType::ExampleText("some command".into()) 246 | ); 247 | assert_eq!( 248 | LineType::from(" $ cargo run "), 249 | LineType::ExampleCode("$ cargo run".into()) 250 | ); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use yansi::{Color, Paint}; 2 | 3 | /// Print a warning to stderr. If `enable_styles` is true, then a yellow 4 | /// message will be printed. 5 | pub fn print_warning(enable_styles: bool, message: &str) { 6 | print_msg(enable_styles, message, "Warning: ", Color::Yellow); 7 | } 8 | 9 | /// Print an anyhow error to stderr. If `enable_styles` is true, then a red 10 | /// message will be printed. 11 | pub fn print_error(enable_styles: bool, error: &anyhow::Error) { 12 | print_msg(enable_styles, &format!("{error:?}"), "Error: ", Color::Red); 13 | } 14 | 15 | fn print_msg(enable_styles: bool, message: &str, prefix: &'static str, color: Color) { 16 | if enable_styles { 17 | eprintln!("{}{}", prefix.paint(color), message.paint(color)); 18 | } else { 19 | eprintln!("{message}"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/cache/pages.ja/common/apt.md: -------------------------------------------------------------------------------- 1 | # apt 2 | 3 | > Debian系ディストリビューションで使われるパッケージ管理システムです。 4 | > Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。 5 | > 詳しくはこちら: 6 | 7 | - 利用可能なパーケージとバージョンのリストの更新(他の`apt`コマンドの前での実行を推奨): 8 | 9 | `sudo apt update` 10 | 11 | - 指定されたパッケージの検索: 12 | 13 | `apt search {{パッケージ}}` 14 | 15 | - パッケージの情報を出力: 16 | 17 | `apt show {{パッケージ}}` 18 | 19 | - パッケージのインストール、または利用可能な最新バージョンに更新: 20 | 21 | `sudo apt install {{パッケージ}}` 22 | 23 | - パッケージの削除(`sudo apt remove --purge`の場合設定ファイルも削除): 24 | 25 | `sudo apt remove {{パッケージ}}` 26 | 27 | - インストールされている全てのパッケージを最新のバージョンにアップグレード: 28 | 29 | `sudo apt upgrade` 30 | 31 | - インストールできるすべてのパッケージを表示: 32 | 33 | `apt list` 34 | 35 | - インストールされた全てのパッケージを表示(依存関係も表示): 36 | 37 | `apt list --installed` 38 | -------------------------------------------------------------------------------- /tests/cache/pages/common/git-checkout.md: -------------------------------------------------------------------------------- 1 | # git checkout 2 | 3 | > Checkout a branch or paths to the working tree. 4 | > More information: . 5 | 6 | - Create and switch to a new branch: 7 | 8 | `git checkout -b {{branch_name}}` 9 | 10 | - Create and switch to a new branch based on a specific reference (branch, remote/branch, tag are examples of valid references): 11 | 12 | `git checkout -b {{branch_name}} {{reference}}` 13 | 14 | - Switch to an existing local branch: 15 | 16 | `git checkout {{branch_name}}` 17 | 18 | - Switch to the previously checked out branch: 19 | 20 | `git checkout -` 21 | 22 | - Switch to an existing remote branch: 23 | 24 | `git checkout --track {{remote_name}}/{{branch_name}}` 25 | 26 | - Discard all unstaged changes in the current directory (see `git reset` for more undo-like commands): 27 | 28 | `git checkout .` 29 | 30 | - Discard unstaged changes to a given file: 31 | 32 | `git checkout {{path/to/file}}` 33 | 34 | - Replace a file in the current directory with the version of it committed in a given branch: 35 | 36 | `git checkout {{branch_name}} -- {{path/to/file}}` 37 | -------------------------------------------------------------------------------- /tests/cache/pages/common/inkscape-v1.md: -------------------------------------------------------------------------------- 1 | # inkscape 2 | 3 | > An SVG (Scalable Vector Graphics) editing program. 4 | > Use -z to not open the GUI and only process files in the console. 5 | 6 | - Open an SVG file in the Inkscape GUI: 7 | 8 | `inkscape {{filename.svg}}` 9 | 10 | - Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 11 | 12 | `inkscape {{filename.svg}} -e {{filename.png}}` 13 | 14 | - Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 15 | 16 | `inkscape {{filename.svg}} -e {{filename.png}} -w {{600}} -h {{400}}` 17 | 18 | - Export a single object, given its ID, into a bitmap: 19 | 20 | `inkscape {{filename.svg}} -i {{id}} -e {{object.png}}` 21 | 22 | - Export an SVG document to PDF, converting all texts to paths: 23 | 24 | `inkscape {{filename.svg}} | inkscape | inkscape --export-pdf={{inkscape.pdf}} | inkscape | inkscape --export-text-to-path` 25 | 26 | - Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 27 | 28 | `inkscape {{filename.svg}} --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit` 29 | 30 | - Some invalid command just to test the correct highlighting of the command name: 31 | 32 | `inkscape --use-inkscape=v3.0 file` 33 | -------------------------------------------------------------------------------- /tests/cache/pages/common/inkscape-v2.md: -------------------------------------------------------------------------------- 1 | inkscape 2 | ======== 3 | 4 | > An SVG (Scalable Vector Graphics) editing program. 5 | > Use -z to not open the GUI and only process files in the console. 6 | 7 | Open an SVG file in the Inkscape GUI: 8 | 9 | inkscape {{filename.svg}} 10 | 11 | Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 12 | 13 | inkscape {{filename.svg}} -e {{filename.png}} 14 | 15 | Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 16 | 17 | inkscape {{filename.svg}} -e {{filename.png}} -w {{600}} -h {{400}} 18 | 19 | Export a single object, given its ID, into a bitmap: 20 | 21 | inkscape {{filename.svg}} -i {{id}} -e {{object.png}} 22 | 23 | Export an SVG document to PDF, converting all texts to paths: 24 | 25 | inkscape {{filename.svg}} | inkscape | inkscape --export-pdf={{inkscape.pdf}} | inkscape | inkscape --export-text-to-path 26 | 27 | Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 28 | 29 | inkscape {{filename.svg}} --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit 30 | 31 | Some invalid command just to test the correct highlighting of the command name: 32 | 33 | inkscape --use-inkscape=v3.0 file 34 | -------------------------------------------------------------------------------- /tests/cache/pages/common/which.md: -------------------------------------------------------------------------------- 1 | # which 2 | 3 | > Locate a program in the user's path. 4 | 5 | - Search the PATH environment variable and display the location of any matching executables: 6 | 7 | `which {{executable}}` 8 | 9 | - If there are multiple executables which match, display all: 10 | 11 | `which -a {{executable}}` 12 | -------------------------------------------------------------------------------- /tests/custom-pages/inkscape-v2.patch.md: -------------------------------------------------------------------------------- 1 | Custom inkscape entry 2 | 3 | My Inkscape example 4 | -------------------------------------------------------------------------------- /tests/lib.rs: -------------------------------------------------------------------------------- 1 | //! Integration tests. 2 | 3 | use std::{ 4 | fs::{self, create_dir_all, File}, 5 | io::{self, Write}, 6 | path::{Path, PathBuf}, 7 | process::Command, 8 | time::{Duration, SystemTime}, 9 | }; 10 | 11 | use assert_cmd::prelude::*; 12 | use predicates::{ 13 | boolean::PredicateBooleanExt, 14 | prelude::predicate::str::{contains, diff, is_empty, is_match}, 15 | }; 16 | use tempfile::{Builder as TempfileBuilder, TempDir}; 17 | 18 | pub static TLDR_PAGES_DIR: &str = "tldr-pages"; 19 | 20 | struct TestEnv { 21 | _test_dir: TempDir, 22 | pub default_features: bool, 23 | pub features: Vec, 24 | } 25 | 26 | impl TestEnv { 27 | fn new() -> Self { 28 | let test_dir: TempDir = TempfileBuilder::new() 29 | .prefix(".tldr.test") 30 | .tempdir() 31 | .unwrap(); 32 | 33 | let this = TestEnv { 34 | _test_dir: test_dir, 35 | default_features: true, 36 | features: vec![], 37 | }; 38 | 39 | create_dir_all(&this.cache_dir()).unwrap(); 40 | create_dir_all(&this.config_dir()).unwrap(); 41 | create_dir_all(&this.custom_pages_dir()).unwrap(); 42 | 43 | this.append_to_config(format!( 44 | "directories.cache_dir = '{}'\n", 45 | this.cache_dir().to_str().unwrap(), 46 | )); 47 | 48 | this 49 | } 50 | 51 | fn cache_dir(&self) -> PathBuf { 52 | self._test_dir.path().join(".cache") 53 | } 54 | 55 | fn config_dir(&self) -> PathBuf { 56 | self._test_dir.path().join(".config") 57 | } 58 | 59 | fn custom_pages_dir(&self) -> PathBuf { 60 | self._test_dir.path().join(".custom_pages") 61 | } 62 | 63 | fn append_to_config(&self, content: impl AsRef) { 64 | File::options() 65 | .create(true) 66 | .append(true) 67 | .open(self.config_dir().join("config.toml")) 68 | .expect("Failed to open config file") 69 | .write_all(content.as_ref().as_bytes()) 70 | .expect("Failed to append to config file."); 71 | } 72 | 73 | fn create_secondary_config(self) -> Self { 74 | self.append_to_secondary_config(format!( 75 | "directories.cache_dir = '{}'\n", 76 | self.cache_dir().to_str().unwrap(), 77 | )); 78 | self 79 | } 80 | 81 | fn append_to_secondary_config(&self, content: impl AsRef) { 82 | File::options() 83 | .create(true) 84 | .append(true) 85 | .open(self.config_dir().join("config-secondary.toml")) 86 | .expect("Failed to open config file") 87 | .write_all(content.as_ref().as_bytes()) 88 | .expect("Failed to append to config file."); 89 | } 90 | 91 | fn remove_initial_config(self) -> Self { 92 | let _ = fs::remove_file(self.config_dir().join("config.toml")); 93 | self 94 | } 95 | 96 | /// Add entry for that environment to the "common" pages. 97 | fn add_entry(&self, name: &str, contents: &str) { 98 | self.add_os_entry("common", name, contents); 99 | } 100 | 101 | /// Add entry for that environment to an OS-specific subfolder. 102 | fn add_os_entry(&self, os: &str, name: &str, contents: &str) { 103 | let dir = self.cache_dir().join(TLDR_PAGES_DIR).join("pages").join(os); 104 | create_dir_all(&dir).unwrap(); 105 | 106 | fs::write(dir.join(format!("{name}.md")), contents.as_bytes()).unwrap(); 107 | } 108 | 109 | /// Add custom patch entry to the custom_pages_dir 110 | fn add_page_entry(&self, name: &str, contents: &str) { 111 | let dir = &self.custom_pages_dir(); 112 | create_dir_all(dir).unwrap(); 113 | fs::write(dir.join(format!("{name}.page.md")), contents.as_bytes()).unwrap(); 114 | } 115 | 116 | /// Add custom patch entry to the custom_pages_dir 117 | fn add_patch_entry(&self, name: &str, contents: &str) { 118 | let dir = &self.custom_pages_dir(); 119 | create_dir_all(dir).unwrap(); 120 | fs::write(dir.join(format!("{name}.patch.md")), contents.as_bytes()).unwrap(); 121 | } 122 | 123 | /// Disable default features. 124 | fn no_default_features(mut self) -> Self { 125 | self.default_features = false; 126 | self 127 | } 128 | 129 | /// Add the specified feature. 130 | fn with_feature>(mut self, feature: S) -> Self { 131 | self.features.push(feature.into()); 132 | self 133 | } 134 | 135 | /// Return a new `Command` with env vars set. 136 | fn command(&self) -> Command { 137 | let mut build = escargot::CargoBuild::new() 138 | .bin("tldr") 139 | .arg("--color=never") 140 | .current_release() 141 | .current_target(); 142 | if !self.default_features { 143 | build = build.no_default_features(); 144 | } 145 | if !self.features.is_empty() { 146 | build = build.features(self.features.join(" ")) 147 | } 148 | let run = build.run().expect("Failed to build tealdeer for testing"); 149 | let mut cmd = run.command(); 150 | cmd.env("TEALDEER_CONFIG_DIR", self.config_dir().to_str().unwrap()); 151 | cmd 152 | } 153 | 154 | fn install_default_cache(self) -> Self { 155 | copy_recursively( 156 | &PathBuf::from_iter([env!("CARGO_MANIFEST_DIR"), "tests", "cache"]), 157 | &self.cache_dir().join(TLDR_PAGES_DIR), 158 | ) 159 | .expect("Failed to copy the cache to the test environment"); 160 | 161 | self 162 | } 163 | 164 | fn install_default_custom_pages(self) -> Self { 165 | copy_recursively( 166 | &PathBuf::from_iter([env!("CARGO_MANIFEST_DIR"), "tests", "custom-pages"]), 167 | self.custom_pages_dir().as_path(), 168 | ) 169 | .expect("Failed to copy the custom pages to the test environment"); 170 | 171 | self.write_custom_pages_config() 172 | } 173 | 174 | fn write_custom_pages_config(self) -> Self { 175 | self.append_to_config(format!( 176 | "directories.custom_pages_dir = '{}'\n", 177 | self.custom_pages_dir().to_str().unwrap() 178 | )); 179 | 180 | self 181 | } 182 | } 183 | 184 | fn copy_recursively(source: &Path, destination: &Path) -> io::Result<()> { 185 | if source.is_dir() { 186 | fs::create_dir_all(destination)?; 187 | for entry in fs::read_dir(source)? { 188 | let entry = entry?; 189 | copy_recursively(&entry.path(), &destination.join(entry.file_name()))?; 190 | } 191 | } else { 192 | fs::copy(source, destination)?; 193 | } 194 | 195 | Ok(()) 196 | } 197 | 198 | #[test] 199 | #[should_panic] 200 | fn test_cannot_build_without_tls_feature() { 201 | let _ = TestEnv::new().no_default_features().command(); 202 | } 203 | 204 | #[test] 205 | fn test_load_the_correct_config() { 206 | let testenv = TestEnv::new() 207 | .install_default_cache() 208 | .create_secondary_config(); 209 | testenv.append_to_secondary_config(include_str!("style-config.toml")); 210 | 211 | let expected_default = include_str!("rendered/inkscape-default.expected"); 212 | let expected_with_config = include_str!("rendered/inkscape-with-config.expected"); 213 | 214 | testenv 215 | .command() 216 | .args(["--color", "always", "inkscape-v2"]) 217 | .assert() 218 | .success() 219 | .stdout(diff(expected_default)); 220 | 221 | testenv 222 | .command() 223 | .args([ 224 | "--color", 225 | "always", 226 | "--config-path", 227 | testenv 228 | .config_dir() 229 | .join("config-secondary.toml") 230 | .to_str() 231 | .unwrap(), 232 | "inkscape-v2", 233 | ]) 234 | .assert() 235 | .success() 236 | .stdout(diff(expected_with_config)); 237 | } 238 | 239 | #[test] 240 | fn test_fail_on_custom_config_path_is_directory() { 241 | let testenv = TestEnv::new(); 242 | let error = if cfg!(windows) { 243 | "Access is denied" 244 | } else { 245 | "Is a directory" 246 | }; 247 | testenv 248 | .command() 249 | .args([ 250 | "--config-path", 251 | testenv.config_dir().to_str().unwrap(), 252 | "sl", 253 | ]) 254 | .assert() 255 | .failure() 256 | .stderr(contains(error)); 257 | } 258 | 259 | #[test] 260 | fn test_missing_cache() { 261 | TestEnv::new() 262 | .command() 263 | .args(["sl"]) 264 | .assert() 265 | .failure() 266 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 267 | } 268 | 269 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 270 | #[test] 271 | fn test_update_cache_default_features() { 272 | let testenv = TestEnv::new(); 273 | 274 | testenv 275 | .command() 276 | .args(["sl"]) 277 | .assert() 278 | .failure() 279 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 280 | 281 | testenv 282 | .command() 283 | .args(["--update"]) 284 | .assert() 285 | .success() 286 | .stderr(contains("Successfully updated cache.")); 287 | 288 | testenv.command().args(["sl"]).assert().success(); 289 | } 290 | 291 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 292 | #[test] 293 | fn test_update_cache_rustls_webpki() { 294 | let testenv = TestEnv::new() 295 | .no_default_features() 296 | .with_feature("rustls-with-webpki-roots"); 297 | 298 | testenv 299 | .command() 300 | .args(["sl"]) 301 | .assert() 302 | .failure() 303 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 304 | 305 | testenv 306 | .command() 307 | .args(["--update"]) 308 | .assert() 309 | .success() 310 | .stderr(contains("Successfully updated cache.")); 311 | 312 | testenv.command().args(["sl"]).assert().success(); 313 | } 314 | 315 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 316 | #[test] 317 | fn test_update_cache_native_tls() { 318 | let testenv = TestEnv::new() 319 | .no_default_features() 320 | .with_feature("rustls-with-native-roots"); 321 | 322 | testenv 323 | .command() 324 | .args(["sl"]) 325 | .assert() 326 | .failure() 327 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 328 | 329 | testenv 330 | .command() 331 | .args(["--update"]) 332 | .assert() 333 | .success() 334 | .stderr(contains("Successfully updated cache.")); 335 | 336 | testenv.command().args(["sl"]).assert().success(); 337 | } 338 | 339 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 340 | #[test] 341 | fn test_quiet_cache() { 342 | let testenv = TestEnv::new(); 343 | testenv 344 | .command() 345 | .args(["--update", "--quiet"]) 346 | .assert() 347 | .success() 348 | .stdout(is_empty()); 349 | 350 | testenv 351 | .command() 352 | .args(["--clear-cache", "--quiet"]) 353 | .assert() 354 | .success() 355 | .stdout(is_empty()); 356 | } 357 | 358 | #[test] 359 | fn test_warn_invalid_tls_backend() { 360 | let testenv = TestEnv::new() 361 | .no_default_features() 362 | .with_feature("rustls-with-webpki-roots") 363 | .remove_initial_config(); 364 | 365 | testenv.append_to_config("updates.tls_backend = 'invalid-tls-backend'\n"); 366 | 367 | testenv 368 | .command() 369 | .args(["sl"]) 370 | .assert() 371 | .failure() 372 | .stderr(contains("unknown variant `invalid-tls-backend`, expected one of `native-tls`, `rustls-with-webpki-roots`, `rustls-with-native-roots`")); 373 | } 374 | 375 | #[test] 376 | fn test_quiet_failures() { 377 | let testenv = TestEnv::new().install_default_cache(); 378 | 379 | testenv 380 | .command() 381 | .args(["fakeprogram", "-q"]) 382 | .assert() 383 | .failure() 384 | .stdout(is_empty()); 385 | } 386 | 387 | #[test] 388 | fn test_quiet_old_cache() { 389 | let testenv = TestEnv::new().install_default_cache(); 390 | 391 | filetime::set_file_mtime( 392 | testenv.cache_dir().join(TLDR_PAGES_DIR), 393 | filetime::FileTime::from_unix_time(1, 0), 394 | ) 395 | .unwrap(); 396 | 397 | testenv 398 | .command() 399 | .args(["which"]) 400 | .assert() 401 | .success() 402 | .stderr(contains("The cache hasn't been updated for ")); 403 | 404 | testenv 405 | .command() 406 | .args(["which", "--quiet"]) 407 | .assert() 408 | .success() 409 | .stderr(contains("The cache hasn't been updated for ").not()); 410 | } 411 | 412 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 413 | #[test] 414 | fn test_create_cache_directory_path() { 415 | let testenv = TestEnv::new().remove_initial_config(); 416 | let cache_dir = &testenv.cache_dir(); 417 | let internal_cache_dir = cache_dir.join("internal"); 418 | testenv.append_to_config(format!( 419 | "directories.cache_dir = '{}'\n", 420 | internal_cache_dir.to_str().unwrap() 421 | )); 422 | 423 | let mut command = testenv.command(); 424 | 425 | assert!(!internal_cache_dir.exists()); 426 | 427 | command 428 | .arg("--update") 429 | .assert() 430 | .success() 431 | .stderr(contains(format!( 432 | "Successfully created cache directory path `{}`.", 433 | internal_cache_dir.to_str().unwrap() 434 | ))) 435 | .stderr(contains("Successfully updated cache.")); 436 | 437 | assert!(internal_cache_dir.is_dir()); 438 | } 439 | 440 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 441 | #[test] 442 | fn test_cache_location_not_a_directory() { 443 | let testenv = TestEnv::new().remove_initial_config(); 444 | let cache_dir = &testenv.cache_dir(); 445 | let internal_file = cache_dir.join("internal"); 446 | File::create(&internal_file).unwrap(); 447 | 448 | testenv.append_to_config(format!( 449 | "directories.cache_dir = '{}'\n", 450 | internal_file.to_str().unwrap() 451 | )); 452 | 453 | testenv 454 | .command() 455 | .arg("--update") 456 | .assert() 457 | .failure() 458 | .stderr(contains(format!( 459 | "Cache directory path `{}` is not a directory", 460 | internal_file.display(), 461 | ))); 462 | } 463 | 464 | #[test] 465 | fn test_cache_location_source() { 466 | let testenv = TestEnv::new().remove_initial_config(); 467 | let default_cache_dir = &testenv.cache_dir(); 468 | let tmp_cache_dir = TempfileBuilder::new() 469 | .prefix(".tldr.test.cache_dir") 470 | .tempdir() 471 | .unwrap(); 472 | 473 | // Source: Default (OS convention) 474 | let mut command = testenv.command(); 475 | command 476 | .arg("--show-paths") 477 | .assert() 478 | .success() 479 | .stdout(is_match("\nCache dir: [^(]* \\(OS convention\\)\n").unwrap()); 480 | 481 | // Source: Config variable 482 | let mut command = testenv.command(); 483 | testenv.append_to_config(format!( 484 | "directories.cache_dir = '{}'\n", 485 | tmp_cache_dir.path().to_str().unwrap(), 486 | )); 487 | command 488 | .arg("--show-paths") 489 | .assert() 490 | .success() 491 | .stdout(is_match("\nCache dir: [^(]* \\(config file\\)\n").unwrap()); 492 | 493 | // Source: Env var 494 | let mut command = testenv.command(); 495 | command.env("TEALDEER_CACHE_DIR", default_cache_dir.to_str().unwrap()); 496 | command 497 | .arg("--show-paths") 498 | .assert() 499 | .success() 500 | .stdout(is_match("\nCache dir: [^(]* \\(env variable\\)\n").unwrap()); 501 | } 502 | 503 | #[test] 504 | fn test_setup_seed_config() { 505 | let testenv = TestEnv::new(); 506 | 507 | testenv 508 | .command() 509 | .args(["--seed-config"]) 510 | .assert() 511 | .failure() 512 | .stderr(contains("A configuration file already exists")); 513 | 514 | assert!(testenv.config_dir().join("config.toml").is_file()); 515 | 516 | let testenv = testenv.remove_initial_config(); 517 | testenv 518 | .command() 519 | .args(["--seed-config"]) 520 | .assert() 521 | .success() 522 | .stderr(contains("Successfully created seed config file here")); 523 | 524 | assert!(testenv.config_dir().join("config.toml").is_file()); 525 | 526 | // Create parent directories as needed for the default config path. 527 | fs::remove_dir_all(testenv.config_dir()).unwrap(); 528 | testenv 529 | .command() 530 | .args(["--seed-config"]) 531 | .assert() 532 | .success() 533 | .stderr(contains("Successfully created seed config file here")); 534 | 535 | assert!(testenv.config_dir().join("config.toml").is_file()); 536 | 537 | // Write the default config to --config-path if specified by the user 538 | // at the same time. 539 | let custom_config_path = testenv.config_dir().join("config_custom.toml"); 540 | testenv 541 | .command() 542 | .args([ 543 | "--seed-config", 544 | "--config-path", 545 | custom_config_path.to_str().unwrap(), 546 | ]) 547 | .assert() 548 | .success() 549 | .stderr(contains("Successfully created seed config file here")); 550 | 551 | assert!(custom_config_path.is_file()); 552 | 553 | // DON'T create parent directories for a custom config path. 554 | fs::remove_dir_all(testenv.config_dir()).unwrap(); 555 | testenv 556 | .command() 557 | .args([ 558 | "--seed-config", 559 | "--config-path", 560 | custom_config_path.to_str().unwrap(), 561 | ]) 562 | .assert() 563 | .failure() 564 | .stderr(contains("Could not create config file")); 565 | 566 | assert!(!custom_config_path.is_file()); 567 | } 568 | 569 | #[test] 570 | fn test_show_paths() { 571 | let testenv = TestEnv::new(); 572 | 573 | // Show general commands 574 | testenv 575 | .command() 576 | .args(["--show-paths"]) 577 | .assert() 578 | .success() 579 | .stdout(contains(format!( 580 | "Config dir: {}", 581 | testenv.config_dir().to_str().unwrap(), 582 | ))) 583 | .stdout(contains(format!( 584 | "Config path: {}", 585 | testenv.config_dir().join("config.toml").to_str().unwrap(), 586 | ))) 587 | .stdout(contains(format!( 588 | "Cache dir: {}", 589 | testenv.cache_dir().to_str().unwrap(), 590 | ))) 591 | .stdout(contains(format!( 592 | "Pages dir: {}", 593 | testenv.cache_dir().join(TLDR_PAGES_DIR).to_str().unwrap(), 594 | ))); 595 | 596 | let testenv = testenv.write_custom_pages_config(); 597 | 598 | // Now ensure that this path is contained in the output 599 | testenv 600 | .command() 601 | .args(["--show-paths"]) 602 | .assert() 603 | .success() 604 | .stdout(contains(format!( 605 | "Custom pages dir: {}", 606 | testenv.custom_pages_dir().to_str().unwrap(), 607 | ))); 608 | } 609 | 610 | #[test] 611 | fn test_os_specific_page() { 612 | let testenv = TestEnv::new(); 613 | 614 | testenv.add_os_entry("sunos", "truss", "contents"); 615 | 616 | testenv 617 | .command() 618 | .args(["--platform", "sunos", "truss"]) 619 | .assert() 620 | .success(); 621 | } 622 | 623 | #[test] 624 | fn test_markdown_rendering() { 625 | let testenv = TestEnv::new().install_default_cache(); 626 | 627 | let expected = include_str!("cache/pages/common/which.md"); 628 | testenv 629 | .command() 630 | .args(["--raw", "which"]) 631 | .assert() 632 | .success() 633 | .stdout(diff(expected)); 634 | } 635 | 636 | fn _test_correct_rendering(page: &str, expected: &'static str, additional_args: &[&str]) { 637 | let testenv = TestEnv::new().install_default_cache(); 638 | 639 | testenv 640 | .command() 641 | .args(additional_args) 642 | .arg(page) 643 | .assert() 644 | .success() 645 | .stdout(diff(expected)); 646 | } 647 | 648 | /// An end-to-end integration test for direct file rendering (v1 syntax). 649 | #[test] 650 | fn test_correct_rendering_v1() { 651 | _test_correct_rendering( 652 | "inkscape-v1", 653 | include_str!("rendered/inkscape-default.expected"), 654 | &["--color", "always"], 655 | ); 656 | } 657 | 658 | /// An end-to-end integration test for direct file rendering (v2 syntax). 659 | #[test] 660 | fn test_correct_rendering_v2() { 661 | _test_correct_rendering( 662 | "inkscape-v2", 663 | include_str!("rendered/inkscape-default.expected"), 664 | &["--color", "always"], 665 | ); 666 | } 667 | 668 | #[test] 669 | /// An end-to-end integration test for direct file rendering with the `--color auto` option. This 670 | /// will not use styling since output is not stdout. 671 | fn test_rendering_color_auto() { 672 | _test_correct_rendering( 673 | "inkscape-v2", 674 | include_str!("rendered/inkscape-default-no-color.expected"), 675 | &["--color", "auto"], 676 | ); 677 | } 678 | 679 | #[test] 680 | /// An end-to-end integration test for direct file rendering with the `--color never` option. 681 | fn test_rendering_color_never() { 682 | _test_correct_rendering( 683 | "inkscape-v2", 684 | include_str!("rendered/inkscape-default-no-color.expected"), 685 | &["--color", "never"], 686 | ); 687 | } 688 | 689 | #[test] 690 | fn test_rendering_i18n() { 691 | _test_correct_rendering( 692 | "apt", 693 | include_str!("rendered/apt.ja.expected"), 694 | &["--color", "always", "--language", "ja"], 695 | ); 696 | } 697 | 698 | /// An end-to-end integration test for rendering with custom syntax config. 699 | #[test] 700 | fn test_correct_rendering_with_config() { 701 | let testenv = TestEnv::new().install_default_cache(); 702 | 703 | testenv.append_to_config(include_str!("style-config.toml")); 704 | 705 | let expected = include_str!("rendered/inkscape-with-config.expected"); 706 | 707 | testenv 708 | .command() 709 | .args(["--color", "always", "inkscape-v2"]) 710 | .assert() 711 | .success() 712 | .stdout(diff(expected)); 713 | } 714 | 715 | #[test] 716 | fn test_spaces_find_command() { 717 | let testenv = TestEnv::new().install_default_cache(); 718 | 719 | testenv 720 | .command() 721 | .args(["git", "checkout"]) 722 | .assert() 723 | .success(); 724 | } 725 | 726 | #[test] 727 | fn test_pager_flag_enable() { 728 | let testenv = TestEnv::new().install_default_cache(); 729 | 730 | testenv 731 | .command() 732 | .args(["--pager", "which"]) 733 | .assert() 734 | .success(); 735 | } 736 | 737 | #[test] 738 | fn test_multiple_platform_command_search() { 739 | let testenv = TestEnv::new(); 740 | testenv.add_os_entry("linux", "linux-only", "this command only exists for linux"); 741 | testenv.add_os_entry( 742 | "linux", 743 | "windows-and-linux", 744 | "# windows-and-linux \n\n > linux version", 745 | ); 746 | testenv.add_os_entry( 747 | "windows", 748 | "windows-and-linux", 749 | "# windows-and-linux \n\n > windows version", 750 | ); 751 | 752 | testenv 753 | .command() 754 | .args(["--platform", "windows", "--platform", "linux", "linux-only"]) 755 | .assert() 756 | .success(); 757 | 758 | // test order of platforms supplied if preserved 759 | testenv 760 | .command() 761 | .args([ 762 | "--platform", 763 | "windows", 764 | "--platform", 765 | "linux", 766 | "windows-and-linux", 767 | ]) 768 | .assert() 769 | .success() 770 | .stdout(contains("windows version")); 771 | 772 | testenv 773 | .command() 774 | .args([ 775 | "--platform", 776 | "linux", 777 | "--platform", 778 | "windows", 779 | "windows-and-linux", 780 | ]) 781 | .assert() 782 | .success() 783 | .stdout(contains("linux version")); 784 | } 785 | 786 | #[test] 787 | fn test_multiple_platform_command_search_not_found() { 788 | let testenv = TestEnv::new(); 789 | testenv.add_os_entry( 790 | "windows", 791 | "windows-only", 792 | "this command only exists for Windows", 793 | ); 794 | 795 | testenv 796 | .command() 797 | .args(["--platform", "macos", "--platform", "linux", "windows-only"]) 798 | .assert() 799 | .stderr(contains("Page `windows-only` not found in cache.")); 800 | } 801 | 802 | #[test] 803 | fn test_macos_is_alias_for_osx() { 804 | let testenv = TestEnv::new(); 805 | testenv.add_os_entry("osx", "maconly", "this command only exists on mac"); 806 | 807 | testenv 808 | .command() 809 | .args(["--platform", "macos", "maconly"]) 810 | .assert() 811 | .success(); 812 | testenv 813 | .command() 814 | .args(["--platform", "osx", "maconly"]) 815 | .assert() 816 | .success(); 817 | 818 | testenv 819 | .command() 820 | .args(["--platform", "macos", "--list"]) 821 | .assert() 822 | .stdout("maconly\n"); 823 | testenv 824 | .command() 825 | .args(["--platform", "osx", "--list"]) 826 | .assert() 827 | .stdout("maconly\n"); 828 | } 829 | 830 | #[test] 831 | fn test_common_platform_is_used_as_fallback() { 832 | let testenv = TestEnv::new(); 833 | testenv.add_entry("in-common", "this command comes from common"); 834 | 835 | // No platform specified 836 | testenv.command().args(["in-common"]).assert().success(); 837 | 838 | // Platform specified 839 | testenv 840 | .command() 841 | .args(["--platform", "linux", "in-common"]) 842 | .assert() 843 | .success(); 844 | } 845 | 846 | #[test] 847 | fn test_list_flag_rendering() { 848 | let testenv = TestEnv::new().write_custom_pages_config(); 849 | 850 | testenv 851 | .command() 852 | .args(["--list"]) 853 | .assert() 854 | .failure() 855 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 856 | 857 | testenv.add_entry("foo", ""); 858 | 859 | testenv 860 | .command() 861 | .args(["--list"]) 862 | .assert() 863 | .success() 864 | .stdout("foo\n"); 865 | 866 | testenv.add_entry("bar", ""); 867 | testenv.add_entry("baz", ""); 868 | testenv.add_entry("qux", ""); 869 | testenv.add_page_entry("faz", ""); 870 | testenv.add_page_entry("bar", ""); 871 | testenv.add_page_entry("fiz", ""); 872 | testenv.add_patch_entry("buz", ""); 873 | 874 | testenv 875 | .command() 876 | .args(["--list"]) 877 | .assert() 878 | .success() 879 | .stdout("bar\nbaz\nfaz\nfiz\nfoo\nqux\n"); 880 | } 881 | 882 | #[test] 883 | fn test_multi_platform_list_flag_rendering() { 884 | let testenv = TestEnv::new().write_custom_pages_config(); 885 | 886 | testenv.add_entry("common", ""); 887 | 888 | testenv 889 | .command() 890 | .args(["--list"]) 891 | .assert() 892 | .success() 893 | .stdout("common\n"); 894 | 895 | testenv 896 | .command() 897 | .args(["--platform", "linux", "--list"]) 898 | .assert() 899 | .success() 900 | .stdout("common\n"); 901 | 902 | testenv 903 | .command() 904 | .args(["--platform", "windows", "--list"]) 905 | .assert() 906 | .success() 907 | .stdout("common\n"); 908 | 909 | testenv.add_os_entry("linux", "rm", ""); 910 | testenv.add_os_entry("linux", "ls", ""); 911 | testenv.add_os_entry("windows", "del", ""); 912 | testenv.add_os_entry("windows", "dir", ""); 913 | testenv.add_os_entry("linux", "winux", ""); 914 | testenv.add_os_entry("windows", "winux", ""); 915 | 916 | // test `--list` for `--platform linux` by itself 917 | testenv 918 | .command() 919 | .args(["--platform", "linux", "--list"]) 920 | .assert() 921 | .success() 922 | .stdout("common\nls\nrm\nwinux\n"); 923 | 924 | // test `--list` for `--platform windows` by itself 925 | testenv 926 | .command() 927 | .args(["--platform", "windows", "--list"]) 928 | .assert() 929 | .success() 930 | .stdout("common\ndel\ndir\nwinux\n"); 931 | 932 | // test `--list` for `--platform linux --platform windows` 933 | testenv 934 | .command() 935 | .args(["--platform", "linux", "--platform", "windows", "--list"]) 936 | .assert() 937 | .success() 938 | .stdout("common\ndel\ndir\nls\nrm\nwinux\n"); 939 | 940 | // test `--list` for `--platform windows --platform linux` 941 | testenv 942 | .command() 943 | .args(["--platform", "linux", "--platform", "windows", "--list"]) 944 | .assert() 945 | .success() 946 | .stdout("common\ndel\ndir\nls\nrm\nwinux\n"); 947 | } 948 | 949 | #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] 950 | #[test] 951 | fn test_autoupdate_cache() { 952 | let testenv = TestEnv::new(); 953 | 954 | // The first time, if automatic updates are disabled, the cache should not be found 955 | testenv 956 | .command() 957 | .args(["--list"]) 958 | .assert() 959 | .failure() 960 | .stderr(contains("Page cache not found. Please run `tldr --update`")); 961 | 962 | let cache_file_path = testenv.cache_dir().join(TLDR_PAGES_DIR); 963 | 964 | testenv 965 | .append_to_config("updates.auto_update = true\nupdates.auto_update_interval_hours = 24\n"); 966 | 967 | // Helper function that runs `tldr --list` and asserts that the cache is automatically updated 968 | // or not, depending on the value of `expected`. 969 | let check_cache_updated = |expected| { 970 | let assert = testenv.command().args(["--list"]).assert().success(); 971 | let pred = contains("Successfully updated cache"); 972 | if expected { 973 | assert.stderr(pred) 974 | } else { 975 | assert.stderr(pred.not()) 976 | }; 977 | }; 978 | 979 | // The cache is updated the first time we run `tldr --list` 980 | check_cache_updated(true); 981 | 982 | // The cache is not updated with a subsequent call 983 | check_cache_updated(false); 984 | 985 | // We update the modification and access times such that they are about 23 hours from now. 986 | // auto-update interval is 24 hours, the cache should not be updated 987 | let new_mtime = SystemTime::now() - Duration::from_secs(82_800); 988 | filetime::set_file_mtime(&cache_file_path, new_mtime.into()).unwrap(); 989 | check_cache_updated(false); 990 | 991 | // We update the modification and access times such that they are about 25 hours from now. 992 | // auto-update interval is 24 hours, the cache should be updated 993 | let new_mtime = SystemTime::now() - Duration::from_secs(90_000); 994 | filetime::set_file_mtime(&cache_file_path, new_mtime.into()).unwrap(); 995 | check_cache_updated(true); 996 | 997 | // The cache is not updated with a subsequent call 998 | check_cache_updated(false); 999 | } 1000 | 1001 | /// End-end test to ensure .page.md files overwrite pages in cache_dir 1002 | #[test] 1003 | fn test_custom_page_overwrites() { 1004 | let testenv = TestEnv::new().write_custom_pages_config(); 1005 | 1006 | // Add file that should be ignored to the cache dir 1007 | testenv.add_entry("inkscape-v2", ""); 1008 | // Add .page.md file to custom_pages_dir 1009 | testenv.add_page_entry( 1010 | "inkscape-v2", 1011 | include_str!("cache/pages/common/inkscape-v2.md"), 1012 | ); 1013 | 1014 | // Load expected output 1015 | let expected = include_str!("rendered/inkscape-default-no-color.expected"); 1016 | 1017 | testenv 1018 | .command() 1019 | .args(["inkscape-v2", "--color", "never"]) 1020 | .assert() 1021 | .success() 1022 | .stdout(diff(expected)); 1023 | } 1024 | 1025 | /// End-End test to ensure that .patch.md files are appended to pages in the cache_dir 1026 | #[test] 1027 | fn test_custom_patch_appends_to_common() { 1028 | let testenv = TestEnv::new() 1029 | .install_default_cache() 1030 | .install_default_custom_pages(); 1031 | 1032 | // Load expected output 1033 | let expected = include_str!("rendered/inkscape-patched-no-color.expected"); 1034 | 1035 | testenv 1036 | .command() 1037 | .args(["inkscape-v2", "--color", "never"]) 1038 | .assert() 1039 | .success() 1040 | .stdout(diff(expected)); 1041 | } 1042 | 1043 | /// End-End test to ensure that .patch.md files are not appended to .page.md files in the custom_pages_dir 1044 | /// Maybe this interaction should change but I put this test here for the coverage 1045 | #[test] 1046 | fn test_custom_patch_does_not_append_to_custom() { 1047 | let testenv = TestEnv::new() 1048 | .install_default_cache() 1049 | .install_default_custom_pages(); 1050 | 1051 | // In addition to the page in the cache, add the same page as a custom page. 1052 | testenv.add_page_entry( 1053 | "inkscape-v2", 1054 | include_str!("cache/pages/common/inkscape-v2.md"), 1055 | ); 1056 | 1057 | // Load expected output 1058 | let expected = include_str!("rendered/inkscape-default-no-color.expected"); 1059 | 1060 | testenv 1061 | .command() 1062 | .args(["inkscape-v2", "--color", "never"]) 1063 | .assert() 1064 | .success() 1065 | .stdout(diff(expected)); 1066 | } 1067 | 1068 | #[test] 1069 | #[cfg(target_os = "windows")] 1070 | fn test_pager_warning() { 1071 | let testenv = TestEnv::new().install_default_cache(); 1072 | 1073 | // Regular call should not show a "pager flag not available on windows" warning 1074 | testenv 1075 | .command() 1076 | .args(["which"]) 1077 | .assert() 1078 | .success() 1079 | .stderr(contains("pager flag not available on Windows").not()); 1080 | 1081 | // But it should be shown if the pager flag is true 1082 | testenv 1083 | .command() 1084 | .args(["--pager", "which"]) 1085 | .assert() 1086 | .success() 1087 | .stderr(contains("pager flag not available on Windows")); 1088 | } 1089 | 1090 | /// Ensure that page lookup is case insensitive, so a page lookup for `eyed3` 1091 | /// and `eyeD3` should return the same page. 1092 | #[test] 1093 | fn test_lowercased_page_lookup() { 1094 | let testenv = TestEnv::new(); 1095 | 1096 | // Lookup `eyed3`, initially fails 1097 | testenv.command().args(["eyed3"]).assert().failure(); 1098 | 1099 | // Add entry 1100 | testenv.add_entry("eyed3", "contents"); 1101 | 1102 | // Lookup `eyed3` again 1103 | testenv.command().args(["eyed3"]).assert().success(); 1104 | 1105 | // Lookup `eyeD3`, should succeed as well 1106 | testenv.command().args(["eyeD3"]).assert().success(); 1107 | } 1108 | 1109 | /// Regression test for #219: It should be possible to combine `--raw` and `-f`. 1110 | #[test] 1111 | fn test_raw_render_file() { 1112 | let testenv = TestEnv::new().install_default_cache(); 1113 | 1114 | let path = testenv 1115 | .cache_dir() 1116 | .join(TLDR_PAGES_DIR) 1117 | .join("pages/common/inkscape-v1.md"); 1118 | let mut args = vec!["--color", "never", "-f", &path.to_str().unwrap()]; 1119 | 1120 | // Default render 1121 | testenv 1122 | .command() 1123 | .args(&args) 1124 | .assert() 1125 | .success() 1126 | .stdout(diff(include_str!( 1127 | "rendered/inkscape-default-no-color.expected" 1128 | ))); 1129 | 1130 | // Raw render 1131 | args.push("--raw"); 1132 | testenv 1133 | .command() 1134 | .args(&args) 1135 | .assert() 1136 | .success() 1137 | .stdout(diff(include_str!("cache/pages/common/inkscape-v1.md"))); 1138 | } 1139 | 1140 | fn touch_custom_page(testenv: &TestEnv) { 1141 | let args = vec!["--edit-page", "foo"]; 1142 | 1143 | testenv 1144 | .command() 1145 | .args(&args) 1146 | .env("EDITOR", "touch") 1147 | .assert() 1148 | .success(); 1149 | assert!(testenv.custom_pages_dir().join("foo.page.md").exists()); 1150 | } 1151 | 1152 | fn touch_custom_patch(testenv: &TestEnv) { 1153 | let args = vec!["--edit-patch", "foo"]; 1154 | 1155 | testenv 1156 | .command() 1157 | .args(&args) 1158 | .env("EDITOR", "touch") 1159 | .assert() 1160 | .success(); 1161 | assert!(testenv.custom_pages_dir().join("foo.patch.md").exists()); 1162 | } 1163 | 1164 | #[test] 1165 | fn test_edit_page() { 1166 | let testenv = TestEnv::new().write_custom_pages_config(); 1167 | touch_custom_page(&testenv); 1168 | } 1169 | 1170 | #[test] 1171 | fn test_edit_patch() { 1172 | let testenv = TestEnv::new().write_custom_pages_config(); 1173 | touch_custom_patch(&testenv); 1174 | } 1175 | 1176 | #[test] 1177 | fn test_recreate_dir() { 1178 | let testenv = TestEnv::new().write_custom_pages_config(); 1179 | touch_custom_patch(&testenv); 1180 | touch_custom_page(&testenv); 1181 | } 1182 | 1183 | #[test] 1184 | fn test_custom_pages_dir_is_not_dir() { 1185 | let testenv = TestEnv::new().write_custom_pages_config(); 1186 | let _ = std::fs::remove_dir_all(testenv.custom_pages_dir()); 1187 | let _ = File::create(testenv.custom_pages_dir()).unwrap(); 1188 | assert!(testenv.custom_pages_dir().is_file()); 1189 | 1190 | let args = vec!["--edit-patch", "foo"]; 1191 | 1192 | testenv 1193 | .command() 1194 | .args(&args) 1195 | .env("EDITOR", "touch") 1196 | .assert() 1197 | .failure(); 1198 | } 1199 | -------------------------------------------------------------------------------- /tests/rendered/apt.ja.expected: -------------------------------------------------------------------------------- 1 | 2 | Debian系ディストリビューションで使われるパッケージ管理システムです。 3 | Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。 4 | 詳しくはこちら: 5 | 6 | 利用可能なパーケージとバージョンのリストの更新(他の`apt`コマンドの前での実行を推奨): 7 | 8 |  sudo apt update 9 | 10 | 指定されたパッケージの検索: 11 | 12 |  apt search パッケージ 13 | 14 | パッケージの情報を出力: 15 | 16 |  apt show パッケージ 17 | 18 | パッケージのインストール、または利用可能な最新バージョンに更新: 19 | 20 |  sudo apt install パッケージ 21 | 22 | パッケージの削除(`sudo apt remove --purge`の場合設定ファイルも削除): 23 | 24 |  sudo apt remove パッケージ 25 | 26 | インストールされている全てのパッケージを最新のバージョンにアップグレード: 27 | 28 |  sudo apt upgrade 29 | 30 | インストールできるすべてのパッケージを表示: 31 | 32 |  apt list 33 | 34 | インストールされた全てのパッケージを表示(依存関係も表示): 35 | 36 |  apt list --installed 37 | 38 | -------------------------------------------------------------------------------- /tests/rendered/inkscape-default-no-color.expected: -------------------------------------------------------------------------------- 1 | 2 | An SVG (Scalable Vector Graphics) editing program. 3 | Use -z to not open the GUI and only process files in the console. 4 | 5 | Open an SVG file in the Inkscape GUI: 6 | 7 | inkscape filename.svg 8 | 9 | Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 10 | 11 | inkscape filename.svg -e filename.png 12 | 13 | Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 14 | 15 | inkscape filename.svg -e filename.png -w 600 -h 400 16 | 17 | Export a single object, given its ID, into a bitmap: 18 | 19 | inkscape filename.svg -i id -e object.png 20 | 21 | Export an SVG document to PDF, converting all texts to paths: 22 | 23 | inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path 24 | 25 | Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 26 | 27 | inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit 28 | 29 | Some invalid command just to test the correct highlighting of the command name: 30 | 31 | inkscape --use-inkscape=v3.0 file 32 | 33 | -------------------------------------------------------------------------------- /tests/rendered/inkscape-default.expected: -------------------------------------------------------------------------------- 1 | 2 | An SVG (Scalable Vector Graphics) editing program. 3 | Use -z to not open the GUI and only process files in the console. 4 | 5 | Open an SVG file in the Inkscape GUI: 6 | 7 |  inkscape filename.svg 8 | 9 | Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 10 | 11 |  inkscape filename.svg -e filename.png 12 | 13 | Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 14 | 15 |  inkscape filename.svg -e filename.png -w 600 -h 400 16 | 17 | Export a single object, given its ID, into a bitmap: 18 | 19 |  inkscape filename.svg -i id -e object.png 20 | 21 | Export an SVG document to PDF, converting all texts to paths: 22 | 23 |  inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path 24 | 25 | Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 26 | 27 |  inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit 28 | 29 | Some invalid command just to test the correct highlighting of the command name: 30 | 31 |  inkscape --use-inkscape=v3.0 file 32 | 33 | -------------------------------------------------------------------------------- /tests/rendered/inkscape-patched-no-color.expected: -------------------------------------------------------------------------------- 1 | 2 | An SVG (Scalable Vector Graphics) editing program. 3 | Use -z to not open the GUI and only process files in the console. 4 | 5 | Open an SVG file in the Inkscape GUI: 6 | 7 | inkscape filename.svg 8 | 9 | Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 10 | 11 | inkscape filename.svg -e filename.png 12 | 13 | Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 14 | 15 | inkscape filename.svg -e filename.png -w 600 -h 400 16 | 17 | Export a single object, given its ID, into a bitmap: 18 | 19 | inkscape filename.svg -i id -e object.png 20 | 21 | Export an SVG document to PDF, converting all texts to paths: 22 | 23 | inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path 24 | 25 | Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 26 | 27 | inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit 28 | 29 | Some invalid command just to test the correct highlighting of the command name: 30 | 31 | inkscape --use-inkscape=v3.0 file 32 | 33 | Custom inkscape entry 34 | 35 | My Inkscape example 36 | 37 | -------------------------------------------------------------------------------- /tests/rendered/inkscape-with-config.expected: -------------------------------------------------------------------------------- 1 | 2 | An SVG (Scalable Vector Graphics) editing program. 3 | Use -z to not open the GUI and only process files in the console. 4 | 5 | Open an SVG file in the Inkscape GUI: 6 | 7 | inkscape filename.svg 8 | 9 | Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI): 10 | 11 | inkscape filename.svg -e filename.png 12 | 13 | Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur): 14 | 15 | inkscape filename.svg -e filename.png -w 600 -h 400 16 | 17 | Export a single object, given its ID, into a bitmap: 18 | 19 | inkscape filename.svg -i id -e object.png 20 | 21 | Export an SVG document to PDF, converting all texts to paths: 22 | 23 | inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path 24 | 25 | Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape: 26 | 27 | inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit 28 | 29 | Some invalid command just to test the correct highlighting of the command name: 30 | 31 | inkscape --use-inkscape=v3.0 file 32 | 33 | -------------------------------------------------------------------------------- /tests/style-config.toml: -------------------------------------------------------------------------------- 1 | [style.highlight] 2 | foreground = "green" 3 | underline = false 4 | bold = false 5 | 6 | [style.command_name] 7 | bold = true 8 | 9 | [style.description] 10 | underline = false 11 | bold = false 12 | 13 | [style.example_text] 14 | foreground = "black" 15 | background = "blue" 16 | underline = false 17 | 18 | [style.example_variable] 19 | underline = true 20 | bold = false 21 | italic = true 22 | --------------------------------------------------------------------------------