Compare commits

..

No commits in common. "main" and "v1.7.2" have entirely different histories.

45 changed files with 2264 additions and 3654 deletions

View file

@ -15,54 +15,37 @@ jobs:
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
toolchain: [stable, 1.87.0] # MSRV
include:
- platform: windows-latest
exe_suffix: .exe
toolchain: [stable, 1.75.0]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
- run: mkdir artifacts
- name: Build with default features
run: |
cargo build
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-default${{ matrix.exe_suffix}}
- name: Build with logging and Rustls with webpki roots
run: |
cargo build --features logging,rustls-with-webpki-roots --no-default-features
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-logging-rustls-webpki${{ matrix.exe_suffix}}
- name: Build with native TLS backend
run: |
# expects runners have the proper Native SSL library
cargo build --features native-tls --no-default-features
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-native-tls${{ matrix.exe_suffix}}
- uses: actions/upload-artifact@v7
with:
name: tldr-debug-build-${{ matrix.platform }}-rust-${{ matrix.toolchain }}
path: artifacts/
run: cargo build
- name: Build with logging and webpki roots
run: cargo build --features logging,webpki-roots --no-default-features
- name: Run tests
run: cargo test -- --test-threads 1
run: cargo test
clippy:
name: run clippy lints
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy
- name: run clippy lints
run: cargo clippy --all-targets --features logging
run: cargo clippy --features logging
fmt:
name: run rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
@ -74,7 +57,7 @@ jobs:
name: build docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v2
with:

View file

@ -3,13 +3,12 @@ on:
push:
tags:
- "v[1-9]*" # push events matching `v` followed by anything larger than 0, e.g. v1.0, v20.15.10
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v2

View file

@ -8,7 +8,7 @@ jobs:
create-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Create release for tag
if: startsWith(github.ref, 'refs/tags/')
run: |
@ -24,7 +24,7 @@ jobs:
matrix:
target: ["bash", "fish", "zsh"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Upload completion
if: startsWith(github.ref, 'refs/tags/')
run: |
@ -40,7 +40,7 @@ jobs:
matrix:
target: ["MIT", "APACHE"]
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Upload license
if: startsWith(github.ref, 'refs/tags/')
run: |
@ -66,14 +66,14 @@ jobs:
- arch: "arm"
libc: "musleabihf"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Pull Docker image
run: docker pull messense/rust-musl-cross:${{ matrix.arch }}-${{ matrix.libc }}
- name: Build in Docker
run: docker run --rm -i -v "$(pwd)":/home/rust/src messense/rust-musl-cross:${{ matrix.arch }}-${{ matrix.libc }} cargo build --release
- name: Strip binary
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
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
with:
name: "tealdeer-linux-${{ matrix.arch }}-${{ matrix.libc }}"
path: "target/${{ matrix.arch }}-unknown-linux-${{ matrix.libc }}/release/tldr"
@ -86,15 +86,15 @@ jobs:
- arch: "x86_64"
- arch: "aarch64"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Setup toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: "${{ matrix.arch }}-apple-darwin"
- name: Build
run: cargo build --release --target ${{ matrix.arch }}-apple-darwin
- uses: actions/upload-artifact@v7
run: cargo build --release --target ${{ matrix.arch }}-apple-darwin --no-default-features --features webpki-roots
- uses: actions/upload-artifact@v4
with:
name: "tealdeer-macos-${{ matrix.arch }}"
path: "target/${{ matrix.arch }}-apple-darwin/release/tldr"
@ -102,14 +102,14 @@ jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Setup toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Build
run: cargo build --release --target x86_64-pc-windows-msvc
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
with:
name: "tealdeer-windows-x86_64-msvc"
path: "target/x86_64-pc-windows-msvc/release/tldr.exe"
@ -134,8 +134,8 @@ jobs:
- macos-aarch64
- windows-x86_64-msvc
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Upload binary
if: startsWith(github.ref, 'refs/tags/')
run: |

View file

@ -13,136 +13,6 @@ Possible log types:
- `[docs]` for documentation changes.
- `[chore]` for maintenance work.
### [v1.5.1][v1.5.1], [v1.6.2][v1.6.2], [v1.7.3][v1.7.3] (2026-01-25)
Today I am releasing three patch updates for outdated versions of tealdeer.
They are minimal patches for Linux distributions that ship old versions of
tealdeer which recently broke due to an upstream change. If you can choose
freely which version of tealdeer to use, I recommend using the latest version of
tealdeer, 1.8.1. For more details, see the "Notes to package maintainers"
section below.
All three updates contain only a single change compared to their respective
previous versions which changes the `ARCHIVE_URL` constant used for updating the
page cache. The reason for this change is that the upstream tldr-pages
repository shut down the domain that clients were previously required to use.
Note that this issue is already fixed in tealdeer 1.8.0 where we introduced a
config file option for changing the URL used at runtime. The versions 1.8.0 and
1.8.1 also use the new domain of the tldr-pages archive by default, so no action
is needed for users of those versions.
#### Changes
- [fixed] Update `ARCHIVE_URL`
#### Notes to package maintainers
I have _not_ updated the lockfile for any of these releases, so the locked
dependency versions are still the same as they were for the previous release in
the respective v1.x series. Updating the lockfile for tealdeer 1.5.0 to remove
any `cargo audit` warnings while also maintaining compatibility with Rust 1.54
also brings larger changes through transitive dependencies, which contradicts my
plan to make this update easy to plug into existing build pipelines.
If you want to build / distribute tealdeer v1.5.1, v1.6.2, or v1.7.3, please use
an up to date Rust toolchain to permit updates to newer versions of (transitive)
dependencies. Do not use the lockfile, instead update to the newest available
dependency versions.
For the same reason, there are no artifacts attached to the GitHub releases of
these versions.
### [v1.8.1][v1.8.1] (2025-11-11)
This patch release tweaks the enabled features for ureq, the library we use to
perform HTTP requests when updating the cache. In particular, support for socks
proxies is now enabled.
#### Changes:
- [added] Enable ureq's socks-proxy feature ([#451])
### [v1.8.0][v1.8.0] (2025-10-03)
One year and one day have passed since tealdeer version 1.7.0 was released, so
it's time for an update! Tealdeer 1.8 comes with a complete rewrite of the page
cache and contains many long awaited improvements around it.
Firstly, tealdeer now supports language-specific downloads. This means that only
the pages matching the configured languages are downloaded when updating the
cache. The languages used for searching pages can be configured separately to
the ones used for updating, so it is possible to download pages in languages
that are not usually queried.
Next to configuring which languages are used for searching, it is now also
possible to specify which platforms are used in the config file. Importantly,
the default behavior for page search has changed so that all platforms are
searched if no page is found for the platform that tealdeer is running on. To
restore the behavior of tealdeer 1.7, users should set
```toml
[search]
platforms = ["current", "common"]
```
in their config file.
Coming back to updating, the default build configuration of tealdeer now
includes multiple TLS backends. This means that tealdeer does not have to be
rebuilt to try out a different TLS backend. The used backend can be chosen in
the config file. By default, tealdeer comes with support for rustls using webpki
certificates or system certificates. Native TLS is supported, but not enabled by
default to avoid build troubles with OpenSSL and musl.
For details, please refer to the [user documentation].
#### Changes:
- [added] Resolve paths in config `[directories]` relative to the config directory ([#306])
- [added] Add `common` platform to CLI ([#401])
- [added] Add configuration option for `archive_source` ([#337])
- [added] Allows configuring TLS backend ([#386])
- [added] Add args: `--edit-page` and `--edit-patch` ([#388])
- [added] Add an option to specify a custom config file to be used ([#422])
- [added] Upload binaries from build step as artifact ([#423])
- [added] Add `search.languages` and `updates.download_languages` settings ([#430])
- [added] Add `search.platforms` config option and search all platforms by default ([#435])
- [added] Add `display.show_title` option to display command titles in output ([#439])
- [chore] Various test improvements ([#399])
- [chore] Add tests for osx/macos alias ([#407])
- [chore] Move most of `main` to `try_main` ([#400])
- [chore] Only create a single temporary directory in integration tests ([#411])
- [chore] Replace reqwest with ureq ([#417])
- [chore] Introduce Language struct ([#425])
- [chore] Cache rewrite ([#416])
- [chore] Allow references in `Config` ([#429])
- [docs] Highlight code examples in user docs ([#440])
- [removed] Remove native-tls from default feature set ([#436])
#### Contributors to this version:
- [Christoph Loy][@beatbrot]
- [Erick Guan][@erickguan]
- [@MHS-0][@MHS-0]
- [Matěj Kafka][@MatejKafka]
- [Nachiket Kanore][@nachiketkanore]
- [Niklas Mohrin][@niklasmohrin]
- [Predrag Minic][@mipedja]
- [@hex1c][@hex1c]
- [lyj][@lengyijun]
Thanks!
#### Notes to package maintainers
1. The MSRV has been bumped to 1.85.
2. Consider whether you want to include the `native-tls` feature in your build
of tealdeer. The feature is disabled for the binaries in the GitHub release
because we target musl, but it might work out of the box for your
distribution.
3. We have added the `ignore-online-tests` feature to automatically mark all
tests that require an internet connection as skipped, so you can use this
feature instead of maintaining a list of these tests yourself.
### [v1.7.2][v1.7.2] (2025-03-18)
This patch release updates the `zip` dependency to mitigate a potential security
@ -164,11 +34,11 @@ This patch release updates the `yansi` dependency to version 1, so that the
previous versions of `yansi` can be removed from the package sets of Linux
distributions. This change should not impact the behavior of tealdeer.
#### Changes:
Changes:
- [chore] Upgrade yansi: 0.5.1 -> 1.0.1 ([#389])
#### Contributors to this version:
Contributors to this version:
- [Blair Noctis][@nc7s]
@ -204,7 +74,7 @@ On a personal note, this will be the last release from me
([Danilo](https://github.com/dbrgn/)) as primary maintainer of tealdeer. For
details, see [#376](https://github.com/tealdeer-rs/tealdeer/issues/376).
#### Changes:
Changes:
- [added] Allow querying multiple platforms ([#300])
- [added] Add BSD platform support ([#354])
@ -224,7 +94,7 @@ details, see [#376](https://github.com/tealdeer-rs/tealdeer/issues/376).
- [chore] Update Cargo.toml license field following SPDX 2.1 ([#336])
- [chore] Dependency updates
#### Contributors to this version:
Contributors to this version:
- [Adam Henley][@adamazing]
- [Andrea Frigido][@frisoft]
@ -248,12 +118,12 @@ Thanks!
### [v1.6.1][v1.6.1] (2022-10-24)
#### Changes:
Changes:
- [fixed] Fix path source for custom pages dir ([#297])
- [chore] Update dependendencies ([#299])
#### Contributors to this version:
Contributors to this version:
- [Cyrus Yip][@CyrusYip]
- [Danilo Bargen][@dbrgn]
@ -272,7 +142,7 @@ The `TEALDEER_CACHE_DIR` env variable is now deprecated.
A note to packagers: Shell completions have been moved to the `completion/`
subdirectory! Packaging scripts might need to be updated.
#### Changes:
Changes:
- [added] Allow overriding cache directory through config ([#276])
- [added] Add `--no-auto-update` CLI flag ([#257])
@ -293,7 +163,7 @@ subdirectory! Packaging scripts might need to be updated.
- [chore] Use anyhow for error handling ([#249])
- [chore] Switch to Rust 2021 edition ([#284])
#### Contributors to this version:
Contributors to this version:
- [@bagohart][@bagohart]
- [@cyqsimon][@cyqsimon]
@ -342,7 +212,7 @@ Note that the MSRV (Minimal Supported Rust Version) of the project
> When publishing a tealdeer release, the Rust version required to build it
> should be stable for at least a month.
#### Changes:
Changes:
- [added] Support custom pages and patches ([#142][i142])
- [added] Multi-language support ([#125][i125], [#161][i161])
@ -373,7 +243,7 @@ Note that the MSRV (Minimal Supported Rust Version) of the project
- [chore] All release binaries are now generated in CI. Binaries for macOS and Windows are also provided. ([#240][i240])
- [chore] Update all dependencies
#### Contributors to this version:
Contributors to this version:
- [@bl-ue][@bl-ue]
- [Cameron Tod][@cam8001]
@ -402,7 +272,7 @@ co-maintainer. Thank you for your help!
- [fixed] Syntax error in zsh completion file ([#138][i138])
#### Contributors to this version:
Contributors to this version:
- [Danilo Bargen][@dbrgn]
- [Bruno A. Muciño][@mucinoab]
@ -419,7 +289,7 @@ Thanks!
- [changed] Make `--list` option comply with official spec ([#112][i112])
- [changed] Move cache age warning to stderr ([#113][i113])
#### Contributors to this version:
Contributors to this version:
- [Atul Bhosale][@Atul9]
- [Danilo Bargen][@dbrgn]
@ -445,7 +315,7 @@ Thanks!
- [fixed] Fix Fish autocompletion on macOS ([#87][i87])
- [fixed] Fix compilation on Windows by disabling pager ([#99][i99])
#### Contributors to this version:
Contributors to this version:
- [Bruno Heridet][@Delapouite]
- [Danilo Bargen][@dbrgn]
@ -471,7 +341,7 @@ Thanks!
- [changed] Move to Rust 2018, require Rust 1.32 ([#69][i69] / [#84][i84])
- [fixed] Add (back) support for proxies ([#68][i68])
#### Contributors to this version:
Contributors to this version:
- [Bar Hatsor][@Bassets]
- [Danilo Bargen][@dbrgn]
@ -494,7 +364,7 @@ Thanks!
- [changed] Require at least Rust 1.28 to build (previous: 1.19)
- [fixed] Fix building on systems with openssl 1.1.1 ([#47][i47])
#### Contributors to this version:
Contributors to this version:
- [Danilo Bargen][@dbrgn]
- [@equal-l2][@equal-l2]
@ -527,7 +397,7 @@ Thanks!
- First crates.io release
[user documentation]: https://tealdeer-rs.github.io/tealdeer/
[@0ndorio]: https://github.com/0ndorio
[@adamazing]: https://github.com/adamazing
@ -590,14 +460,6 @@ Thanks!
[@Walker-00]: https://github.com/Walker-00
[@YDX-2147483647]: https://github.com/YDX-2147483647
[@zedseven]: https://github.com/zedseven
[@beatbrot]: https://github.com/beatbrot
[@erickguan]: https://github.com/erickguan
[@MHS-0]: https://github.com/MHS-0
[@MatejKafka]: https://github.com/MatejKafka
[@nachiketkanore]: https://github.com/nachiketkanore
[@mipedja]: https://github.com/mipedja
[@hex1c]: https://github.com/hex1c
[@lengyijun]: https://github.com/lengyijun
[v1.0.0]: https://github.com/tealdeer-rs/tealdeer/compare/v0.4.0...v1.0.0
[v1.1.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.0.0...v1.1.0
@ -606,16 +468,11 @@ Thanks!
[v1.4.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.3.0...v1.4.0
[v1.4.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.4.0...v1.4.1
[v1.5.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.4.1...v1.5.0
[v1.5.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.5.0...v1.5.1
[v1.6.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.5.0...v1.6.0
[v1.6.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.6.0...v1.6.1
[v1.6.2]: https://github.com/tealdeer-rs/tealdeer/compare/v1.6.1...v1.6.2
[v1.7.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.6.1...v1.7.0
[v1.7.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.0...v1.7.1
[v1.7.2]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.1...v1.7.2
[v1.7.3]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.2...v1.7.3
[v1.8.0]: https://github.com/tealdeer-rs/tealdeer/compare/v1.7.2...v1.8.0
[v1.8.1]: https://github.com/tealdeer-rs/tealdeer/compare/v1.8.0...v1.8.1
[i34]: https://github.com/tealdeer-rs/tealdeer/issues/34
[i43]: https://github.com/tealdeer-rs/tealdeer/issues/43
@ -687,7 +544,6 @@ Thanks!
[#300]: https://github.com/tealdeer-rs/tealdeer/pull/300
[#303]: https://github.com/tealdeer-rs/tealdeer/pull/303
[#305]: https://github.com/tealdeer-rs/tealdeer/pull/305
[#306]: https://github.com/tealdeer-rs/tealdeer/pull/306
[#314]: https://github.com/tealdeer-rs/tealdeer/pull/314
[#315]: https://github.com/tealdeer-rs/tealdeer/pull/315
[#322]: https://github.com/tealdeer-rs/tealdeer/pull/322
@ -696,29 +552,8 @@ Thanks!
[#331]: https://github.com/tealdeer-rs/tealdeer/pull/331
[#333]: https://github.com/tealdeer-rs/tealdeer/pull/333
[#336]: https://github.com/tealdeer-rs/tealdeer/pull/336
[#337]: https://github.com/tealdeer-rs/tealdeer/pull/337
[#342]: https://github.com/tealdeer-rs/tealdeer/pull/342
[#354]: https://github.com/tealdeer-rs/tealdeer/pull/354
[#355]: https://github.com/tealdeer-rs/tealdeer/pull/355
[#362]: https://github.com/tealdeer-rs/tealdeer/pull/362
[#386]: https://github.com/tealdeer-rs/tealdeer/pull/386
[#388]: https://github.com/tealdeer-rs/tealdeer/pull/388
[#389]: https://github.com/tealdeer-rs/tealdeer/pull/389
[#399]: https://github.com/tealdeer-rs/tealdeer/pull/399
[#400]: https://github.com/tealdeer-rs/tealdeer/pull/400
[#401]: https://github.com/tealdeer-rs/tealdeer/pull/401
[#407]: https://github.com/tealdeer-rs/tealdeer/pull/407
[#411]: https://github.com/tealdeer-rs/tealdeer/pull/411
[#416]: https://github.com/tealdeer-rs/tealdeer/pull/416
[#417]: https://github.com/tealdeer-rs/tealdeer/pull/417
[#422]: https://github.com/tealdeer-rs/tealdeer/pull/422
[#423]: https://github.com/tealdeer-rs/tealdeer/pull/423
[#425]: https://github.com/tealdeer-rs/tealdeer/pull/425
[#426]: https://github.com/tealdeer-rs/tealdeer/pull/426
[#429]: https://github.com/tealdeer-rs/tealdeer/pull/429
[#430]: https://github.com/tealdeer-rs/tealdeer/pull/430
[#435]: https://github.com/tealdeer-rs/tealdeer/pull/435
[#436]: https://github.com/tealdeer-rs/tealdeer/pull/436
[#439]: https://github.com/tealdeer-rs/tealdeer/pull/439
[#440]: https://github.com/tealdeer-rs/tealdeer/pull/440
[#451]: https://github.com/tealdeer-rs/tealdeer/pull/451

1469
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -10,9 +10,9 @@ name = "tealdeer"
readme = "README.md"
repository = "https://github.com/tealdeer-rs/tealdeer/"
documentation = "https://tealdeer-rs.github.io/tealdeer/"
version = "1.8.1"
version = "1.7.2"
include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"]
rust-version = "1.87" # MSRV
rust-version = "1.75"
edition = "2021"
[[bin]]
@ -21,16 +21,17 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
app_dirs = { version = "2", package = "app_dirs2" }
clap = { version = "4", features = ["std", "derive", "help", "usage", "cargo", "error-context", "color", "wrap_help"], default-features = false }
env_logger = { version = "0.11", optional = true }
etcetera = "0.11.0"
log = "0.4"
reqwest = { version = "0.12.5", features = ["blocking"], default-features = false }
serde = "1.0.21"
serde_derive = "1.0.21"
ureq = { version = "3.0.8", default-features = false, features = ["gzip", "socks-proxy"] }
toml = "0.8.19"
walkdir = "2.0.1"
yansi = "1"
zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
zip = { version = "2.3.0", default-features = false, features = ["deflate"] }
[target.'cfg(not(windows))'.dependencies]
pager = "0.16"
@ -43,16 +44,21 @@ tempfile = "3.1.0"
filetime = "0.2.10"
[features]
# native-tls is not enabled by default, because it is difficult to build for musl
default = ["rustls-with-webpki-roots", "rustls-with-native-roots"]
default = ["native-roots"]
logging = ["env_logger"]
# At least one of variants for `ureq` HTTP client must be selected.
native-tls = ["ureq/native-tls", "ureq/platform-verifier"]
rustls-with-webpki-roots = ["ureq/rustls"] # ureq uses WebPKI roots by default
rustls-with-native-roots = ["ureq/rustls", "ureq/platform-verifier"]
ignore-online-tests = []
# Reqwest (the HTTP client library) can handle TLS connections in three
# different modes:
#
# - Rustls with native roots
# - Rustls with WebPK roots
# - Native TLS (SChannel on Windows, Secure Transport on macOS and OpenSSL otherwise)
#
# Exactly one of the three variants must be selected. By default, Rustls with
# native roots is enabled.
native-roots = ["reqwest/rustls-tls-native-roots"]
webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
native-tls = ["reqwest/native-tls"]
[profile.release]
strip = true

View file

@ -52,6 +52,7 @@ Docker container using sharkdp's [`hyperfine`][hyperfine-gh]
| [`fast-tldr`][fast-tldr-gh] | Haskell | 17.0 | 0.6 | no example highlighting |
| [`tldr-hs`][hs-gh] | Haskell | 25.1 | 0.5 | no example highlighting |
| [`tldr-bash`][bash-gh] | Bash | 30.0 | 0.8 | |
| [`tldr-c`][c-gh] | C | 38.4 | 1.0 | |
| [`tldr-python-client`][python-gh] | Python | 87.0 | 2.4 | |
| [`tldr-node-client`][node-gh] | JavaScript / NodeJS | 407.1 | 12.9 | |
@ -86,17 +87,6 @@ To run lints:
$ cargo clean && cargo clippy
### AI Policy
Using AI is generally discouraged. However, if it is used as part of a contribution, the contributor MUST:
1. Clearly mark what parts (if any) of a contribution were created with the help of AI tools. This includes issue and pull request comments.
2. Check all output of AI tools before sharing it with others in the tealdeer project.
3. Not post slop, spam, or low quality contributions. This includes pull request descriptions and comments with excessive text and markdown flair.
4. Leave small or easy tasks to new contributors who want to learn without the use of AI. This is to maintain the presence of the `good-first-issue` tag.
5. Be respectful of everyone's time: *maintainers and other contributors will be reviewing your PRs.*
## MSRV (Minimally Supported Rust Version)
When publishing a tealdeer release, the Rust version required to build it
@ -123,6 +113,7 @@ Thanks to @severen for coming up with the name "tealdeer"!
[node-gh]: https://github.com/tldr-pages/tldr-node-client
[c-gh]: https://github.com/tldr-pages/tldr-c-client
[hs-gh]: https://github.com/psibi/tldr-hs
[fast-tldr-gh]: https://github.com/gutjuri/fast-tldr
[bash-gh]: https://4e4.win/tldr

View file

@ -8,7 +8,6 @@
- [Configuration](./config.md)
- [Section: \[display\]](./config_display.md)
- [Section: \[style\]](./config_style.md)
- [Section: \[search\]](./config_search.md)
- [Section: \[updates\]](./config_updates.md)
- [Section: \[directories\]](./config_directories.md)
- [Tips and Tricks](./tips_and_tricks.md)

View file

@ -9,15 +9,11 @@ The configuration file path follows OS conventions (e.g.
`$XDG_CONFIG_HOME/tealdeer/config.toml` on Linux). The paths can be queried
with the following command:
```shell
$ tldr --show-paths
```
$ tldr --show-paths
Creating the config file can be done manually or with the help of `tldr`:
```shell
$ tldr --seed-config
```
$ tldr --seed-config
On Linux, this will usually be `~/.config/tealdeer/config.toml`.
@ -26,14 +22,13 @@ On Linux, this will usually be `~/.config/tealdeer/config.toml`.
Here's an example configuration file. Note that this example does not contain
all possible config options. For details on the things that can be configured,
please refer to the subsections of this documentation page
([display](config_display.html), [style](config_style.html), [search](config_search.html),
([display](config_display.html), [style](config_style.html),
[updates](config_updates.html) or [directories](config_directories.html)).
```toml
[display]
compact = false
use_pager = true
show_title = false
[style.command_name]
foreground = "red"

View file

@ -8,10 +8,8 @@ Override the cache directory. Remember to use an absolute path. Variable
expansion will not be performed on the path. If the directory does not yet
exist, it will be created.
```toml
[directories]
cache_dir = "/home/myuser/.tealdeer-cache/"
```
[directories]
cache_dir = "/home/myuser/.tealdeer-cache/"
If no `cache_dir` is specified, tealdeer will fall back to a location that
follows OS conventions. On Linux, it will usually be at `~/.cache/tealdeer/`.
@ -23,7 +21,5 @@ Set the directory to be used to look up [custom
pages](usage_custom_pages.html). Remember to use an absolute path. Variable
expansion will not be performed on the path.
```toml
[directories]
custom_pages_dir = "/home/myuser/custom-tldr-pages/"
```
[directories]
custom_pages_dir = "/home/myuser/custom-tldr-pages/"

View file

@ -6,10 +6,8 @@ In the `display` section you can configure the output format.
Specifies whether the pager should be used by default or not (default `false`).
```toml
[display]
use_pager = true
```
[display]
use_pager = true
When enabled, `less -R` is used as pager. To override the pager command used,
set the `PAGER` environment variable.
@ -21,51 +19,5 @@ NOTE: This feature is not available on Windows.
Set this to enforce more compact output, where empty lines are stripped out
(default `false`).
```toml
[display]
compact = true
```
## `show_title`
Display the command name at the top of the page output (default `false`).
```toml
[display]
show_title = true
```
When enabled, the command name will be displayed at the top of the output,
styled with the `command_name` style configuration.
## `indent`
Controls the indentation of the output via two sub-keys.
### `indent.base`
Specifies the number of spaces used to indent descriptions, example text, and titles (default `2`).
```toml
[display.indent]
base = 2
```
### `indent.command`
Specifies the number of spaces used to indent example code lines (default `6`).
```toml
[display.indent]
command = 6
```
You can also configure both subkeys in a single line like this:
```toml
[display]
indent = {
base = 2,
command = 6,
}
```
[display]
compact = true

View file

@ -1,33 +0,0 @@
# Section: \[search\]
This config section is used to configure the page search in the cache.
The settings apply to `tldr <page>` and `tldr --list`.
## `languages`
The list of languages that should be considered when searching.
If unspecified, the list of languages will be inferred from the `LANG` and `LANGUAGE` environment variables.
Either way, the language used can be overwritten using the `--language` command line flag.
```toml
[search]
# Show pages in German if available, otherwise show in English
languages = ["de", "en"]
```
## `platforms`
The list of platforms that should be considered when searching.
In addition to the platforms listed in the help text of the `--platform` flag, there are two special platforms available:
- `"current"`: equals the platform that tealdeer was compiled for
- `"all"`: adds all remaining platforms to the list
Tealdeer searches the platforms in order of appearance in this list.
The default list of platforms is `["current", "common", "all"]`.
The list of platforms can be overwritten using the `--platform` command line flag.
```toml
[search]
# Search for linux and common, and then search windows before trying the remaining platforms
platforms = ["linux", "common", "windows", "all"]
```

View file

@ -26,22 +26,16 @@ Colors can be specified in one of three ways:
Example:
```toml
foreground = "green"
```
foreground = "green"
- 256 color ANSI code (*tealdeer v1.5.0+*)
Example:
```toml
foreground = { ansi = 4 }
```
foreground = { ansi = 4 }
- 24-bit RGB color (*tealdeer v1.5.0+*)
Example:
```toml
background = { rgb = { r = 255, g = 255, b = 255 } }
```
background = { rgb = { r = 255, g = 255, b = 255 } }

View file

@ -1,7 +1,5 @@
# Section: \[updates\]
This config section contains settings related to updating the tealdeer cache.
## Automatic updates
Tealdeer can refresh the cache automatically when it is outdated. This
@ -13,10 +11,8 @@ default.
Specifies whether the auto-update feature should be enabled (defaults to
`false`).
```toml
[updates]
auto_update = true
```
[updates]
auto_update = true
### `auto_update_interval_hours`
@ -24,68 +20,7 @@ Duration, since the last cache update, after which the cache will be
refreshed (defaults to 720 hours). This parameter is ignored if `auto_update`
is set to `false`.
```toml
[updates]
auto_update = true
auto_update_interval_hours = 24
```
[updates]
auto_update = true
auto_update_interval_hours = 24
### `warn_cache_age`
Controls when a warning is printed if the cache has not been updated in a while.
By default, the warning is shown once the cache is older than 30 days. Set this
to `"never"` to silence the warning. This is useful if, for some reason, the
modification time does not reflect its actual age.
```toml
[updates]
warn_cache_age = "never"
```
## Download configuration
### `download_languages`
The list of languages which should be downloaded when updating.
If unspecified, the languages listed in the `search.languages` setting are used.
Thus, this setting is the most useful to instruct tealdeer to download pages in additional languages that are not searched by default.
Either way, the language used can be overwritten using the `--language` command line flag.
```toml
[search]
languages = ["de", "en"]
[updates]
# sometimes I like to read the Italian description
download_languages = ["de", "en", "it"]
```
### `archive_source`
URL for the location of the tldr pages archive. By default the pages are
fetched from the latest `tldr-pages/tldr` GitHub release.
```toml
[updates]
archive_source = "https://my-company.example.com/tldr/"
```
### `tls_backend`
Specifies which TLS backend to use. Try changing this setting if you encounter certificate errors.
Available options:
- `rustls-with-native-roots` - [Rustls][rustls] (a TLS library in Rust) with native roots
- `rustls-with-webpki-roots` - Rustls with [WebPKI][rustls-webpki] roots
- `native-tls` - Native TLS
- SChannel on Windows
- Secure Transport on macOS
- OpenSSL on other platforms
```toml
[updates]
tls_backend = "native-tls"
```
[rustls]: https://github.com/rustls/rustls
[rustls-webpki]: https://github.com/rustls/webpki

View file

@ -38,29 +38,21 @@ Simply download the binary for your platform and run it!
Build and install the tool via cargo...
```shell
$ cargo install tealdeer
```
$ cargo install tealdeer
## Build From Source
Release build:
```shell
$ cargo build --release
```
$ cargo build --release
Release build with native TLS support:
Release build with bundled CA roots:
```shell
$ cargo build --release --features native-tls
```
$ cargo build --release --no-default-features --features webpki-roots
Debug build with logging support:
```shell
$ cargo build --features logging
```
$ cargo build --features logging
(To enable logging at runtime, export the `RUST_LOG=tldr=debug` env variable.)

View file

@ -1,4 +1,4 @@
tealdeer 1.8.1: A fast TLDR client
tealdeer 1.7.2: A fast TLDR client
Danilo Bargen <mail@dbrgn.ch>, Niklas Mohrin <dev@niklasmohrin.de>
Usage: tldr [OPTIONS] [COMMAND]...
@ -8,17 +8,14 @@ Arguments:
Options:
-l, --list List all commands in the cache
--edit-page Edit custom page with `EDITOR`
--edit-patch Edit custom patch with `EDITOR`
-f, --render <FILE> Render a specific markdown file
-p, --platform <PLATFORM> Override the operating system, can be specified multiple times in order
of preference [possible values: linux, macos, sunos, windows, android,
freebsd, netbsd, openbsd, common]
freebsd, netbsd, openbsd]
-L, --language <LANGUAGE> Override the language
-u, --update Update the local cache
--no-auto-update If auto update is configured, disable it for this run
-c, --clear-cache Clear the local cache
--config-path <FILE> Override config file location
--pager Use a pager to page output
-r, --raw Display the raw markdown instead of rendering it
-q, --quiet Suppress informational messages
@ -29,5 +26,3 @@ Options:
-h, --help Print help
To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/.
To view usage examples, run tldr tldr or tldr tealdeer.

View file

@ -28,15 +28,11 @@ your custom page will be shown instead of the upstream version in the cache.
Path:
```plain
$CUSTOM_PAGES_DIR/<command>.page.md
```
$CUSTOM_PAGES_DIR/<command>.page.md
Example:
```plain
~/.local/share/tealdeer/pages/ufw.page.md
```
~/.local/share/tealdeer/pages/ufw.page.md
## Custom Patches
@ -47,12 +43,8 @@ pages.
Path:
```plain
$CUSTOM_PAGES_DIR/<command>.patch.md
```
$CUSTOM_PAGES_DIR/<command>.patch.md
Example:
```plain
~/.local/share/tealdeer/pages/ufw.patch.md
```
~/.local/share/tealdeer/pages/ufw.patch.md

View file

@ -1,42 +0,0 @@
# tldr
> This is a builtin page that shows information for your installed tealdeer version.
> More information: <https://tealdeer-rs.github.io/tealdeer/>.
> This page shows tealdeer specific functionality. See tldr tldr for more examples.
- Render a local markdown file as a tldr page:
`tldr --render {{path/to/file.md}}`
- Show the raw markdown source of a page instead of rendering it:
`tldr --raw {{command}}`
- Show file and directory paths used by tealdeer:
`tldr --show-paths`
- Create an initial config file:
`tldr --seed-config`
- Override config file location:
`tldr --config-path <FILE>`
- Open a custom page for a command in `$EDITOR` (creates it if it doesn't exist):
`tldr --edit-page {{command}}`
- Open a custom patch for a command in `$EDITOR` (appended to the existing page):
`tldr --edit-patch {{command}}`
- Clear the local cache:
`tldr --clear-cache`
- If auto update is configured, disable it for this run:
`tldr --no-auto-update`

View file

@ -1,39 +1,27 @@
use std::{
env,
ffi::OsStr,
fs::{self, File},
io::{Cursor, ErrorKind, Read},
io::{BufReader, Cursor, Read},
path::{Path, PathBuf},
time::{Duration, SystemTime},
};
use anyhow::{anyhow, bail, ensure, Context, Result};
use log::{debug, info};
use ureq::{
http::StatusCode,
tls::{RootCerts, TlsConfig, TlsProvider},
Agent,
};
use anyhow::{ensure, Context, Result};
use log::debug;
use reqwest::{blocking::Client, Proxy};
use walkdir::{DirEntry, WalkDir};
use zip::ZipArchive;
use crate::{
config::{Language, TlsBackend},
types::PlatformType,
};
use crate::{types::PlatformType, utils::print_warning};
pub static TLDR_PAGES_DIR: &str = "tldr-pages";
pub static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
static TLDR_OLD_PAGES_DIR: &str = "tldr-master";
#[derive(Clone)]
pub struct CacheConfig<'a> {
pub pages_directory: &'a Path,
pub custom_pages_directory: Option<&'a Path>,
pub platforms: &'a [PlatformType],
pub search_languages: &'a [Language<'a>],
pub download_languages: &'a [Language<'a>],
}
/// The directory backing this cache is checked to be populated at construction.
pub struct Cache<'a> {
config: CacheConfig<'a>,
#[derive(Debug)]
pub struct Cache {
cache_dir: PathBuf,
enable_styles: bool,
}
#[derive(Debug)]
@ -42,228 +30,6 @@ pub struct PageLookupResult {
pub patch_path: Option<PathBuf>,
}
impl<'a> Cache<'a> {
/// Try opening a cache at the location given by `config.pages_directory`. If no directory
/// exists at this location, `Ok(None)` is returned.
pub fn open(config: CacheConfig<'a>) -> Result<Option<Self>> {
match config.pages_directory.metadata() {
Ok(md) => {
ensure!(
md.is_dir(),
"Cache directory `{}` exists, but is not a directory.",
config.pages_directory.display(),
);
Ok(Some(Cache { config }))
}
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(anyhow!(err).context(format!(
"Error getting metdata of cache directory {}",
config.pages_directory.display()
))),
}
}
/// Open an existing cache at `config.pages_directory` or create one if no cache resides at
/// this location. In case of success, the return value is a tuple with the `Cache` and a
/// boolean indicating whether the cache was newly created.
pub fn open_or_create(config: CacheConfig<'a>) -> Result<(Self, bool)> {
if let Some(cache) = Self::open(config.clone())? {
return Ok((cache, false));
}
fs::create_dir_all(config.pages_directory).with_context(|| {
format!(
"Cache directory `{}` cannot be created",
config.pages_directory.display(),
)
})?;
eprintln!(
"Successfully created cache directory `{}`.",
config.pages_directory.display(),
);
Ok((Cache { config }, true))
}
pub fn age(&self) -> Result<Duration> {
let mtime = self.config.pages_directory.metadata()?.modified()?;
SystemTime::now()
.duration_since(mtime)
.context("Error comparing cache mtime with current time")
}
pub fn find_page(&self, command: &str) -> Option<PageLookupResult> {
let page_filename = format!("{command}.md");
let patch_filename = format!("{command}.patch.md");
let custom_filename = format!("{command}.page.md");
if let Some(custom_pages_dir) = self.config.custom_pages_directory {
let custom_page = custom_pages_dir.join(custom_filename);
if custom_page.is_file() {
return Some(PageLookupResult::with_page(custom_page));
}
}
let patch_path = self
.config
.custom_pages_directory
.map(|dir| dir.join(&patch_filename))
.filter(|path| path.is_file());
for &platform in self.config.platforms {
for language in self.config.search_languages {
let mut search_path = self.config.pages_directory.to_path_buf();
search_path.push(language.directory_name());
search_path.push(platform.directory_name());
search_path.push(&page_filename);
if search_path.is_file() {
return Some(
PageLookupResult::with_page(search_path).with_optional_patch(patch_path),
);
}
}
}
None
}
pub fn list_pages(&self) -> Result<impl IntoIterator<Item = String>> {
let mut pages = Vec::new();
let mut append_all = |directory: &Path, suffix: &str| -> Result<()> {
let Ok(file_iter) = fs::read_dir(directory) else {
return Ok(());
};
for entry in file_iter {
let entry = entry?;
if entry.file_type()?.is_file() {
let mut page_path = entry
.file_name()
.into_string()
.map_err(|_| anyhow!("Found invalid filename: {:?}", entry.path()))?;
if page_path.ends_with(suffix) {
page_path.truncate(page_path.len() - suffix.len());
pages.push(page_path);
} else {
debug!(
"Skipping page entry not ending in \".md\": {:?}",
entry.path(),
);
}
}
}
Ok(())
};
let mut search_path = self.config.pages_directory.to_path_buf();
for language in self.config.search_languages {
search_path.push(language.directory_name());
for platform in self.config.platforms {
search_path.push(platform.directory_name());
append_all(&search_path, ".md")?;
search_path.pop();
}
search_path.pop();
}
if let Some(custom_pages_dir) = self.config.custom_pages_directory {
append_all(custom_pages_dir, ".page.md")?;
}
pages.sort_unstable();
pages.dedup();
Ok(pages)
}
pub fn old_custom_pages_exist(&self) -> Result<bool> {
let Some(directory) = self.config.custom_pages_directory else {
return Ok(false);
};
let Ok(file_iter) = fs::read_dir(directory) else {
return Ok(false);
};
for entry in file_iter {
if let Some(extension) = entry?.path().extension() {
if extension == "page" || extension == "patch" {
return Ok(true);
}
}
}
Ok(false)
}
pub fn clear(self) -> Result<()> {
fs::remove_dir_all(self.config.pages_directory).with_context(|| {
format!(
"Could not remove pages directory at {}",
self.config.pages_directory.display(),
)
})
}
/// Download archives for the languages in `self.config().download_languages` and replace the
/// pages directory with the newly downloaded pages. As not all languages might have pages
/// available (for example, `en_US` instead of `en`), an iterator yielding all languages which
/// were successfully downloaded is returned.
pub fn update(
&mut self,
archive_url: &str,
tls_backend: TlsBackend,
) -> Result<impl IntoIterator<Item = Language<'_>>> {
let client = Self::build_client(tls_backend);
// Download everything before deleting anything
let mut archives = self
.config
.download_languages
.iter()
.map(|&lang| {
Ok((
lang,
Self::download(
&client,
&format!("{archive_url}/tldr-{}.zip", lang.directory_name()),
)?
.map(|bytes| ZipArchive::new(Cursor::new(bytes)))
.transpose()?,
))
})
.collect::<Result<Vec<_>>>()?;
// Clear cache directory
// Note: This is not the best solution. Ideally we would download the
// archive to a temporary directory and then swap the two directories.
// But renaming a directory doesn't work across filesystems and Rust
// does not yet offer a recursive directory copying function. So for
// now, we'll use this approach.
fs::remove_dir_all(self.config.pages_directory)?;
fs::create_dir(self.config.pages_directory)?;
for (lang, archive) in &mut archives {
if let Some(archive) = archive {
info!("Extracting archive for {lang:?}");
archive.extract(self.config.pages_directory.join(lang.directory_name()))?;
} else {
info!("No archive found for {lang:?}");
}
}
Ok(archives
.into_iter()
.filter_map(|(lang, archive)| archive.is_some().then_some(lang)))
}
pub fn config(&self) -> &CacheConfig<'a> {
&self.config
}
}
impl PageLookupResult {
pub fn with_page(page_path: PathBuf) -> Self {
Self {
@ -277,12 +43,12 @@ impl PageLookupResult {
self
}
/// Create a reader that sequentially reads from the page and the
/// Create a buffered reader that sequentially reads from the page and the
/// patch, as if they were concatenated.
///
/// This will return an error if either the page file or the patch file
/// cannot be opened.
pub fn reader(&self) -> Result<Box<dyn Read>> {
pub fn reader(&self) -> Result<BufReader<Box<dyn Read>>> {
// Open page file
let page_file = File::open(&self.page_path)
.with_context(|| format!("Could not open page file at {}", self.page_path.display()))?;
@ -302,23 +68,151 @@ impl PageLookupResult {
// the page and patch files and that will read them sequentially,
// because it avoids the boxing below. However, the performance impact
// would first need to be shown to be significant using a benchmark.
Ok(if let Some(patch_file) = patch_file_opt {
Ok(BufReader::new(if let Some(patch_file) = patch_file_opt {
Box::new(page_file.chain(&b"\n"[..]).chain(patch_file)) as Box<dyn Read>
} else {
Box::new(page_file) as Box<dyn Read>
})
}))
}
}
impl Language<'_> {
fn directory_name(&self) -> String {
format!("pages.{}", self.0)
}
pub enum CacheFreshness {
/// The cache is still fresh (less than `MAX_CACHE_AGE` old)
Fresh,
/// The cache is stale and should be updated
Stale(Duration),
/// The cache is missing
Missing,
}
impl PlatformType {
fn directory_name(self) -> &'static str {
match self {
impl Cache {
pub fn new<P>(cache_dir: P, enable_styles: bool) -> Self
where
P: Into<PathBuf>,
{
Self {
cache_dir: cache_dir.into(),
enable_styles,
}
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
/// Make sure that the cache directory exists and is a directory.
/// If necessary, create the directory.
fn ensure_cache_dir_exists(&self) -> Result<()> {
// Check whether `cache_dir` exists and is a directory
let (cache_dir_exists, cache_dir_is_dir) = self
.cache_dir
.metadata()
.map_or((false, false), |md| (true, md.is_dir()));
ensure!(
!cache_dir_exists || cache_dir_is_dir,
"Cache directory path `{}` is not a directory",
self.cache_dir.display(),
);
if !cache_dir_exists {
// If missing, try to create the complete directory path
fs::create_dir_all(&self.cache_dir).with_context(|| {
format!(
"Cache directory path `{}` cannot be created",
self.cache_dir.display(),
)
})?;
eprintln!(
"Successfully created cache directory path `{}`.",
self.cache_dir.display(),
);
}
Ok(())
}
fn pages_dir(&self) -> PathBuf {
self.cache_dir.join(TLDR_PAGES_DIR)
}
/// Download the archive from the specified URL.
fn download(archive_url: &str) -> Result<Vec<u8>> {
let mut builder = Client::builder();
if let Ok(ref host) = env::var("HTTP_PROXY") {
if let Ok(proxy) = Proxy::http(host) {
builder = builder.proxy(proxy);
}
}
if let Ok(ref host) = env::var("HTTPS_PROXY") {
if let Ok(proxy) = Proxy::https(host) {
builder = builder.proxy(proxy);
}
}
let client = builder
.build()
.context("Could not instantiate HTTP client")?;
let mut resp = client
.get(archive_url)
.send()?
.error_for_status()
.with_context(|| format!("Could not download tldr pages from {archive_url}"))?;
let mut buf: Vec<u8> = vec![];
let bytes_downloaded = resp.copy_to(&mut buf)?;
debug!("{} bytes downloaded", bytes_downloaded);
Ok(buf)
}
/// Update the pages cache from the specified URL.
pub fn update(&self, archive_url: &str) -> Result<()> {
self.ensure_cache_dir_exists()?;
// First, download the compressed data
let bytes: Vec<u8> = Self::download(archive_url)?;
// Decompress the response body into an `Archive`
let mut archive = ZipArchive::new(Cursor::new(bytes))
.context("Could not decompress downloaded ZIP archive")?;
// Clear cache directory
// Note: This is not the best solution. Ideally we would download the
// archive to a temporary directory and then swap the two directories.
// But renaming a directory doesn't work across filesystems and Rust
// does not yet offer a recursive directory copying function. So for
// now, we'll use this approach.
self.clear()
.context("Could not clear the cache directory")?;
// Extract archive into pages dir
archive
.extract(self.pages_dir())
.context("Could not unpack compressed data")?;
Ok(())
}
/// Return the duration since the cache directory was last modified.
pub fn last_update(&self) -> Option<Duration> {
if let Ok(metadata) = fs::metadata(self.pages_dir()) {
if let Ok(mtime) = metadata.modified() {
let now = SystemTime::now();
return now.duration_since(mtime).ok();
};
};
None
}
/// Return the freshness of the cache (fresh, stale or missing).
pub fn freshness(&self) -> CacheFreshness {
match self.last_update() {
Some(ago) if ago > crate::config::MAX_CACHE_AGE => CacheFreshness::Stale(ago),
Some(_) => CacheFreshness::Fresh,
None => CacheFreshness::Missing,
}
}
/// Return the platform directory.
fn get_platform_dir(platform: PlatformType) -> &'static str {
match platform {
PlatformType::Linux => "linux",
PlatformType::OsX => "osx",
PlatformType::SunOs => "sunos",
@ -327,51 +221,232 @@ impl PlatformType {
PlatformType::FreeBsd => "freebsd",
PlatformType::NetBsd => "netbsd",
PlatformType::OpenBsd => "openbsd",
PlatformType::Common => "common",
}
}
}
impl Cache<'_> {
fn build_client(tls_backend: TlsBackend) -> Agent {
let tls_builder = match tls_backend {
#[cfg(feature = "native-tls")]
TlsBackend::NativeTls => TlsConfig::builder()
.provider(TlsProvider::NativeTls)
.root_certs(RootCerts::PlatformVerifier),
#[cfg(feature = "rustls-with-webpki-roots")]
TlsBackend::RustlsWithWebpkiRoots => TlsConfig::builder()
.provider(TlsProvider::Rustls)
.root_certs(RootCerts::WebPki),
#[cfg(feature = "rustls-with-native-roots")]
TlsBackend::RustlsWithNativeRoots => TlsConfig::builder()
.provider(TlsProvider::Rustls)
.root_certs(RootCerts::PlatformVerifier),
};
let config = Agent::config_builder()
.http_status_as_error(false) // because we want to handle them
.tls_config(tls_builder.build())
.build();
config.into()
/// Check for pages for a given platform in one of the given languages.
fn find_page_for_platform(
page_name: &str,
pages_dir: &Path,
platform: &str,
language_dirs: &[String],
) -> Option<PathBuf> {
language_dirs
.iter()
.map(|lang_dir| pages_dir.join(lang_dir).join(platform).join(page_name))
.find(|path| path.exists() && path.is_file())
}
/// Download the archive from the specified URL.
fn download(client: &Agent, archive_url: &str) -> Result<Option<Vec<u8>>> {
info!("Downloading archive from {archive_url}");
let response = client.get(archive_url).call();
match response {
Ok(response) if response.status().is_success() => {
let mut buf: Vec<u8> = Vec::new();
response.into_body().into_reader().read_to_end(&mut buf)?;
debug!("{} bytes downloaded", buf.len());
Ok(Some(buf))
/// Look up custom patch (<name>.patch.md). If it exists, store it in a variable.
fn find_patch(patch_name: &str, custom_pages_dir: Option<&Path>) -> Option<PathBuf> {
custom_pages_dir
.map(|custom_dir| custom_dir.join(patch_name))
.filter(|path| path.exists() && path.is_file())
}
/// Search for a page and return the path to it.
pub fn find_page(
&self,
name: &str,
languages: &[String],
custom_pages_dir: Option<&Path>,
platforms: &[PlatformType],
) -> Option<PageLookupResult> {
let page_filename = format!("{name}.md");
let patch_filename = format!("{name}.patch.md");
let custom_filename = format!("{name}.page.md");
// Determine directory paths
let pages_dir = self.pages_dir();
let lang_dirs: Vec<String> = languages
.iter()
.map(|lang| {
if lang == "en" {
String::from("pages")
} else {
format!("pages.{lang}")
}
})
.collect();
// Look up custom page (<name>.page.md). If it exists, return it directly
if let Some(config_dir) = custom_pages_dir {
// TODO: Remove this check 1 year after version 1.7.0 was released
self.check_for_old_custom_pages(config_dir);
let custom_page = config_dir.join(custom_filename);
if custom_page.exists() && custom_page.is_file() {
return Some(PageLookupResult::with_page(custom_page));
}
Ok(response) if response.status() == StatusCode::NOT_FOUND => Ok(None),
_ => {
bail!("Could not download tldr pages from {archive_url}: {response:?}")
}
let patch_path = Self::find_patch(&patch_filename, custom_pages_dir);
// Try to find a platform specific path next, in the order supplied by the user, and append custom patch to it.
for &platform in platforms {
let platform_dir = Cache::get_platform_dir(platform);
if let Some(page) =
Self::find_page_for_platform(&page_filename, &pages_dir, platform_dir, &lang_dirs)
{
return Some(PageLookupResult::with_page(page).with_optional_patch(patch_path));
}
}
// Did not find platform specific results, fall back to "common"
Self::find_page_for_platform(&page_filename, &pages_dir, "common", &lang_dirs)
.map(|page| PageLookupResult::with_page(page).with_optional_patch(patch_path))
}
/// Return the available pages.
pub fn list_pages(
&self,
custom_pages_dir: Option<&Path>,
platforms: &[PlatformType],
) -> Vec<String> {
// Determine platforms directory and platform
let platforms_dir = self.pages_dir().join("pages");
let platform_dirs: Vec<&'static str> = platforms
.iter()
.map(|&p| Self::get_platform_dir(p))
.collect();
// Closure that allows the WalkDir instance to traverse platform
// specific and common page directories, but not others.
let should_walk = |entry: &DirEntry| -> bool {
let file_type = entry.file_type();
let Some(file_name) = entry.file_name().to_str() else {
return false;
};
if file_type.is_dir() {
return file_name == "common" || platform_dirs.contains(&file_name);
} else if file_type.is_file() {
return true;
}
false
};
let to_stem = |entry: DirEntry| -> Option<String> {
entry
.path()
.file_stem()
.and_then(OsStr::to_str)
.map(str::to_string)
};
let to_stem_custom = |entry: DirEntry| -> Option<String> {
entry
.path()
.file_name()
.and_then(OsStr::to_str)
.and_then(|s| s.strip_suffix(".page.md"))
.map(str::to_string)
};
// Recursively walk through common and (if applicable) platform specific directory
let mut pages = WalkDir::new(platforms_dir)
.min_depth(1) // Skip root directory
.into_iter()
.filter_entry(should_walk) // Filter out pages for other architectures
.filter_map(Result::ok) // Convert results to options, filter out errors
.filter_map(|e| {
let extension = e.path().extension().unwrap_or_default();
if e.file_type().is_file() && extension == "md" {
to_stem(e)
} else {
None
}
})
.collect::<Vec<String>>();
if let Some(custom_pages_dir) = custom_pages_dir {
let is_page = |entry: &DirEntry| -> bool {
entry.file_type().is_file()
&& entry
.path()
.file_name()
.and_then(OsStr::to_str)
.map_or(false, |file_name| file_name.ends_with(".page.md"))
};
let custom_pages = WalkDir::new(custom_pages_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_entry(is_page)
.filter_map(Result::ok)
.filter_map(to_stem_custom);
pages.extend(custom_pages);
}
pages.sort();
pages.dedup();
pages
}
/// Delete the cache directory
///
/// Returns true if the cache was deleted and false if the cache dir did
/// not exist.
pub fn clear(&self) -> Result<bool> {
if !self.cache_dir.exists() {
return Ok(false);
}
ensure!(
self.cache_dir.is_dir(),
"Cache path ({}) is not a directory.",
self.cache_dir.display(),
);
// Delete old tldr-pages cache location as well if present
// TODO: To be removed in the future
for pages_dir_name in [TLDR_PAGES_DIR, TLDR_OLD_PAGES_DIR] {
let pages_dir = self.cache_dir.join(pages_dir_name);
if pages_dir.exists() {
fs::remove_dir_all(&pages_dir).with_context(|| {
format!(
"Could not remove the cache directory at {}",
pages_dir.display()
)
})?;
}
}
Ok(true)
}
/// Check for old custom pages (without .md suffix) and print a warning.
fn check_for_old_custom_pages(&self, custom_pages_dir: &Path) {
let old_custom_pages_exist = WalkDir::new(custom_pages_dir)
.min_depth(1)
.max_depth(1)
.into_iter()
.filter_entry(|entry| entry.file_type().is_file())
.any(|entry| {
if let Ok(entry) = entry {
let extension = entry.path().extension();
if let Some(extension) = extension {
extension == "page" || extension == "patch"
} else {
false
}
} else {
false
}
});
if old_custom_pages_exist {
print_warning(
self.enable_styles,
&format!(
"Custom pages using the old naming convention were found in {}.\n\
Please rename them to follow the new convention:\n\
- `<name>.page` `<name>.page.md`\n\
- `<name>.patch` `<name>.patch.md`",
custom_pages_dir.display()
),
);
}
}
}

View file

@ -2,7 +2,7 @@
use std::path::PathBuf;
use clap::{builder::ArgAction, ArgGroup, Parser};
use clap::{arg, builder::ArgAction, command, ArgGroup, Parser};
use crate::types::{ColorOptions, PlatformType};
@ -18,9 +18,7 @@ use crate::types::{ColorOptions, PlatformType};
{usage-heading} {usage}
{all-args}{after-help}",
after_help = "To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/.
To view usage examples, run tldr tldr or tldr tealdeer.",
after_help = "To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/.",
arg_required_else_help = true,
help_expected = true,
group = ArgGroup::new("command_or_file").args(&["command", "render"]),
@ -34,14 +32,6 @@ pub(crate) struct Cli {
#[arg(short = 'l', long = "list")]
pub list: bool,
/// Edit custom page with `EDITOR`
#[arg(long, requires = "command")]
pub edit_page: bool,
/// Edit custom patch with `EDITOR`
#[arg(long, requires = "command", conflicts_with = "edit_page")]
pub edit_patch: bool,
/// Render a specific markdown file
#[arg(
short = 'f',
@ -76,10 +66,6 @@ pub(crate) struct Cli {
#[arg(short = 'c', long = "clear-cache")]
pub clear_cache: bool,
/// Override config file location
#[arg(long = "config-path", value_name = "FILE")]
pub config_path: Option<PathBuf>,
/// Use a pager to page output
#[arg(long = "pager", requires = "command_or_file")]
pub pager: bool,

View file

@ -1,101 +1,26 @@
use std::{
borrow::Cow,
env, fmt,
fs::{self, File},
io::{ErrorKind, Write},
path::{Component, Path, PathBuf},
sync::LazyLock,
env, fmt, fs,
io::{Read, Write},
path::{Path, PathBuf},
time::Duration,
};
use anyhow::{anyhow, ensure, Context, Result};
use clap::ValueEnum;
use log::info;
use serde::Serialize as _;
use anyhow::{bail, ensure, Context, Result};
use app_dirs::{get_app_root, AppDataType};
use log::debug;
use serde_derive::{Deserialize, Serialize};
use yansi::{Color, Style};
use crate::{
extensions::Dedup as _,
types::{PathSource, PlatformType},
};
use crate::types::PathSource;
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const MAX_CACHE_AGE: Duration = Duration::from_secs(2_592_000); // 30 days
const DEFAULT_UPDATE_INTERVAL_HOURS: u64 = MAX_CACHE_AGE.as_secs() / 3600; // 30 days
const SUPPORTED_TLS_BACKENDS: &[RawTlsBackend] = &[
#[cfg(feature = "native-tls")]
RawTlsBackend::NativeTls,
#[cfg(feature = "rustls-with-webpki-roots")]
RawTlsBackend::RustlsWithWebpkiRoots,
#[cfg(feature = "rustls-with-native-roots")]
RawTlsBackend::RustlsWithNativeRoots,
];
struct SystemDirectories {
config: PathBuf,
cache: PathBuf,
data: PathBuf,
}
impl SystemDirectories {
fn discover() -> Result<Self> {
use etcetera::{
app_strategy::choose_native_strategy, choose_app_strategy, AppStrategy, AppStrategyArgs,
};
let args = AppStrategyArgs {
top_level_domain: String::new(),
author: String::new(),
app_name: crate::NAME.to_string(),
};
// The app strategy prefers XDG on MacOs, whereas the native strategy returns paths which
// are used by installed applications. On Linux and Windows, the strategies are the same.
let app_dirs = choose_app_strategy(args.clone())?;
let native_dirs = choose_native_strategy(args)?;
// We prefer the XDG paths, but before tealdeer 1.9, we used only the native paths on MacOs.
// So if we find files in these locations, we keep using them.
let fallback = |app_dir: PathBuf, native_dir: PathBuf| {
if !app_dir.exists() && native_dir.exists() {
native_dir
} else {
app_dir
}
};
Ok(Self {
config: fallback(app_dirs.config_dir(), native_dirs.config_dir()),
cache: fallback(app_dirs.cache_dir(), native_dirs.cache_dir()),
data: fallback(app_dirs.data_dir(), native_dirs.data_dir()),
})
}
}
static SYSTEM_DIRECTORIES: LazyLock<SystemDirectories> = LazyLock::new(|| {
SystemDirectories::discover().expect("Failed to initialize system directories.")
});
pub(crate) fn supported_tls_backends_string() -> String {
SUPPORTED_TLS_BACKENDS
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
.join(", ")
}
fn default_underline() -> bool {
false
}
const fn default_base_indent() -> usize {
2
}
const fn default_command_indent() -> usize {
6
}
fn default_bold() -> bool {
false
}
@ -204,8 +129,8 @@ struct RawStyleConfig {
pub example_variable: RawStyle,
}
impl From<&RawStyleConfig> for StyleConfig {
fn from(raw_style_config: &RawStyleConfig) -> Self {
impl From<RawStyleConfig> for StyleConfig {
fn from(raw_style_config: RawStyleConfig) -> Self {
Self {
command_name: raw_style_config.command_name.into(),
description: raw_style_config.description.into(),
@ -222,39 +147,13 @@ struct RawDisplayConfig {
pub compact: bool,
#[serde(default)]
pub use_pager: bool,
#[serde(default)]
pub show_title: bool,
#[serde(default)]
pub indent: RawIndent,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
struct RawIndent {
#[serde(default = "default_base_indent")]
base: usize,
#[serde(default = "default_command_indent")]
command: usize,
}
impl Default for RawIndent {
fn default() -> Self {
Self {
base: 2,
command: 6,
}
}
}
impl From<&RawDisplayConfig> for DisplayConfig {
fn from(raw_display_config: &RawDisplayConfig) -> Self {
impl From<RawDisplayConfig> for DisplayConfig {
fn from(raw_display_config: RawDisplayConfig) -> Self {
Self {
compact: raw_display_config.compact,
use_pager: raw_display_config.use_pager,
show_title: raw_display_config.show_title,
indent: Indent {
base: raw_display_config.indent.base,
command: raw_display_config.indent.command,
},
}
}
}
@ -267,35 +166,12 @@ const fn default_auto_update_interval_hours() -> u64 {
DEFAULT_UPDATE_INTERVAL_HOURS
}
fn default_archive_source() -> String {
"https://github.com/tldr-pages/tldr/releases/latest/download".to_owned()
}
/// Controls when a warning about an outdated cache is printed.
///
/// Currently, the only nameable option is `"never"`. In the future, this may
/// be extended to also accept a duration (e.g. `"60d"`), after which the
/// warning should be shown.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum RawWarnCacheAge {
Never,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct RawUpdatesConfig {
#[serde(default)]
pub auto_update: bool,
#[serde(default = "default_auto_update_interval_hours")]
pub auto_update_interval_hours: u64,
#[serde(default = "default_archive_source")]
pub archive_source: String,
#[serde(default)]
pub tls_backend: RawTlsBackend,
#[serde(default)]
pub download_languages: Option<Vec<String>>,
#[serde(default)]
pub warn_cache_age: Option<RawWarnCacheAge>,
}
impl Default for RawUpdatesConfig {
@ -303,10 +179,17 @@ impl Default for RawUpdatesConfig {
Self {
auto_update: false,
auto_update_interval_hours: DEFAULT_UPDATE_INTERVAL_HOURS,
archive_source: default_archive_source(),
tls_backend: RawTlsBackend::default(),
download_languages: None,
warn_cache_age: None,
}
}
}
impl From<RawUpdatesConfig> for UpdatesConfig {
fn from(raw_updates_config: RawUpdatesConfig) -> Self {
Self {
auto_update: raw_updates_config.auto_update,
auto_update_interval: Duration::from_secs(
raw_updates_config.auto_update_interval_hours * 3600,
),
}
}
}
@ -319,63 +202,6 @@ struct RawDirectoriesConfig {
pub custom_pages_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RawPlatformType {
Current,
All,
MacOs, // alias for Platform(PlatformType::OsX)
#[serde(untagged)]
Platform(PlatformType),
}
impl RawPlatformType {
pub fn flatten(raw_platforms: impl IntoIterator<Item = Self>) -> Vec<PlatformType> {
let mut flattened = Vec::new();
for raw_platform in raw_platforms {
match raw_platform {
RawPlatformType::Current => flattened.push(PlatformType::current()),
RawPlatformType::Platform(platform) => flattened.push(platform),
RawPlatformType::MacOs => flattened.push(PlatformType::OsX),
RawPlatformType::All => flattened.extend(PlatformType::value_variants()),
}
}
flattened.clear_duplicates();
flattened
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RawSearchConfig {
pub languages: Option<Vec<String>>,
pub platforms: Option<Vec<RawPlatformType>>,
}
impl<'a> From<&'a RawSearchConfig> for SearchConfig<'a> {
fn from(raw_search_config: &'a RawSearchConfig) -> Self {
let languages = raw_search_config
.languages
.as_ref()
.map_or_else(get_languages_from_env, |langs| {
langs.iter().map(|lang| Language(lang)).collect()
});
let platforms = if let Some(raw_platforms) = raw_search_config.platforms.as_ref() {
RawPlatformType::flatten(raw_platforms.iter().copied())
} else {
RawPlatformType::flatten([
RawPlatformType::Current,
RawPlatformType::Platform(PlatformType::Common),
RawPlatformType::All,
])
};
Self {
languages,
platforms,
}
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
struct RawConfig {
@ -383,7 +209,12 @@ struct RawConfig {
display: RawDisplayConfig,
updates: RawUpdatesConfig,
directories: RawDirectoriesConfig,
search: RawSearchConfig,
}
impl RawConfig {
fn new() -> Self {
Self::default()
}
}
impl Default for RawConfig {
@ -393,7 +224,6 @@ impl Default for RawConfig {
display: RawDisplayConfig::default(),
updates: RawUpdatesConfig::default(),
directories: RawDirectoriesConfig::default(),
search: RawSearchConfig::default(),
};
// Set default config
@ -407,7 +237,7 @@ impl Default for RawConfig {
}
}
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct StyleConfig {
pub description: Style,
pub command_name: Style,
@ -420,24 +250,12 @@ pub struct StyleConfig {
pub struct DisplayConfig {
pub compact: bool,
pub use_pager: bool,
pub show_title: bool,
pub indent: Indent,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Indent {
pub base: usize,
pub command: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatesConfig<'a> {
pub struct UpdatesConfig {
pub auto_update: bool,
pub auto_update_interval: Duration,
pub archive_source: &'a str,
pub tls_backend: TlsBackend,
pub download_languages: Vec<Language<'a>>,
pub warn_cache_age: Option<Duration>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -465,179 +283,22 @@ pub struct DirectoriesConfig {
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SearchConfig<'a> {
pub languages: Vec<Language<'a>>,
pub platforms: Vec<PlatformType>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Language<'a>(pub &'a str);
fn get_languages<'a>(
env_lang: Option<&'a str>,
env_language: Option<&'a str>,
) -> Vec<Language<'a>> {
// Language list according to
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language
let Some(env_lang) = env_lang else {
return vec![Language("en")];
};
// Create an iterator that contains $LANGUAGE (':' separated list) followed by $LANG (single language)
let locales = env_language.unwrap_or("").split(':').chain([env_lang]);
let mut lang_list = Vec::new();
for locale in locales {
if !locale.is_ascii() {
info!("Skipping non-ASCII locale string: {locale}");
continue;
}
// Language plus country code (e.g. `en_US`)
if locale.len() >= 5 && locale.chars().nth(2) == Some('_') {
lang_list.push(Language(&locale[..5]));
}
// Language code only (e.g. `en`)
if locale.len() >= 2 && locale != "POSIX" {
lang_list.push(Language(&locale[..2]));
}
}
lang_list.push(Language("en"));
lang_list.clear_duplicates();
lang_list
}
pub fn get_languages_from_env<'a>() -> Vec<Language<'a>> {
static LANG: LazyLock<Option<String>> = LazyLock::new(|| std::env::var("LANG").ok());
static LANGUAGE: LazyLock<Option<String>> = LazyLock::new(|| std::env::var("LANGUAGE").ok());
get_languages(
LANG.as_ref().map(String::as_str),
LANGUAGE.as_ref().map(String::as_str),
)
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum RawTlsBackend {
/// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise)
NativeTls,
/// Rustls with `WebPKI` roots.
RustlsWithWebpkiRoots,
/// Rustls with native roots.
RustlsWithNativeRoots,
}
impl Default for RawTlsBackend {
fn default() -> Self {
*SUPPORTED_TLS_BACKENDS.first().unwrap()
}
}
impl std::fmt::Display for RawTlsBackend {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.serialize(f)
}
}
/// Allows choosing a `reqwest`'s TLS backend. Available TLS backends:
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TlsBackend {
/// Native TLS (`SChannel` on Windows, Secure Transport on macOS and OpenSSL otherwise)
#[cfg(feature = "native-tls")]
NativeTls,
/// Rustls with `WebPKI` roots.
#[cfg(feature = "rustls-with-webpki-roots")]
RustlsWithWebpkiRoots,
/// Rustls with native roots.
#[cfg(feature = "rustls-with-native-roots")]
RustlsWithNativeRoots,
}
impl TryFrom<RawTlsBackend> for TlsBackend {
type Error = anyhow::Error;
fn try_from(raw: RawTlsBackend) -> Result<Self, Self::Error> {
match raw {
#[cfg(feature = "native-tls")]
RawTlsBackend::NativeTls => Ok(TlsBackend::NativeTls),
#[cfg(feature = "rustls-with-webpki-roots")]
RawTlsBackend::RustlsWithWebpkiRoots => Ok(TlsBackend::RustlsWithWebpkiRoots),
#[cfg(feature = "rustls-with-native-roots")]
RawTlsBackend::RustlsWithNativeRoots => Ok(TlsBackend::RustlsWithNativeRoots),
// when compiling without all TLS backend features, we want to handle config error.
#[allow(unreachable_patterns)]
_ => Err(anyhow!(
"Unsupported TLS backend: {}. This tealdeer build has support for the following options: {}",
raw,
supported_tls_backends_string(),
))
}
}
}
impl TlsBackend {
const fn as_raw(self) -> RawTlsBackend {
match self {
#[cfg(feature = "native-tls")]
Self::NativeTls => RawTlsBackend::NativeTls,
#[cfg(feature = "rustls-with-webpki-roots")]
Self::RustlsWithWebpkiRoots => RawTlsBackend::RustlsWithWebpkiRoots,
#[cfg(feature = "rustls-with-native-roots")]
Self::RustlsWithNativeRoots => RawTlsBackend::RustlsWithNativeRoots,
}
}
}
impl fmt::Display for TlsBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.as_raw().fmt(f)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config<'a> {
pub struct Config {
pub style: StyleConfig,
pub display: DisplayConfig,
pub updates: UpdatesConfig<'a>,
pub updates: UpdatesConfig,
pub directories: DirectoriesConfig,
pub search: SearchConfig<'a>,
pub file_path: PathWithSource,
}
impl<'a> Config<'a> {
impl Config {
/// Convert a `RawConfig` to a high-level `Config`.
///
/// For this, some values need to be converted to other types and some
/// defaults need to be set (sometimes based on env variables).
fn from_raw(raw_config: &'a RawConfig, config_file_path: PathWithSource) -> Result<Self> {
let style = (&raw_config.style).into();
let display = (&raw_config.display).into();
let search: SearchConfig<'a> = (&raw_config.search).into();
let updates = UpdatesConfig {
auto_update: raw_config.updates.auto_update,
auto_update_interval: Duration::from_secs(
raw_config.updates.auto_update_interval_hours * 3600,
),
archive_source: &raw_config.updates.archive_source,
tls_backend: raw_config.updates.tls_backend.try_into()?,
download_languages: raw_config.updates.download_languages.as_ref().map_or_else(
|| search.languages.clone(),
|languages| languages.iter().map(|lang| Language(lang)).collect(),
),
warn_cache_age: match raw_config.updates.warn_cache_age {
None => Some(MAX_CACHE_AGE),
Some(RawWarnCacheAge::Never) => None,
},
};
let relative_path_root = config_file_path
.path()
.parent()
.context("Failed to get config directory")?;
let home_path = env::home_dir();
fn from_raw(raw_config: RawConfig) -> Result<Self> {
let style = raw_config.style.into();
let display = raw_config.display.into();
let updates = raw_config.updates.into();
// Determine directories config. For this, we need to take some
// additional factory into account, like env variables, or the
@ -652,44 +313,39 @@ impl<'a> Config<'a> {
path: PathBuf::from(env_var),
source: PathSource::EnvVar,
}
} else if let Some(config_value) = &raw_config.directories.cache_dir {
// Resolve possible ~ prefixed path
let expanded_path = expand_home(config_value, home_path.as_deref())?;
// Resolve possible relative path.
let resolved_path = relative_path_root.join(expanded_path);
} else if let Some(config_value) = raw_config.directories.cache_dir {
// If the user explicitly configured a cache directory, use that.
PathWithSource {
path: resolved_path,
path: config_value,
source: PathSource::ConfigFile,
}
} else {
} else if let Ok(default_dir) = get_app_root(AppDataType::UserCache, &crate::APP_INFO) {
// Otherwise, fall back to the default user cache directory.
PathWithSource {
path: SYSTEM_DIRECTORIES.cache.clone(),
path: default_dir,
source: PathSource::OsConvention,
}
} else {
// If everything fails, give up
bail!("Could not determine user cache directory");
};
let custom_pages_dir = raw_config
.directories
.custom_pages_dir
.as_ref()
.map(|path| -> Result<PathWithSource> {
// Resolve possible ~ prefixed path
let expanded_path = expand_home(path, home_path.as_deref())?;
// Resolve possible relative path.
let resolved_path = relative_path_root.join(expanded_path);
Ok(PathWithSource {
path: resolved_path,
source: PathSource::ConfigFile,
})
.map(|path| PathWithSource {
path,
source: PathSource::ConfigFile,
})
.transpose()?
.or_else(|| {
// Note: The `join("")` call ensures that there's a trailing slash
Some(PathWithSource {
path: SYSTEM_DIRECTORIES.data.join("pages").join(""),
source: PathSource::OsConvention,
})
get_app_root(AppDataType::UserData, &crate::APP_INFO)
.map(|path| {
// Note: The `join("")` call ensures that there's a trailing slash
PathWithSource {
path: path.join("pages").join(""),
source: PathSource::OsConvention,
}
})
.ok()
});
let directories = DirectoriesConfig {
cache_dir,
@ -701,91 +357,46 @@ impl<'a> Config<'a> {
display,
updates,
directories,
search,
file_path: config_file_path,
})
}
}
/// Expands tilde (~) prefixed directories into its absolute version
fn expand_home<'a>(input_path: &'a Path, home_path: Option<&Path>) -> Result<Cow<'a, Path>> {
let mut components = input_path.components();
pub fn load(enable_styles: bool) -> Result<Self> {
debug!("Loading config");
if let Some(Component::Normal(first_component_raw)) = components.next() {
let first_component = first_component_raw
.to_str()
.ok_or(anyhow!("Path contains invalid UTF-8"))?;
// Determine path
let (config_file_path, _) = get_config_path().context("Could not determine config path")?;
if first_component == "~" {
let home_path = home_path.ok_or(anyhow!("Unable to find user home directory"))?;
let rest: PathBuf = components.collect();
let expanded = home_path.join(rest);
// Load raw config
let raw_config: RawConfig = if config_file_path.exists() && config_file_path.is_file() {
let mut config_file = fs::File::open(&config_file_path).with_context(|| {
format!("Failed to open config file path at {:?}", &config_file_path)
})?;
let mut contents = String::new();
config_file.read_to_string(&mut contents).with_context(|| {
format!("Failed to read from config file at {:?}", &config_file_path)
})?;
toml::from_str(&contents).with_context(|| {
format!("Failed to parse TOML config file at {config_file_path:?}")
})?
} else {
RawConfig::new()
};
return Ok(Cow::Owned(expanded));
} else if first_component.starts_with('~') {
return Err(anyhow!("Tilde expansion with a login name not supported"));
// Convert to config
let mut config = Self::from_raw(raw_config).context("Could not process raw config")?;
// Potentially override styles
if !enable_styles {
config.style = StyleConfig {
command_name: Style::default(),
description: Style::default(),
example_text: Style::default(),
example_code: Style::default(),
example_variable: Style::default(),
};
}
}
Ok(Cow::Borrowed(input_path))
}
/// The [`ConfigLoader`] is used to load a [`Config`] from a file.
///
/// Since the rich [`Config`] keeps references to [`RawConfig`], the raw config needs to be kept alive outside of the
/// [`Config`]. The [`ConfigLoader`] thus offers the following flow:
/// 1. Read a raw config using [`ConfigLoader::read`] or [`ConfigLoader::read_default_path`].
/// 2. Validate the contents to a [`Config`] that borrows the [`ConfigLoader`].
pub struct ConfigLoader {
raw: RawConfig,
path: PathWithSource,
}
impl ConfigLoader {
fn read_internal(path: PathWithSource, allow_not_found: bool) -> Result<Self> {
match fs::read_to_string(&path.path) {
Ok(content) => Ok(Self {
raw: toml::from_str(&content).with_context(|| {
format!(
"Could not parse config file contents as toml from {}.",
path.path.display()
)
})?,
path,
}),
Err(e) if allow_not_found && e.kind() == ErrorKind::NotFound => Ok(Self {
raw: RawConfig::default(),
path,
}),
Err(e) => Err(e).context(format!(
"Could not read config file contents from {}.",
path.path().display()
)),
}
}
/// Create a loader that uses the config at `path`.
pub fn read(path: PathBuf) -> Result<Self> {
Self::read_internal(
PathWithSource {
path,
source: PathSource::Cli,
},
false,
)
}
/// Create a loader that uses the default config file location. If no file is present at the default location, the
/// default configuration is used.
pub fn read_default_path() -> Result<Self> {
let path = get_default_config_path();
Self::read_internal(path, true)
}
/// Parse the read [`RawConfig`] into a [`Config`].
pub fn load(&self) -> Result<Config<'_>> {
Config::from_raw(&self.raw, self.path.clone())
.context("Could not process raw config into rich config")
Ok(config)
}
}
@ -796,50 +407,46 @@ impl ConfigLoader {
///
/// Note that this function does not verify whether the directory at that
/// location exists, or is a directory.
pub fn get_config_dir() -> (PathBuf, PathSource) {
pub fn get_config_dir() -> Result<(PathBuf, PathSource)> {
// Allow overriding the config directory by setting the
// $TEALDEER_CONFIG_DIR env variable.
if let Ok(value) = env::var("TEALDEER_CONFIG_DIR") {
return (PathBuf::from(value), PathSource::EnvVar);
}
return Ok((PathBuf::from(value), PathSource::EnvVar));
};
(SYSTEM_DIRECTORIES.config.clone(), PathSource::OsConvention)
// Otherwise, fall back to the user config directory.
let dirs = get_app_root(AppDataType::UserConfig, &crate::APP_INFO)
.context("Failed to determine the user config directory")?;
Ok((dirs, PathSource::OsConvention))
}
/// Return the path to the config file.
///
/// Note that this function does not verify whether the file at that location
/// exists, or is a file.
pub fn get_default_config_path() -> PathWithSource {
let (mut path, source) = get_config_dir();
path.push(CONFIG_FILE_NAME);
PathWithSource { path, source }
pub fn get_config_path() -> Result<(PathBuf, PathSource)> {
let (config_dir, source) = get_config_dir()?;
let config_file_path = config_dir.join(CONFIG_FILE_NAME);
Ok((config_file_path, source))
}
/// Create default config file.
/// path: Can be specified to create the config in that path instead of
/// the default path.
pub fn make_default_config(path: Option<&Path>) -> Result<PathBuf> {
let config_file_path = if let Some(p) = path {
p.into()
pub fn make_default_config() -> Result<PathBuf> {
let (config_dir, _) = get_config_dir()?;
// Ensure that config directory exists
if config_dir.exists() {
ensure!(
config_dir.is_dir(),
"Config directory could not be created: {} already exists but is not a directory",
config_dir.to_string_lossy(),
);
} else {
let (config_dir, _) = get_config_dir();
// Ensure that config directory exists
if config_dir.exists() {
ensure!(
config_dir.is_dir(),
"Config directory could not be created: {} already exists but is not a directory",
config_dir.to_string_lossy(),
);
} else {
fs::create_dir_all(&config_dir).context("Could not create config directory")?;
}
config_dir.join(CONFIG_FILE_NAME)
};
fs::create_dir_all(&config_dir).context("Could not create config directory")?;
}
// Ensure that a config file doesn't get overwritten
let config_file_path = config_dir.join(CONFIG_FILE_NAME);
ensure!(
!config_file_path.is_file(),
"A configuration file already exists at {}, no action was taken.",
@ -848,11 +455,11 @@ pub fn make_default_config(path: Option<&Path>) -> Result<PathBuf> {
// Create default config
let serialized_config =
toml::to_string(&RawConfig::default()).context("Failed to serialize default config")?;
toml::to_string(&RawConfig::new()).context("Failed to serialize default config")?;
// Write default config
let mut config_file =
File::create(&config_file_path).context("Could not create config file")?;
fs::File::create(&config_file_path).context("Could not create config file")?;
let _wc = config_file
.write(serialized_config.as_bytes())
.context("Could not write to config file")?;
@ -860,169 +467,10 @@ pub fn make_default_config(path: Option<&Path>) -> Result<PathBuf> {
Ok(config_file_path)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn serialize_deserialize() {
let raw_config = RawConfig::default();
let serialized = toml::to_string(&raw_config).unwrap();
let deserialized: RawConfig = toml::from_str(&serialized).unwrap();
assert_eq!(raw_config, deserialized);
}
#[test]
fn expand_path_with_valid_home() {
let home = Some(PathBuf::from("/foo/bar"));
let path_to_expand = PathBuf::from("~/baz");
assert_eq!(
*expand_home(&path_to_expand, home.as_deref()).unwrap(),
PathBuf::from("/foo/bar/baz")
);
}
#[test]
fn expand_path_with_absolute_path() {
let home = Some(PathBuf::from("/foo/bar"));
let dir_to_expand = PathBuf::from("/one/two");
assert_eq!(
*expand_home(&dir_to_expand, home.as_deref()).unwrap(),
dir_to_expand
);
}
#[test]
fn error_with_tilde_username() {
let home = Some(PathBuf::from("/foo/bar"));
let dir_to_expand = PathBuf::from("~baz/foo");
assert!(expand_home(&dir_to_expand, home.as_deref()).is_err());
}
#[test]
fn expand_tilde_in_config_file() {
let mut raw_config = RawConfig::default();
raw_config.directories.cache_dir = Some("~/my/custom_cache".into());
raw_config.directories.custom_pages_dir = Some("~/custom_pages".into());
let config = Config::from_raw(
&raw_config,
PathWithSource {
path: PathBuf::from("/path/to/config/config.toml"),
source: PathSource::OsConvention,
},
)
.unwrap();
let home_dir = env::home_dir().unwrap();
assert_eq!(
config.directories.cache_dir.path(),
home_dir.join("my/custom_cache")
);
assert_eq!(
config.directories.custom_pages_dir.unwrap().path(),
home_dir.join("custom_pages")
);
}
#[test]
fn relative_path_resolution() {
let mut raw_config = RawConfig::default();
raw_config.directories.cache_dir = Some("../cache".into());
raw_config.directories.custom_pages_dir = Some("../custom_pages".into());
let config = Config::from_raw(
&raw_config,
PathWithSource {
path: PathBuf::from("/path/to/config/config.toml"),
source: PathSource::OsConvention,
},
)
.unwrap();
assert_eq!(
config.directories.cache_dir.path(),
Path::new("/path/to/config/../cache")
);
assert_eq!(
config.directories.custom_pages_dir.unwrap().path(),
Path::new("/path/to/config/../custom_pages")
);
}
mod language {
use super::*;
#[test]
fn missing_lang_env() {
let lang_list = get_languages(None, Some("de:fr"));
assert_eq!(lang_list, [Language("en")]);
let lang_list = get_languages(None, None);
assert_eq!(lang_list, [Language("en")]);
}
#[test]
fn missing_language_env() {
let lang_list = get_languages(Some("de"), None);
assert_eq!(lang_list, [Language("de"), Language("en")]);
}
#[test]
fn preference_order() {
let lang_list = get_languages(Some("de"), Some("fr:cn"));
assert_eq!(
lang_list,
[
Language("fr"),
Language("cn"),
Language("de"),
Language("en")
]
);
}
#[test]
fn country_code_expansion() {
let lang_list = get_languages(Some("pt_BR"), None);
assert_eq!(
lang_list,
[Language("pt_BR"), Language("pt"), Language("en")]
);
}
#[test]
fn with_encoding() {
let lang_list = get_languages(Some("de_DE.UTF-8"), None);
assert_eq!(
lang_list,
[Language("de_DE"), Language("de"), Language("en")]
);
}
#[test]
fn ignore_posix_and_c() {
let lang_list = get_languages(Some("POSIX"), None);
assert_eq!(lang_list, [Language("en")]);
let lang_list = get_languages(Some("C"), None);
assert_eq!(lang_list, [Language("en")]);
}
#[test]
fn no_duplicates() {
let lang_list = get_languages(Some("de"), Some("fr:de:cn:de"));
assert_eq!(
lang_list,
[
Language("fr"),
Language("de"),
Language("cn"),
Language("en")
]
);
}
}
#[test]
fn test_serialize_deserialize() {
let raw_config = RawConfig::new();
let serialized = toml::to_string(&raw_config).unwrap();
let deserialized: RawConfig = toml::from_str(&serialized).unwrap();
assert_eq!(raw_config, deserialized);
}

View file

@ -1,14 +1,14 @@
use std::mem;
/// An extension trait to clear duplicates from a collection.
pub(crate) trait Dedup<T: PartialEq> {
pub(crate) trait Dedup<T: PartialEq + Clone> {
fn clear_duplicates(&mut self);
}
/// Clear duplicates from a collection, keep the first one seen.
///
/// For small vectors, this will be faster than a `HashSet`.
impl<T: PartialEq> Dedup<T> for Vec<T> {
impl<T: PartialEq + Clone> Dedup<T> for Vec<T> {
fn clear_duplicates(&mut self) {
let orig = mem::replace(self, Vec::with_capacity(self.len()));
for item in orig {

View file

@ -2,61 +2,25 @@
use log::debug;
use crate::{config::Indent, extensions::FindFrom, types::LineType};
use crate::{extensions::FindFrom, types::LineType};
#[derive(Debug, Clone, Copy, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Represents a snippet from a page of a specific highlighting class.
pub enum PageSnippet<T> {
CommandName(T),
Variable(T),
NormalCode(T),
Description(T),
Text(T),
Title(T),
pub enum PageSnippet<'a> {
CommandName(&'a str),
Variable(&'a str),
NormalCode(&'a str),
Description(&'a str),
Text(&'a str),
Linebreak,
}
#[cfg_attr(not(test), allow(dead_code))]
impl<T> PageSnippet<T> {
pub fn map<F, U>(self, f: F) -> PageSnippet<U>
where
F: FnOnce(T) -> U,
{
match self {
PageSnippet::CommandName(s) => PageSnippet::CommandName(f(s)),
PageSnippet::Variable(s) => PageSnippet::Variable(f(s)),
PageSnippet::NormalCode(s) => PageSnippet::NormalCode(f(s)),
PageSnippet::Description(s) => PageSnippet::Description(f(s)),
PageSnippet::Text(s) => PageSnippet::Text(f(s)),
PageSnippet::Title(s) => PageSnippet::Title(f(s)),
PageSnippet::Linebreak => PageSnippet::Linebreak,
}
}
}
impl<T: PartialEq<U>, U> PartialEq<PageSnippet<U>> for PageSnippet<T> {
fn eq(&self, other: &PageSnippet<U>) -> bool {
match (self, other) {
(PageSnippet::CommandName(s), PageSnippet::CommandName(t))
| (PageSnippet::Variable(s), PageSnippet::Variable(t))
| (PageSnippet::NormalCode(s), PageSnippet::NormalCode(t))
| (PageSnippet::Description(s), PageSnippet::Description(t))
| (PageSnippet::Text(s), PageSnippet::Text(t))
| (PageSnippet::Title(s), PageSnippet::Title(t)) => s == t,
(PageSnippet::Linebreak, PageSnippet::Linebreak) => true,
_ => false,
}
}
}
impl PageSnippet<&str> {
impl<'a> PageSnippet<'a> {
pub fn is_empty(&self) -> bool {
use PageSnippet::*;
match self {
CommandName(s) | Variable(s) | NormalCode(s) | Description(s) | Text(s) | Title(s) => {
s.is_empty()
}
CommandName(s) | Variable(s) | NormalCode(s) | Description(s) | Text(s) => s.is_empty(),
Linebreak => false,
}
}
@ -67,15 +31,11 @@ pub fn highlight_lines<L, F, E>(
lines: L,
process_snippet: &mut F,
keep_empty_lines: bool,
show_title: bool,
indent: Indent,
) -> Result<(), E>
where
L: Iterator<Item = LineType>,
F: for<'snip> FnMut(PageSnippet<&'snip str>) -> Result<(), E>,
F: for<'snip> FnMut(PageSnippet<'snip>) -> Result<(), E>,
{
let base_indent = " ".repeat(indent.base);
let command_indent = " ".repeat(indent.command);
let mut command = String::new();
for line in lines {
match line {
@ -85,122 +45,51 @@ where
}
}
LineType::Title(title) => {
if show_title {
process_snippet(PageSnippet::Linebreak)?;
process_snippet(PageSnippet::Title(&base_indent))?;
process_snippet(PageSnippet::Title(&title))?;
process_snippet(PageSnippet::Linebreak)?;
} else {
debug!("Ignoring title");
}
debug!("Ignoring title");
// This is safe as long as the parsed title is only the command,
// and the iterator yields values in order of appearance.
command = title;
debug!("Detected command name: {command}");
}
LineType::Description(text) => {
process_snippet(PageSnippet::Description(&base_indent))?;
process_snippet(PageSnippet::Description(&text))?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::ExampleText(text) => {
process_snippet(PageSnippet::Text(&base_indent))?;
process_snippet(PageSnippet::Text(&text))?;
process_snippet(PageSnippet::Linebreak)?;
debug!("Detected command name: {}", &command);
}
LineType::Description(text) => process_snippet(PageSnippet::Description(&text))?,
LineType::ExampleText(text) => process_snippet(PageSnippet::Text(&text))?,
LineType::ExampleCode(text) => {
process_snippet(PageSnippet::NormalCode(&command_indent))?;
process_snippet(PageSnippet::NormalCode(" "))?;
highlight_code(&command, &text, process_snippet)?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::Other(text) => debug!("Unknown line type: {text:?}"),
LineType::Other(text) => debug!("Unknown line type: {:?}", text),
}
}
process_snippet(PageSnippet::Linebreak)?;
Ok(())
}
/// Highlight code examples.
/// - parse placeholders (`{{ curly braces }}`)
/// - replace escaped placeholder markers (`\{\{` and `\}\}`)
fn highlight_code<E>(
command: &str,
mut text: &str,
process_snippet: &mut impl FnMut(PageSnippet<&str>) -> Result<(), E>,
/// Highlight code examples including user variables in {{ curly braces }}.
fn highlight_code<'a, E>(
command: &'a str,
text: &'a str,
process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>,
) -> Result<(), E> {
// We replace escaped placeholder markers at the end so that our replacing does not interfere
// with finding the actual markers.
// NOTE: This is not optimal, as it allocates one String for each `replace`
let replace_escaped = |s: &str| s.replace(r"\{\{", "{{").replace(r"\}\}", "}}");
loop {
// Find placeholder markers and split into code and placeholder accordingly
let Some(start_marker) = find_marker(text, "{{", r"\{\{") else {
break;
};
let Some(mut end_marker) = find_marker(&text[start_marker + 2..], "}}", r"\}\}") else {
break;
};
end_marker += start_marker + 2;
// Greedily extend matched range
while end_marker + 2 < text.len() && text.as_bytes()[end_marker + 2] == b'}' {
end_marker += 1;
}
let placeholder_content = &text[start_marker + 2..end_marker];
if start_marker > 0 {
highlight_code_segment(
command,
&replace_escaped(&text[..start_marker]),
process_snippet,
)?;
}
process_snippet(PageSnippet::Variable(&replace_escaped(placeholder_content)))?;
text = &text[end_marker + 2..];
let variable_splits = text
.split("}}")
.map(|s| s.split_once("{{").unwrap_or((s, "")));
for (code_segment, variable) in variable_splits {
highlight_code_segment(command, code_segment, process_snippet)?;
process_snippet(PageSnippet::Variable(variable))?;
}
if !text.is_empty() {
highlight_code_segment(command, &replace_escaped(text), process_snippet)?;
}
Ok(())
}
/// Find a "{{" (or "}}") substring that does not overlap with a preceding "\{\{" (or "\}\}").
fn find_marker(s: &str, marker: &str, forbidden_prefix: &str) -> Option<usize> {
let mut search_start = 0;
loop {
let marker_index = s.find_from(marker, search_start)?;
let overlaps_with_prefix = (forbidden_prefix.len() <= marker_index + 1) && {
let prefix_start = marker_index + 1 - forbidden_prefix.len();
// NOTE: The indices might not be valid character offsets, so we should do this
// comparison on raw bytes. If prefix_start is indeed not a character offset than the
// comparison is guaranteed to return false because forbidden_prefix[0] definitely _is_
// the start of a (single byte, ASCII) character.
&s.as_bytes()[prefix_start..=marker_index] == forbidden_prefix.as_bytes()
};
if !overlaps_with_prefix {
return Some(marker_index);
}
// The next valid marker cannot include the first character of the current match
search_start = marker_index + 1;
}
}
/// Yields `NormalCode` and `CommandName` in alternating order according to the occurrences of
/// `command_name` in `segment`. Variables are not detected here, see `highlight_code`
/// instead.
fn highlight_code_segment<'a, E>(
command_name: &'a str,
mut segment: &'a str,
process_snippet: &mut impl FnMut(PageSnippet<&'a str>) -> Result<(), E>,
process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>,
) -> Result<(), E> {
if !command_name.is_empty() {
let mut search_start = 0;
@ -230,17 +119,20 @@ fn is_freestanding_substring(surrounding: &str, substring: (usize, usize)) -> bo
let char_before_is_okay = surrounding[..start]
.chars()
.last()
.is_none_or(char::is_whitespace);
.filter(|prev_char| !prev_char.is_whitespace())
.is_none();
let char_after_is_okay = surrounding[end..]
.chars()
.next()
.is_none_or(char::is_whitespace);
.filter(|next_char| !next_char.is_whitespace())
.is_none();
char_before_is_okay && char_after_is_okay
}
#[cfg(test)]
mod tests {
use super::*;
use PageSnippet::*;
#[test]
fn test_is_freestanding_substring() {
@ -267,193 +159,80 @@ mod tests {
));
}
fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec<PageSnippet<String>> {
fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec<PageSnippet<'a>> {
let mut yielded = Vec::new();
let mut process_snippet = |snip: PageSnippet<&str>| {
let mut process_snippet = |snip: PageSnippet<'a>| {
if !snip.is_empty() {
yielded.push(snip.map(str::to_string));
yielded.push(snip);
}
Ok::<(), ()>(())
};
highlight_code(cmd, segment, &mut process_snippet).expect("highlight code segment failed");
highlight_code_segment(cmd, segment, &mut process_snippet)
.expect("highlight code segment failed");
yielded
}
mod highlight_code_segment {
use super::*;
use PageSnippet::*;
#[test]
fn test_highlight_code_segment() {
assert!(run("make", "").is_empty());
assert_eq!(
&run("make", "make all CC=clang -q"),
&[CommandName("make"), NormalCode(" all CC=clang -q")]
);
assert_eq!(
&run("make", " make money --always-make"),
&[
NormalCode(" "),
CommandName("make"),
NormalCode(" money --always-make")
]
);
assert_eq!(
&run("git commit", "git commit -m 'git commit'"),
&[CommandName("git commit"), NormalCode(" -m 'git commit'"),]
);
}
#[test]
fn test_i18n() {
assert_eq!(
&run("mäke", "mäke höhlenrätselbücher"),
&[CommandName("mäke"), NormalCode(" höhlenrätselbücher")]
);
assert_eq!(
&run(
"Müll",
"1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich"
),
&[
NormalCode("1000 Gründe warum "),
CommandName("Müll"),
NormalCode(" heute größer ist als "),
CommandName("Müll"),
NormalCode(" früher, ärgerlich")
]
);
assert_eq!(
&run(
"übergang",
"die Zustandsübergangsfunktion übergang Änderungen",
),
&[
NormalCode("die Zustandsübergangsfunktion "),
CommandName("übergang"),
NormalCode(" Änderungen")
],
);
}
#[test]
fn test_empty_command() {
let segment = "some code";
let snippets = [NormalCode(segment)];
assert_eq!(run("", segment), snippets);
assert_eq!(run(" ", segment), snippets);
assert_eq!(run(" \t ", segment), snippets);
}
#[test]
fn test_highlight_code_segment() {
assert!(run("make", "").is_empty());
assert_eq!(
&run("make", "make all CC=clang -q"),
&[CommandName("make"), NormalCode(" all CC=clang -q")]
);
assert_eq!(
&run("make", " make money --always-make"),
&[
NormalCode(" "),
CommandName("make"),
NormalCode(" money --always-make")
]
);
assert_eq!(
&run("git commit", "git commit -m 'git commit'"),
&[CommandName("git commit"), NormalCode(" -m 'git commit'"),]
);
}
mod placeholders {
use super::*;
use PageSnippet::*;
#[test]
fn test_i18n() {
assert_eq!(
&run("mäke", "mäke höhlenrätselbücher"),
&[CommandName("mäke"), NormalCode(" höhlenrätselbücher")]
);
assert_eq!(
&run(
"Müll",
"1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich"
),
&[
NormalCode("1000 Gründe warum "),
CommandName("Müll"),
NormalCode(" heute größer ist als "),
CommandName("Müll"),
NormalCode(" früher, ärgerlich")
]
);
assert_eq!(
&run(
"übergang",
"die Zustandsübergangsfunktion übergang Änderungen",
),
&[
NormalCode("die Zustandsübergangsfunktion "),
CommandName("übergang"),
NormalCode(" Änderungen")
],
);
}
#[test]
fn variable_vs_escaped() {
assert_eq!(
run("ping", "ping {{example.com}}"),
[
CommandName("ping"),
NormalCode(" "),
Variable("example.com"),
],
);
assert_eq!(
run(
"docker inspect",
r"docker inspect --format '\{\{range.NetworkSettings.Networks\}\}\{\{.IPAddress\}\}\{\{end\}\}' {{container}}"
),
[
CommandName("docker inspect"),
NormalCode(
" --format '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "
),
Variable("container"),
],
);
assert_eq!(
run("mount", r"mount \\{{computer_name}}\{{share_name}} Z:"),
[
CommandName("mount"),
NormalCode(r" \\"),
Variable("computer_name"),
NormalCode(r"\"),
Variable("share_name"),
NormalCode(" Z:"),
],
);
#[test]
fn test_empty_command() {
let segment = "some code";
let snippets = [NormalCode(segment)];
assert_eq!(run("", r"\{"), [NormalCode(r"\{")]);
assert_eq!(run("", r"\{{a"), [NormalCode(r"\{{a")]);
assert_eq!(run("", r"\{{a}}"), [NormalCode(r"\"), Variable("a")]);
// Placeholder has begin marker, but no end marker
assert_eq!(run("", r"{{\}\}}"), [NormalCode("{{}}}")]);
}
#[test]
fn outer_precedence() {
assert_eq!(
run("git stash", "git stash show --patch {{stash@{0}}}"),
[
CommandName("git stash"),
NormalCode(" show --patch "),
Variable("stash@{0}"),
],
);
// The following is not listed in the specification, but this is the highlighting I would expect.
assert_eq!(
run("rg", "rg {{}}}"),
[CommandName("rg"), NormalCode(" "), Variable("}")]
);
// And these are just to document the current behavior
assert_eq!(run("", "{{{}}}"), [Variable("{}")]);
assert_eq!(run("", "{{{{}}}"), [Variable("{{}")]);
assert_eq!(run("", "{{{}}}}"), [Variable("{}}")]);
}
#[test]
fn escaped_inside_placeholder() {
assert_eq!(
run(
"playerctl",
r#"playerctl metadata {{[-f|--format]}} "{{Now playing: \{\{artist\}\} - \{\{album\}\} - \{\{title\}\}}}""#
),
[
CommandName("playerctl"),
NormalCode(" metadata "),
Variable("[-f|--format]"),
NormalCode(" \""),
Variable("Now playing: {{artist}} - {{album}} - {{title}}"),
NormalCode("\""),
],
);
}
#[test]
fn placeholder_inside_escaped() {
assert_eq!(
run("test", r"test \{\{{{var}} normal\}\}"),
[
CommandName("test"),
NormalCode(" {{"),
Variable("var"),
NormalCode(" normal}}"),
],
);
}
#[test]
/// Regression test for <https://github.com/tealdeer-rs/tealdeer/issues/473>
fn prefix_check_character_boundary() {
assert_eq!("Ä".len(), 2);
assert_eq!(run("", r"Äxx{{x}}"), [NormalCode("Äxx"), Variable("x")],);
}
assert_eq!(run("", segment), snippets);
assert_eq!(run(" ", segment), snippets);
assert_eq!(run(" \t ", segment), snippets);
}
}

View file

@ -53,7 +53,7 @@ impl<R: BufRead> Iterator for LineIterator<R> {
match bytes_read {
Ok(0) => None,
Err(e) => {
warn!("Could not read line from reader: {e:?}");
warn!("Could not read line from reader: {:?}", e);
None
}
Ok(_) => {
@ -68,7 +68,7 @@ impl<R: BufRead> Iterator for LineIterator<R> {
.find(|b| matches!(b, Ok(b'\n') | Err(_)))
.transpose()
{
warn!("Could not read line from reader: {e:?}");
warn!("Could not read line from reader: {:?}", e);
return None;
}
self.first_line = false;

View file

@ -15,32 +15,29 @@
#![allow(clippy::similar_names)]
#![allow(clippy::struct_excessive_bools)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::unnecessary_debug_formatting)]
#![allow(clippy::while_let_loop)]
#[cfg(not(any(
feature = "native-tls",
feature = "rustls-with-webpki-roots",
feature = "rustls-with-native-roots",
)))]
#[cfg(any(
all(feature = "native-roots", feature = "webpki-roots"),
all(feature = "native-roots", feature = "native-tls"),
all(feature = "webpki-roots", feature = "native-tls"),
not(any(
feature = "native-roots",
feature = "webpki-roots",
feature = "native-tls"
)),
))]
compile_error!(
"at least one of the features \"native-tls\", \"rustls-with-webpki-roots\" or \"rustls-with-native-roots\" must be enabled"
"exactly one of the features \"native-roots\", \"webpki-roots\" or \"native-tls\" must be enabled"
);
use std::{
env,
fs::create_dir_all,
io::{self, IsTerminal},
path::Path,
process::{Command, ExitCode},
process,
};
use anyhow::{anyhow, Context, Result};
use cache::{CacheConfig, TLDR_OLD_PAGES_DIR};
use app_dirs::AppInfo;
use clap::Parser;
use config::{ConfigLoader, Language, StyleConfig, TlsBackend};
use log::debug;
use types::PlatformType;
mod cache;
mod cli;
@ -53,67 +50,118 @@ mod types;
mod utils;
use crate::{
cache::{Cache, PageLookupResult, TLDR_PAGES_DIR},
cache::{Cache, CacheFreshness, PageLookupResult, TLDR_PAGES_DIR},
cli::Cli,
config::{
get_config_dir, make_default_config, supported_tls_backends_string, Config, PathWithSource,
},
config::{get_config_dir, get_config_path, make_default_config, Config, PathWithSource},
extensions::Dedup,
output::print_page,
types::ColorOptions,
types::{ColorOptions, PlatformType},
utils::{print_error, print_warning},
};
const NAME: &str = "tealdeer";
static TEALDEER_PAGE: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/pages/tealdeer.md"));
const APP_INFO: AppInfo = AppInfo {
name: NAME,
author: NAME,
};
const ARCHIVE_URL: &str = "https://tldr.sh/assets/tldr.zip";
/// The cache should be updated if it was explicitly requested,
/// or if an automatic update is due and allowed.
fn should_update_cache(cache: &Cache, args: &Cli, config: &Config) -> bool {
args.update
|| (!args.no_auto_update
&& config.updates.auto_update
&& cache
.last_update()
.map_or(true, |ago| ago >= config.updates.auto_update_interval))
}
#[derive(PartialEq)]
enum CheckCacheResult {
CacheFound,
CacheMissing,
}
/// Check the cache for freshness. If it's stale or missing, show a warning.
fn check_cache(cache: &Cache, args: &Cli, enable_styles: bool) -> CheckCacheResult {
match cache.freshness() {
CacheFreshness::Fresh => CheckCacheResult::CacheFound,
CacheFreshness::Stale(_) if args.quiet => CheckCacheResult::CacheFound,
CacheFreshness::Stale(age) => {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
CheckCacheResult::CacheFound
}
CacheFreshness::Missing => {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html");
CheckCacheResult::CacheMissing
}
}
}
/// Clear the cache
fn clear_cache(cache: Cache, quietly: bool) -> Result<()> {
let cache_dir = cache.config().pages_directory.display();
cache.clear().context("Could not clear cache")?;
fn clear_cache(cache: &Cache, quietly: bool, enable_styles: bool) {
let cache_dir_found = cache.clear().unwrap_or_else(|e| {
print_error(enable_styles, &e.context("Could not clear cache"));
process::exit(1);
});
if !quietly {
eprintln!("Successfully cleared cache at `{cache_dir}`.");
let cache_dir = cache.cache_dir().display();
if cache_dir_found {
eprintln!("Successfully cleared cache at `{cache_dir}`.");
} else {
eprintln!("Cache directory not found at `{cache_dir}`, nothing to do.");
}
}
Ok(())
}
/// Update the cache
fn update_cache(
cache: &mut Cache,
archive_source: &str,
tls_backend: TlsBackend,
quietly: bool,
) -> Result<()> {
let downloaded_languages = cache
.update(archive_source, tls_backend)
.context("Could not update cache")?;
fn update_cache(cache: &Cache, quietly: bool, enable_styles: bool) {
cache.update(ARCHIVE_URL).unwrap_or_else(|e| {
print_error(enable_styles, &e.context("Could not update cache"));
process::exit(1);
});
if !quietly {
eprintln!("Successfully updated cache.");
eprint!("Pages for the following languages were downloaded: ");
let language_strings: Vec<_> = downloaded_languages
.into_iter()
.map(|lang| lang.0)
.collect();
if language_strings.is_empty() {
eprintln!("(none)");
} else {
eprintln!("{}", language_strings.join(", "));
}
}
Ok(())
}
/// Show file paths
fn show_paths(config: &Config) {
let config_dir = {
let (mut path, source) = get_config_dir();
path.push(""); // Trailing path separator
match path.to_str() {
Some(path) => format!("{path} ({source})"),
None => "[Invalid]".to_string(),
}
};
let config_path = config.file_path.to_string();
let config_dir = get_config_dir().map_or_else(
|e| format!("[Error: {e}]"),
|(mut path, source)| {
path.push(""); // Trailing path separator
match path.to_str() {
Some(path) => format!("{path} ({source})"),
None => "[Invalid]".to_string(),
}
},
);
let config_path = get_config_path().map_or_else(
|e| format!("[Error: {e}]"),
|(path, _)| path.display().to_string(),
);
let cache_dir = config.directories.cache_dir.to_string();
let pages_dir = {
let mut path = config.directories.cache_dir.path.clone();
@ -132,13 +180,21 @@ fn show_paths(config: &Config) {
println!("Custom pages dir: {custom_pages_dir}");
}
fn create_config(path: Option<&Path>) -> Result<()> {
let config_file_path = make_default_config(path).context("Could not create seed config")?;
eprintln!(
"Successfully created seed config file here: {}",
config_file_path.to_str().unwrap()
);
Ok(())
/// Create seed config file and exit
fn create_config_and_exit(enable_styles: bool) {
match make_default_config() {
Ok(config_file_path) => {
eprintln!(
"Successfully created seed config file here: {}",
config_file_path.to_str().unwrap()
);
process::exit(0);
}
Err(e) => {
print_error(enable_styles, &e.context("Could not create seed config"));
process::exit(1);
}
}
}
#[cfg(feature = "logging")]
@ -149,28 +205,43 @@ fn init_log() {
#[cfg(not(feature = "logging"))]
fn init_log() {}
fn spawn_editor(custom_pages_dir: &Path, file_name: &str) -> Result<()> {
create_dir_all(custom_pages_dir).context("Failed to create custom pages directory")?;
fn get_languages(env_lang: Option<&str>, env_language: Option<&str>) -> Vec<String> {
// Language list according to
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language
let custom_page_path = custom_pages_dir.join(file_name);
let Some(custom_page_path) = custom_page_path.to_str() else {
return Err(anyhow!("`custom_page_path.to_str()` failed"));
};
let Ok(editor) = env::var("EDITOR") else {
return Err(anyhow!(
"To edit a custom page, please set the `EDITOR` environment variable."
));
};
println!("Editing {custom_page_path:?}");
let status = Command::new(&editor).arg(custom_page_path).status()?;
if !status.success() {
return Err(anyhow!("{editor} exit with code {:?}", status.code()));
if env_lang.is_none() {
return vec!["en".to_string()];
}
Ok(())
let env_lang = env_lang.unwrap();
// Create an iterator that contains $LANGUAGE (':' separated list) followed by $LANG (single language)
let locales = env_language.unwrap_or("").split(':').chain([env_lang]);
let mut lang_list = Vec::new();
for locale in locales {
// Language plus country code (e.g. `en_US`)
if locale.len() >= 5 && locale.chars().nth(2) == Some('_') {
lang_list.push(&locale[..5]);
}
// Language code only (e.g. `en`)
if locale.len() >= 2 && locale != "POSIX" {
lang_list.push(&locale[..2]);
}
}
lang_list.push("en");
lang_list.clear_duplicates();
lang_list.into_iter().map(str::to_string).collect()
}
fn main() -> ExitCode {
fn get_languages_from_env() -> Vec<String> {
get_languages(
std::env::var("LANG").ok().as_deref(),
std::env::var("LANGUAGE").ok().as_deref(),
)
}
fn main() {
// Initialize logger
init_log();
@ -192,54 +263,14 @@ fn main() -> ExitCode {
ColorOptions::Never => false,
};
try_main(args, enable_styles).unwrap_or_else(|error| {
print_error(enable_styles, &error);
ExitCode::FAILURE
})
}
fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
// Look up config file, if none is found fall back to default config.
debug!("Loading config");
let config_loader = match &args.config_path {
Some(path) if !args.seed_config => {
ConfigLoader::read(path.clone()).context("Could not read config from given path")?
}
_ => {
ConfigLoader::read_default_path().context("Could not read config from default path")?
let config = match Config::load(enable_styles) {
Ok(config) => config,
Err(e) => {
print_error(enable_styles, &e.context("Could not load config"));
process::exit(1);
}
};
let mut config = config_loader.load()?;
// Override styles if needed
if !enable_styles {
config.style = StyleConfig::default();
}
let custom_pages_dir = config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path);
// Note: According to the TLDR client spec, page names must be transparently
// lowercased before lookup:
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#page-names
let command = args.command.join("-").to_lowercase();
if args.edit_patch || args.edit_page {
let file_name = if args.edit_patch {
format!("{command}.patch.md")
} else {
format!("{command}.page.md")
};
custom_pages_dir
.context("To edit custom pages/patches, please specify a custom pages directory.")
.and_then(|custom_pages_dir| spawn_editor(custom_pages_dir, &file_name))?;
return Ok(ExitCode::SUCCESS);
}
// Show various paths
if args.show_paths {
@ -248,197 +279,156 @@ fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
// Create a basic config and exit
if args.seed_config {
create_config(args.config_path.as_deref())?;
return Ok(ExitCode::SUCCESS);
create_config_and_exit(enable_styles);
}
let fallback_platforms: &[PlatformType] = &[PlatformType::current()];
let platforms = args
.platforms
.as_ref()
.map_or(fallback_platforms, Vec::as_slice);
// If a local file was passed in, render it and exit
if let Some(file) = args.render {
let reader = PageLookupResult::with_page(file).reader()?;
print_page(reader, args.raw, enable_styles, args.pager, &config)?;
return Ok(ExitCode::SUCCESS);
let path = PageLookupResult::with_page(file);
if let Err(ref e) = print_page(&path, args.raw, enable_styles, args.pager, &config) {
print_error(enable_styles, e);
process::exit(1);
} else {
process::exit(0);
};
}
// The tealdeer page is embedded in the binary, no cache needed
if command == "tealdeer" {
print_page(
TEALDEER_PAGE.as_bytes(),
args.raw,
enable_styles,
args.pager,
&config,
)?;
return Ok(ExitCode::SUCCESS);
// Instantiate cache. This will not yet create the cache directory!
let cache = Cache::new(&config.directories.cache_dir.path, enable_styles);
// Clear cache, pass through
if args.clear_cache {
clear_cache(&cache, args.quiet, enable_styles);
}
if let Some(platforms) = args.platforms {
config.search.platforms = platforms;
if !config.search.platforms.contains(&PlatformType::Common) {
config.search.platforms.push(PlatformType::Common);
}
}
let (search_languages, download_languages): (&[_], &[_]) = match args.language.as_deref() {
Some(lang) => (&[Language(lang)], &[Language(lang)]),
None => (&config.search.languages, &config.updates.download_languages),
// Cache update, pass through
let cache_updated = if should_update_cache(&cache, &args, &config) {
update_cache(&cache, args.quiet, enable_styles);
true
} else {
false
};
let cache_config = CacheConfig {
pages_directory: &config.directories.cache_dir.path().join(TLDR_PAGES_DIR),
custom_pages_directory: config
// Check cache presence and freshness
if !cache_updated
&& (args.list || !args.command.is_empty())
&& check_cache(&cache, &args, enable_styles) == CheckCacheResult::CacheMissing
{
process::exit(1);
}
// List cached commands and exit
if args.list {
let custom_pages_dir = config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
platforms: &config.search.platforms,
search_languages,
download_languages,
};
// TODO: remove in tealdeer 1.9
let old_config = CacheConfig {
pages_directory: &config.directories.cache_dir.path().join(TLDR_OLD_PAGES_DIR),
..cache_config
};
if let Ok(Some(old_cache)) = Cache::open(old_config) {
old_cache.clear()?;
eprintln!("Cleared pages from old cache location.");
}
if args.clear_cache {
if let Some(cache) = Cache::open(cache_config)? {
clear_cache(cache, args.quiet)?;
}
return Ok(ExitCode::SUCCESS);
}
let cache = if args.update || config.updates.auto_update && !args.no_auto_update {
let (mut cache, was_created) = Cache::open_or_create(cache_config)?;
if was_created || args.update || cache.age()? >= config.updates.auto_update_interval {
let result = update_cache(
&mut cache,
config.updates.archive_source,
config.updates.tls_backend,
args.quiet,
);
if let Err(e) = result {
print_error(enable_styles, &e);
eprintln!();
eprintln!("Note: Update errors are often caused by unexpected or missing TLS certificates.");
eprintln!(
"You are currently using the following TLS backend: {}",
config.updates.tls_backend,
);
eprintln!(
"Try changing the updates.tls_backend setting in the config file, for example:"
);
eprintln!();
eprintln!(" [updates]");
eprintln!(" tls_backend = \"rustls-with-native-roots\"");
eprintln!();
eprintln!(
"This build of tealdeer has support for the following options: {}",
supported_tls_backends_string(),
);
return Ok(ExitCode::FAILURE);
}
}
cache
} else if args.list || !command.is_empty() {
// Cache is needed for these commands to work
let Some(cache) = Cache::open(cache_config)? else {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html");
return Ok(ExitCode::FAILURE);
};
if let Some(max_cache_age) = config.updates.warn_cache_age {
let age = cache.age()?;
if age > max_cache_age && !args.quiet {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
}
}
cache
} else {
// There is nothing left to do
return Ok(ExitCode::SUCCESS);
};
if args.list {
for page in cache.list_pages()? {
println!("{page}");
}
return Ok(ExitCode::SUCCESS);
.map(PathWithSource::path);
println!(
"{}",
cache.list_pages(custom_pages_dir, platforms).join("\n")
);
process::exit(0);
}
// Show command from cache
if !command.is_empty() {
// TODO: Remove this check 1 year after version 1.7.0 was released
if cache.old_custom_pages_exist()? {
print_warning(
enable_styles,
&format!(
"Custom pages using the old naming convention were found in {}.\n\
Please rename them to follow the new convention:\n\
- `<name>.page` `<name>.page.md`\n\
- `<name>.patch` `<name>.patch.md`",
cache
.config()
.custom_pages_directory
.expect("Old custom pages can only exist in custom pages directory")
.display(),
),
);
}
if !args.command.is_empty() {
// Note: According to the TLDR client spec, page names must be transparently
// lowercased before lookup:
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#page-names
let command = args.command.join("-").to_lowercase();
let Some(result) = cache.find_page(&command) else {
// Collect languages
let languages = args
.language
.map_or_else(get_languages_from_env, |lang| vec![lang]);
// Search for command in cache
if let Some(lookup_result) = cache.find_page(
&command,
&languages,
config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
platforms,
) {
if let Err(ref e) =
print_page(&lookup_result, args.raw, enable_styles, args.pager, &config)
{
print_error(enable_styles, e);
process::exit(1);
}
process::exit(0);
} else {
if !args.quiet {
print_warning(
enable_styles,
&format!(
"Page `{command}` not found in cache.\n\
"Page `{}` not found in cache.\n\
Try updating with `tldr --update`, or submit a pull request to:\n\
https://github.com/tldr-pages/tldr"
https://github.com/tldr-pages/tldr",
&command
),
);
}
return Ok(ExitCode::FAILURE);
};
print_page(
result.reader()?,
args.raw,
enable_styles,
args.pager,
&config,
)?;
process::exit(1);
}
}
}
#[cfg(test)]
mod test {
use crate::get_languages;
mod language {
use super::*;
#[test]
fn missing_lang_env() {
let lang_list = get_languages(None, Some("de:fr"));
assert_eq!(lang_list, ["en"]);
let lang_list = get_languages(None, None);
assert_eq!(lang_list, ["en"]);
}
#[test]
fn missing_language_env() {
let lang_list = get_languages(Some("de"), None);
assert_eq!(lang_list, ["de", "en"]);
}
#[test]
fn preference_order() {
let lang_list = get_languages(Some("de"), Some("fr:cn"));
assert_eq!(lang_list, ["fr", "cn", "de", "en"]);
}
#[test]
fn country_code_expansion() {
let lang_list = get_languages(Some("pt_BR"), None);
assert_eq!(lang_list, ["pt_BR", "pt", "en"]);
}
#[test]
fn ignore_posix_and_c() {
let lang_list = get_languages(Some("POSIX"), None);
assert_eq!(lang_list, ["en"]);
let lang_list = get_languages(Some("C"), None);
assert_eq!(lang_list, ["en"]);
}
#[test]
fn no_duplicates() {
let lang_list = get_languages(Some("de"), Some("fr:de:cn:de"));
assert_eq!(lang_list, ["fr", "de", "cn", "en"]);
}
}
Ok(ExitCode::SUCCESS)
}

View file

@ -1,11 +1,12 @@
//! Functions for printing pages to the terminal
use std::io::{self, BufRead, BufReader, Read, Write};
use std::io::{self, BufRead, Write};
use anyhow::{Context, Result};
use yansi::Paint;
use crate::{
cache::PageLookupResult,
config::{Config, StyleConfig},
formatter::{highlight_lines, PageSnippet},
line_iterator::LineIterator,
@ -29,13 +30,14 @@ fn configure_pager(enable_styles: bool) {
/// Print page by path
pub fn print_page(
reader: impl Read,
lookup_result: &PageLookupResult,
enable_markdown: bool,
enable_styles: bool,
use_pager: bool,
config: &Config,
) -> Result<()> {
let reader = BufReader::new(reader);
// Create reader from file(s)
let reader = lookup_result.reader()?;
// Configure pager if applicable
if use_pager || config.display.use_pager {
@ -54,7 +56,7 @@ pub fn print_page(
}
} else {
// Closure that processes a page snippet and writes it to stdout
let mut process_snippet = |snip: PageSnippet<&str>| {
let mut process_snippet = |snip: PageSnippet<'_>| {
if snip.is_empty() {
Ok(())
} else {
@ -67,11 +69,9 @@ pub fn print_page(
LineIterator::new(reader),
&mut process_snippet,
!config.display.compact,
config.display.show_title,
config.display.indent,
)
.context("Could not write to stdout")?;
}
};
// We're done outputting data, flush stdout now!
handle.flush().context("Could not flush stdout")?;
@ -81,17 +81,17 @@ pub fn print_page(
fn print_snippet(
writer: &mut impl Write,
snip: PageSnippet<&str>,
snip: PageSnippet<'_>,
style: &StyleConfig,
) -> io::Result<()> {
use PageSnippet::*;
match snip {
CommandName(s) | Title(s) => write!(writer, "{}", s.paint(style.command_name)),
CommandName(s) => write!(writer, "{}", s.paint(style.command_name)),
Variable(s) => write!(writer, "{}", s.paint(style.example_variable)),
NormalCode(s) => write!(writer, "{}", s.paint(style.example_code)),
Description(s) => write!(writer, "{}", s.paint(style.description)),
Text(s) => write!(writer, "{}", s.paint(style.example_text)),
Description(s) => writeln!(writer, " {}", s.paint(style.description)),
Text(s) => writeln!(writer, " {}", s.paint(style.example_text)),
Linebreak => writeln!(writer),
}
}

View file

@ -16,7 +16,6 @@ pub enum PlatformType {
FreeBsd,
NetBsd,
OpenBsd,
Common,
}
impl fmt::Display for PlatformType {
@ -30,7 +29,6 @@ impl fmt::Display for PlatformType {
Self::FreeBsd => write!(f, "FreeBSD"),
Self::NetBsd => write!(f, "NetBSD"),
Self::OpenBsd => write!(f, "OpenBSD"),
Self::Common => write!(f, "Common"),
}
}
}
@ -46,7 +44,6 @@ impl clap::ValueEnum for PlatformType {
Self::FreeBsd,
Self::NetBsd,
Self::OpenBsd,
Self::Common,
]
}
@ -60,7 +57,6 @@ impl clap::ValueEnum for PlatformType {
Self::FreeBsd => Some(clap::builder::PossibleValue::new("freebsd")),
Self::NetBsd => Some(clap::builder::PossibleValue::new("netbsd")),
Self::OpenBsd => Some(clap::builder::PossibleValue::new("openbsd")),
Self::Common => Some(clap::builder::PossibleValue::new("common")),
}
}
}
@ -118,14 +114,18 @@ impl PlatformType {
#[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ColorOptions {
Always,
#[default]
Auto,
Never,
}
impl Default for ColorOptions {
fn default() -> Self {
Self::Auto
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum LineType {
Empty,
@ -201,8 +201,6 @@ pub enum PathSource {
EnvVar,
/// Config file
ConfigFile,
/// CLI argument override
Cli,
}
impl fmt::Display for PathSource {
@ -214,7 +212,6 @@ impl fmt::Display for PathSource {
Self::OsConvention => "OS convention",
Self::EnvVar => "env variable",
Self::ConfigFile => "config file",
Self::Cli => "command line argument",
}
)
}

View file

@ -1,36 +0,0 @@
# git checkout
> Checkout a branch or paths to the working tree.
> More information: <https://git-scm.com/docs/git-checkout>.
- Create and switch to a new branch:
`git checkout -b {{branch_name}}`
- Create and switch to a new branch based on a specific reference (branch, remote/branch, tag are examples of valid references):
`git checkout -b {{branch_name}} {{reference}}`
- Switch to an existing local branch:
`git checkout {{branch_name}}`
- Switch to the previously checked out branch:
`git checkout -`
- Switch to an existing remote branch:
`git checkout --track {{remote_name}}/{{branch_name}}`
- Discard all unstaged changes in the current directory (see `git reset` for more undo-like commands):
`git checkout .`
- Discard unstaged changes to a given file:
`git checkout {{path/to/file}}`
- Replace a file in the current directory with the version of it committed in a given branch:
`git checkout {{branch_name}} -- {{path/to/file}}`

View file

@ -1,37 +0,0 @@
# apt
> Debian系ディストリビューションで使われるパッケージ管理システムです。
> Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。
> 詳しくはこちら: <https://manned.org/apt.8>
- 利用可能なパーケージとバージョンのリストの更新(他の`apt`コマンドの前での実行を推奨):
`sudo apt update`
- 指定されたパッケージの検索:
`apt search {{パッケージ}}`
- パッケージの情報を出力:
`apt show {{パッケージ}}`
- パッケージのインストール、または利用可能な最新バージョンに更新:
`sudo apt install {{パッケージ}}`
- パッケージの削除(`sudo apt remove --purge`の場合設定ファイルも削除):
`sudo apt remove {{パッケージ}}`
- インストールされている全てのパッケージを最新のバージョンにアップグレード:
`sudo apt upgrade`
- インストールできるすべてのパッケージを表示:
`apt list`
- インストールされた全てのパッケージを表示(依存関係も表示):
`apt list --installed`

32
tests/chmod.ru.expected Normal file
View file

@ -0,0 +1,32 @@
Изменить права доступа файлу или папке.
Больше информации: <https://www.gnu.org/software/coreutils/chmod>.
Дать [u]пользователю, который владеет файлом, права на его [x]исполнение:
 chmod u+x файл
Дать права [u]пользователю права [r]чтения и [w]записи в файл/папку:
 chmod u+rw файл_или_папка
Убрать права на [x]исполнение у [g]группы:
 chmod g-x файл
Дать [a]всем пользователям права на [r]чтение и [x]исполенеие:
 chmod a+rx файл
Дать [o]другим (не из группы владельцев файлом) такие же права как и у [g]группы:
 chmod o=g файл
Убрать все права у [o]других:
 chmod o= файл
Изменить права рекурсивно, дав [g]группе и [o]другим возможность [w]записи в папку:
 chmod -R g+w,o+w папка

32
tests/chmod.ru.md Normal file
View file

@ -0,0 +1,32 @@
# chmod
> Изменить права доступа файлу или папке.
> Больше информации: <https://www.gnu.org/software/coreutils/chmod>.
- Дать [u]пользователю, который владеет файлом, права на его [x]исполнение:
`chmod u+x {{файл}}`
- Дать права [u]пользователю права [r]чтения и [w]записи в файл/папку:
`chmod u+rw {{файл_или_папка}}`
- Убрать права на [x]исполнение у [g]группы:
`chmod g-x {{файл}}`
- Дать [a]всем пользователям права на [r]чтение и [x]исполенеие:
`chmod a+rx {{файл}}`
- Дать [o]другим (не из группы владельцев файлом) такие же права как и у [g]группы:
`chmod o=g {{файл}}`
- Убрать все права у [o]других:
`chmod o= {{файл}}`
- Изменить права рекурсивно, дав [g]группе и [o]другим возможность [w]записи в папку:
`chmod -R g+w,o+w {{папка}}`

View file

@ -19,3 +19,11 @@ underline = false
underline = true
bold = false
italic = true
[display]
use_pager = false
compact = false
[updates]
auto_update = false
auto_update_interval_hours = 720

View file

@ -2,31 +2,31 @@
An SVG (Scalable Vector Graphics) editing program.
Use -z to not open the GUI and only process files in the console.
 Open an SVG file in the Inkscape GUI:
Open an SVG file in the Inkscape GUI:
 inkscape filename.svg
 Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
 inkscape filename.svg -e filename.png
 Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
 inkscape filename.svg -e filename.png -w 600 -h 400
 Export a single object, given its ID, into a bitmap:
Export a single object, given its ID, into a bitmap:
 inkscape filename.svg -i id -e object.png
 Export an SVG document to PDF, converting all texts to paths:
Export an SVG document to PDF, converting all texts to paths:
 inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path
 Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
 inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit
 Some invalid command just to test the correct highlighting of the command name:
Some invalid command just to test the correct highlighting of the command name:
 inkscape --use-inkscape=v3.0 file

View file

@ -2,31 +2,31 @@
An SVG (Scalable Vector Graphics) editing program.
Use -z to not open the GUI and only process files in the console.
 Open an SVG file in the Inkscape GUI:
Open an SVG file in the Inkscape GUI:
inkscape filename.svg
 Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
inkscape filename.svg -e filename.png
 Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
inkscape filename.svg -e filename.png -w 600 -h 400
 Export a single object, given its ID, into a bitmap:
Export a single object, given its ID, into a bitmap:
inkscape filename.svg -i id -e object.png
 Export an SVG document to PDF, converting all texts to paths:
Export an SVG document to PDF, converting all texts to paths:
inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path
 Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit
 Some invalid command just to test the correct highlighting of the command name:
Some invalid command just to test the correct highlighting of the command name:
inkscape --use-inkscape=v3.0 file

File diff suppressed because it is too large Load diff

View file

@ -1,37 +0,0 @@
Debian系ディストリビューションで使われるパッケージ管理システムです。
Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。
詳しくはこちら: <https://manned.org/apt.8>
 利用可能なパーケージとバージョンのリストの更新他の`apt`コマンドの前での実行を推奨):
 sudo apt update
 指定されたパッケージの検索:
 apt search パッケージ
 パッケージの情報を出力:
 apt show パッケージ
 パッケージのインストール、または利用可能な最新バージョンに更新:
 sudo apt install パッケージ
 パッケージの削除`sudo apt remove --purge`の場合設定ファイルも削除):
 sudo apt remove パッケージ
 インストールされている全てのパッケージを最新のバージョンにアップグレード:
 sudo apt upgrade
 インストールできるすべてのパッケージを表示:
 apt list
 インストールされた全てのパッケージを表示依存関係も表示:
 apt list --installed

View file

@ -1,32 +0,0 @@
An SVG (Scalable Vector Graphics) editing program.
Use -z to not open the GUI and only process files in the console.
Open an SVG file in the Inkscape GUI:
inkscape filename.svg
Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
inkscape filename.svg -e filename.png
Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
inkscape filename.svg -e filename.png -w 600 -h 400
Export a single object, given its ID, into a bitmap:
inkscape filename.svg -i id -e object.png
Export an SVG document to PDF, converting all texts to paths:
inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path
Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit
Some invalid command just to test the correct highlighting of the command name:
inkscape --use-inkscape=v3.0 file

View file

@ -1,34 +0,0 @@
inkscape
An SVG (Scalable Vector Graphics) editing program.
Use -z to not open the GUI and only process files in the console.
Open an SVG file in the Inkscape GUI:
inkscape filename.svg
Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
inkscape filename.svg -e filename.png
Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
inkscape filename.svg -e filename.png -w 600 -h 400
Export a single object, given its ID, into a bitmap:
inkscape filename.svg -i id -e object.png
Export an SVG document to PDF, converting all texts to paths:
inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path
Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit
Some invalid command just to test the correct highlighting of the command name:
inkscape --use-inkscape=v3.0 file

View file

@ -1,34 +0,0 @@
 inkscape
An SVG (Scalable Vector Graphics) editing program.
Use -z to not open the GUI and only process files in the console.
 Open an SVG file in the Inkscape GUI:
 inkscape filename.svg
 Export an SVG file into a bitmap with the default format (PNG) and the default resolution (90 DPI):
 inkscape filename.svg -e filename.png
 Export an SVG file into a bitmap of 600x400 pixels (aspect ratio distortion may occur):
 inkscape filename.svg -e filename.png -w 600 -h 400
 Export a single object, given its ID, into a bitmap:
 inkscape filename.svg -i id -e object.png
 Export an SVG document to PDF, converting all texts to paths:
 inkscape filename.svg | inkscape | inkscape --export-pdf=inkscape.pdf | inkscape | inkscape --export-text-to-path
 Duplicate the object with id="path123", rotate the duplicate 90 degrees, save the file, and quit Inkscape:
 inkscape filename.svg --select=path123 --verb=EditDuplicate --verb=ObjectRotate90 --verb=FileSave --verb=FileQuit
 Some invalid command just to test the correct highlighting of the command name:
 inkscape --use-inkscape=v3.0 file