diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93b83e4..faf3111 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - toolchain: [stable, 1.85.0] + toolchain: [stable, 1.87.0] # MSRV include: - platform: windows-latest exe_suffix: .exe runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.toolchain }} @@ -39,7 +39,7 @@ jobs: # 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@v4 + - uses: actions/upload-artifact@v7 with: name: tldr-debug-build-${{ matrix.platform }}-rust-${{ matrix.toolchain }} path: artifacts/ @@ -50,19 +50,19 @@ jobs: name: run clippy lints runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@master with: toolchain: stable components: clippy - name: run clippy lints - run: cargo clippy --features logging + run: cargo clippy --all-targets --features logging fmt: name: run rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@master with: toolchain: stable @@ -74,7 +74,7 @@ jobs: name: build docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Setup mdBook uses: peaceiris/actions-mdbook@v2 with: diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 8746d42..ae9ae93 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -3,12 +3,13 @@ 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@v5 + - uses: actions/checkout@v7 - name: Setup mdBook uses: peaceiris/actions-mdbook@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 360dd94..9337d6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ jobs: create-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - 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@v5 + - uses: actions/checkout@v7 - name: Upload completion if: startsWith(github.ref, 'refs/tags/') run: | @@ -40,7 +40,7 @@ jobs: matrix: target: ["MIT", "APACHE"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Upload license if: startsWith(github.ref, 'refs/tags/') run: | @@ -66,14 +66,14 @@ jobs: - arch: "arm" libc: "musleabihf" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - 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@v4 + - uses: actions/upload-artifact@v7 with: name: "tealdeer-linux-${{ matrix.arch }}-${{ matrix.libc }}" path: "target/${{ matrix.arch }}-unknown-linux-${{ matrix.libc }}/release/tldr" @@ -86,7 +86,7 @@ jobs: - arch: "x86_64" - arch: "aarch64" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Setup toolchain uses: dtolnay/rust-toolchain@master with: @@ -94,7 +94,7 @@ jobs: targets: "${{ matrix.arch }}-apple-darwin" - name: Build run: cargo build --release --target ${{ matrix.arch }}-apple-darwin - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 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@v5 + - uses: actions/checkout@v7 - 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@v4 + - uses: actions/upload-artifact@v7 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@v5 - - uses: actions/download-artifact@v5 + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 - name: Upload binary if: startsWith(github.ref, 'refs/tags/') run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c479f..644a30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,56 @@ 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 @@ -556,12 +606,16 @@ 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 @@ -667,3 +721,4 @@ Thanks! [#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 diff --git a/Cargo.lock b/Cargo.lock index 661c998..4fba42b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,18 +73,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "app_dirs2" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e7b35733e3a8c1ccb90385088dd5b6eaa61325cb4d1ad56e683b5224ff352e" -dependencies = [ - "jni", - "ndk-context", - "winapi", - "xdg", -] - [[package]] name = "arbitrary" version = "1.4.2" @@ -151,6 +139,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -374,6 +368,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.1", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -629,12 +633,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1078,6 +1076,17 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1097,14 +1106,14 @@ dependencies = [ [[package]] name = "tealdeer" -version = "1.8.0" +version = "1.8.1" dependencies = [ "anyhow", - "app_dirs2", "assert_cmd", "clap", "env_logger", "escargot", + "etcetera", "filetime", "log", "pager", @@ -1236,6 +1245,7 @@ dependencies = [ "rustls-pemfile", "rustls-pki-types", "rustls-platform-verifier", + "socks", "ureq-proto", "utf-8", "webpki-root-certs", @@ -1616,12 +1626,6 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -[[package]] -name = "xdg" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" - [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 44c13ef..1d98992 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.0" +version = "1.8.1" include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"] -rust-version = "1.85" +rust-version = "1.87" # MSRV edition = "2021" [[bin]] @@ -21,13 +21,13 @@ 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" serde = "1.0.21" serde_derive = "1.0.21" -ureq = { version = "3.0.8", default-features = false, features = ["gzip"] } +ureq = { version = "3.0.8", default-features = false, features = ["gzip", "socks-proxy"] } toml = "0.8.19" yansi = "1" zip = { version = "5.1.1", default-features = false, features = ["deflate"] } diff --git a/README.md b/README.md index 230dfa8..859d06f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ 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 | | @@ -87,6 +86,17 @@ 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 @@ -113,7 +123,6 @@ 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 diff --git a/docs/src/config_display.md b/docs/src/config_display.md index 78656a4..007d64b 100644 --- a/docs/src/config_display.md +++ b/docs/src/config_display.md @@ -37,3 +37,35 @@ 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, +} +``` diff --git a/docs/src/config_updates.md b/docs/src/config_updates.md index 9acba55..a8a10c8 100644 --- a/docs/src/config_updates.md +++ b/docs/src/config_updates.md @@ -30,6 +30,18 @@ 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` diff --git a/docs/src/usage.txt b/docs/src/usage.txt index 7618ac4..6a04de7 100644 --- a/docs/src/usage.txt +++ b/docs/src/usage.txt @@ -1,4 +1,4 @@ -tealdeer 1.8.0: A fast TLDR client +tealdeer 1.8.1: A fast TLDR client Danilo Bargen , Niklas Mohrin Usage: tldr [OPTIONS] [COMMAND]... @@ -29,3 +29,5 @@ 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. diff --git a/pages/tealdeer.md b/pages/tealdeer.md new file mode 100644 index 0000000..948b278 --- /dev/null +++ b/pages/tealdeer.md @@ -0,0 +1,42 @@ +# tldr + +> This is a builtin page that shows information for your installed tealdeer version. +> More information: . + +> 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 ` + +- 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` diff --git a/src/cache.rs b/src/cache.rs index b181310..afa3603 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,6 +1,6 @@ use std::{ fs::{self, File}, - io::{BufReader, Cursor, ErrorKind, Read}, + io::{Cursor, ErrorKind, Read}, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; @@ -277,12 +277,12 @@ impl PageLookupResult { self } - /// Create a buffered reader that sequentially reads from the page and the + /// Create a 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>> { + pub fn reader(&self) -> Result> { // 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,11 +302,11 @@ 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(BufReader::new(if let Some(patch_file) = patch_file_opt { + Ok(if let Some(patch_file) = patch_file_opt { Box::new(page_file.chain(&b"\n"[..]).chain(patch_file)) as Box } else { Box::new(page_file) as Box - })) + }) } } @@ -369,7 +369,7 @@ impl Cache<'_> { } Ok(response) if response.status() == StatusCode::NOT_FOUND => Ok(None), _ => { - bail!("Could not download tldr pages from {archive_url}: {response:?}",) + bail!("Could not download tldr pages from {archive_url}: {response:?}") } } } diff --git a/src/cli.rs b/src/cli.rs index d461a3e..161d69d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,7 +18,9 @@ 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/.", + 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.", arg_required_else_help = true, help_expected = true, group = ArgGroup::new("command_or_file").args(&["command", "render"]), diff --git a/src/config.rs b/src/config.rs index e5e85e8..f0feeb4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,16 @@ use std::{ + borrow::Cow, env, fmt, fs::{self, File}, io::{ErrorKind, Write}, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, sync::LazyLock, time::Duration, }; -use anyhow::{anyhow, bail, ensure, Context, Result}; -use app_dirs::{get_app_root, AppDataType}; +use anyhow::{anyhow, ensure, Context, Result}; use clap::ValueEnum; +use log::info; use serde::Serialize as _; use serde_derive::{Deserialize, Serialize}; use yansi::{Color, Style}; @@ -31,10 +32,70 @@ const SUPPORTED_TLS_BACKENDS: &[RawTlsBackend] = &[ RawTlsBackend::RustlsWithNativeRoots, ]; +struct SystemDirectories { + config: PathBuf, + cache: PathBuf, + data: PathBuf, +} + +impl SystemDirectories { + fn discover() -> Result { + 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 = 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::>() + .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 } @@ -163,6 +224,25 @@ struct RawDisplayConfig { 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 { @@ -171,6 +251,10 @@ impl From<&RawDisplayConfig> for DisplayConfig { 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, + }, } } } @@ -184,7 +268,18 @@ const fn default_auto_update_interval_hours() -> u64 { } fn default_archive_source() -> String { - "https://github.com/tldr-pages/tldr/releases/latest/download/".to_owned() + "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)] @@ -199,6 +294,8 @@ struct RawUpdatesConfig { pub tls_backend: RawTlsBackend, #[serde(default)] pub download_languages: Option>, + #[serde(default)] + pub warn_cache_age: Option, } impl Default for RawUpdatesConfig { @@ -209,6 +306,7 @@ impl Default for RawUpdatesConfig { archive_source: default_archive_source(), tls_backend: RawTlsBackend::default(), download_languages: None, + warn_cache_age: None, } } } @@ -323,6 +421,13 @@ 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)] @@ -332,6 +437,7 @@ pub struct UpdatesConfig<'a> { pub archive_source: &'a str, pub tls_backend: TlsBackend, pub download_languages: Vec>, + pub warn_cache_age: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -383,6 +489,11 @@ fn get_languages<'a>( 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])); @@ -460,12 +571,31 @@ impl TryFrom for TlsBackend { _ => Err(anyhow!( "Unsupported TLS backend: {}. This tealdeer build has support for the following options: {}", raw, - SUPPORTED_TLS_BACKENDS.iter().map(std::string::ToString::to_string).collect::>().join(", ") + 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 style: StyleConfig, @@ -497,12 +627,17 @@ impl<'a> Config<'a> { || 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(); // Determine directories config. For this, we need to take some // additional factory into account, like env variables, or the @@ -518,42 +653,43 @@ impl<'a> Config<'a> { source: PathSource::EnvVar, } } else if let Some(config_value) = &raw_config.directories.cache_dir { - // If the user explicitly configured a cache directory, use that. + // 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); + PathWithSource { - // Resolve possible relative path. It would be nicer to clean up the path, but Rust stdlib - // does not give any method for that that does not need the paths to exist. - path: relative_path_root.join(config_value), + path: resolved_path, source: PathSource::ConfigFile, } - } else if let Ok(default_dir) = get_app_root(AppDataType::UserCache, &crate::APP_INFO) { - // Otherwise, fall back to the default user cache directory. + } else { PathWithSource { - path: default_dir, + path: SYSTEM_DIRECTORIES.cache.clone(), 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| PathWithSource { + .map(|path| -> Result { + // Resolve possible ~ prefixed path + let expanded_path = expand_home(path, home_path.as_deref())?; // Resolve possible relative path. - path: relative_path_root.join(path), - source: PathSource::ConfigFile, + let resolved_path = relative_path_root.join(expanded_path); + + Ok(PathWithSource { + path: resolved_path, + source: PathSource::ConfigFile, + }) }) + .transpose()? .or_else(|| { - 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() + // Note: The `join("")` call ensures that there's a trailing slash + Some(PathWithSource { + path: SYSTEM_DIRECTORIES.data.join("pages").join(""), + source: PathSource::OsConvention, + }) }); let directories = DirectoriesConfig { cache_dir, @@ -571,6 +707,29 @@ impl<'a> Config<'a> { } } +/// Expands tilde (~) prefixed directories into its absolute version +fn expand_home<'a>(input_path: &'a Path, home_path: Option<&Path>) -> Result> { + let mut components = input_path.components(); + + 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"))?; + + 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); + + return Ok(Cow::Owned(expanded)); + } else if first_component.starts_with('~') { + return Err(anyhow!("Tilde expansion with a login name not supported")); + } + } + + 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 @@ -619,7 +778,7 @@ impl ConfigLoader { /// 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 { - let path = get_default_config_path().context("Could not determine default config path.")?; + let path = get_default_config_path(); Self::read_internal(path, true) } @@ -637,30 +796,24 @@ 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() -> Result<(PathBuf, PathSource)> { +pub fn get_config_dir() -> (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 Ok((PathBuf::from(value), PathSource::EnvVar)); + return (PathBuf::from(value), PathSource::EnvVar); } - // 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)) + (SYSTEM_DIRECTORIES.config.clone(), 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() -> Result { - let (config_dir, source) = get_config_dir()?; - let config_file_path = config_dir.join(CONFIG_FILE_NAME); - Ok(PathWithSource { - path: config_file_path, - source, - }) +pub fn get_default_config_path() -> PathWithSource { + let (mut path, source) = get_config_dir(); + path.push(CONFIG_FILE_NAME); + PathWithSource { path, source } } /// Create default config file. @@ -670,7 +823,7 @@ pub fn make_default_config(path: Option<&Path>) -> Result { let config_file_path = if let Some(p) = path { p.into() } else { - let (config_dir, _) = get_config_dir()?; + let (config_dir, _) = get_config_dir(); // Ensure that config directory exists if config_dir.exists() { @@ -719,6 +872,63 @@ mod test { 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(); diff --git a/src/formatter.rs b/src/formatter.rs index 5270124..082f436 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -2,21 +2,54 @@ use log::debug; -use crate::{extensions::FindFrom, types::LineType}; +use crate::{config::Indent, extensions::FindFrom, types::LineType}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Eq)] /// Represents a snippet from a page of a specific highlighting class. -pub enum PageSnippet<'a> { - CommandName(&'a str), - Variable(&'a str), - NormalCode(&'a str), - Description(&'a str), - Text(&'a str), - Title(&'a str), +pub enum PageSnippet { + CommandName(T), + Variable(T), + NormalCode(T), + Description(T), + Text(T), + Title(T), Linebreak, } -impl PageSnippet<'_> { +#[cfg_attr(not(test), allow(dead_code))] +impl PageSnippet { + pub fn map(self, f: F) -> PageSnippet + 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, U> PartialEq> for PageSnippet { + fn eq(&self, other: &PageSnippet) -> 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> { pub fn is_empty(&self) -> bool { use PageSnippet::*; @@ -35,11 +68,14 @@ pub fn highlight_lines( process_snippet: &mut F, keep_empty_lines: bool, show_title: bool, + indent: Indent, ) -> Result<(), E> where L: Iterator, - F: for<'snip> FnMut(PageSnippet<'snip>) -> Result<(), E>, + F: for<'snip> FnMut(PageSnippet<&'snip str>) -> 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 { @@ -51,19 +87,29 @@ 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"); } // 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); + 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)?; } - LineType::Description(text) => process_snippet(PageSnippet::Description(&text))?, - LineType::ExampleText(text) => process_snippet(PageSnippet::Text(&text))?, LineType::ExampleCode(text) => { - process_snippet(PageSnippet::NormalCode(" "))?; + process_snippet(PageSnippet::NormalCode(&command_indent))?; highlight_code(&command, &text, process_snippet)?; process_snippet(PageSnippet::Linebreak)?; } @@ -75,29 +121,86 @@ where Ok(()) } -/// 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>, +/// Highlight code examples. +/// - parse placeholders (`{{ curly braces }}`) +/// - replace escaped placeholder markers (`\{\{` and `\}\}`) +fn highlight_code( + command: &str, + mut text: &str, + process_snippet: &mut impl FnMut(PageSnippet<&str>) -> Result<(), E>, ) -> Result<(), E> { - 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))?; + // 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..]; } + + 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 { + 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>) -> Result<(), E>, + process_snippet: &mut impl FnMut(PageSnippet<&'a str>) -> Result<(), E>, ) -> Result<(), E> { if !command_name.is_empty() { let mut search_start = 0; @@ -127,20 +230,17 @@ fn is_freestanding_substring(surrounding: &str, substring: (usize, usize)) -> bo let char_before_is_okay = surrounding[..start] .chars() .last() - .filter(|prev_char| !prev_char.is_whitespace()) - .is_none(); + .is_none_or(char::is_whitespace); let char_after_is_okay = surrounding[end..] .chars() .next() - .filter(|next_char| !next_char.is_whitespace()) - .is_none(); + .is_none_or(char::is_whitespace); char_before_is_okay && char_after_is_okay } #[cfg(test)] mod tests { use super::*; - use PageSnippet::*; #[test] fn test_is_freestanding_substring() { @@ -167,80 +267,193 @@ mod tests { )); } - fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec> { + fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec> { let mut yielded = Vec::new(); - let mut process_snippet = |snip: PageSnippet<'a>| { + let mut process_snippet = |snip: PageSnippet<&str>| { if !snip.is_empty() { - yielded.push(snip); + yielded.push(snip.map(str::to_string)); } Ok::<(), ()>(()) }; - highlight_code_segment(cmd, segment, &mut process_snippet) - .expect("highlight code segment failed"); + highlight_code(cmd, segment, &mut process_snippet).expect("highlight code segment failed"); yielded } - #[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 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_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") - ], - ); - } + mod placeholders { + use super::*; + use PageSnippet::*; - #[test] - fn test_empty_command() { - let segment = "some code"; - let snippets = [NormalCode(segment)]; + #[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:"), + ], + ); - assert_eq!(run("", segment), snippets); - assert_eq!(run(" ", segment), snippets); - assert_eq!(run(" \t ", segment), snippets); + 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 + fn prefix_check_character_boundary() { + assert_eq!("Ä".len(), 2); + assert_eq!(run("", r"Äxx{{x}}"), [NormalCode("Äxx"), Variable("x")],); + } } } diff --git a/src/main.rs b/src/main.rs index a0cd593..6678ceb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ #![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", @@ -35,7 +36,6 @@ use std::{ }; use anyhow::{anyhow, Context, Result}; -use app_dirs::AppInfo; use cache::{CacheConfig, TLDR_OLD_PAGES_DIR}; use clap::Parser; use config::{ConfigLoader, Language, StyleConfig, TlsBackend}; @@ -55,17 +55,17 @@ mod utils; use crate::{ cache::{Cache, PageLookupResult, TLDR_PAGES_DIR}, cli::Cli, - config::{get_config_dir, make_default_config, Config, PathWithSource}, + config::{ + get_config_dir, make_default_config, supported_tls_backends_string, Config, PathWithSource, + }, output::print_page, types::ColorOptions, utils::{print_error, print_warning}, }; const NAME: &str = "tealdeer"; -const APP_INFO: AppInfo = AppInfo { - name: NAME, - author: NAME, -}; +static TEALDEER_PAGE: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/pages/tealdeer.md")); /// Clear the cache fn clear_cache(cache: Cache, quietly: bool) -> Result<()> { @@ -105,16 +105,14 @@ fn update_cache( /// Show file paths fn show_paths(config: &Config) { - 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_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 cache_dir = config.directories.cache_dir.to_string(); let pages_dir = { @@ -256,8 +254,20 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { // If a local file was passed in, render it and exit if let Some(file) = args.render { - let path = PageLookupResult::with_page(file); - print_page(&path, args.raw, enable_styles, args.pager, &config)?; + let reader = PageLookupResult::with_page(file).reader()?; + print_page(reader, args.raw, enable_styles, args.pager, &config)?; + return Ok(ExitCode::SUCCESS); + } + + // 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); } @@ -305,12 +315,36 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { 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 { - update_cache( + 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 @@ -335,16 +369,18 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { return Ok(ExitCode::FAILURE); }; - let age = cache.age()?; - if age > config::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 - ), - ); + 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 @@ -381,23 +417,27 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { ); } - let Some(lookup_result) = cache.find_page(&command) else { + let Some(result) = cache.find_page(&command) else { if !args.quiet { print_warning( enable_styles, &format!( - "Page `{}` not found in cache.\n\ + "Page `{command}` not found in cache.\n\ Try updating with `tldr --update`, or submit a pull request to:\n\ - https://github.com/tldr-pages/tldr", - &command + https://github.com/tldr-pages/tldr" ), ); } - return Ok(ExitCode::FAILURE); }; - print_page(&lookup_result, args.raw, enable_styles, args.pager, &config)?; + print_page( + result.reader()?, + args.raw, + enable_styles, + args.pager, + &config, + )?; } Ok(ExitCode::SUCCESS) diff --git a/src/output.rs b/src/output.rs index 5f1aeae..6243b44 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,12 +1,11 @@ //! Functions for printing pages to the terminal -use std::io::{self, BufRead, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use anyhow::{Context, Result}; use yansi::Paint; use crate::{ - cache::PageLookupResult, config::{Config, StyleConfig}, formatter::{highlight_lines, PageSnippet}, line_iterator::LineIterator, @@ -30,14 +29,13 @@ fn configure_pager(enable_styles: bool) { /// Print page by path pub fn print_page( - lookup_result: &PageLookupResult, + reader: impl Read, enable_markdown: bool, enable_styles: bool, use_pager: bool, config: &Config, ) -> Result<()> { - // Create reader from file(s) - let reader = lookup_result.reader()?; + let reader = BufReader::new(reader); // Configure pager if applicable if use_pager || config.display.use_pager { @@ -56,7 +54,7 @@ pub fn print_page( } } else { // Closure that processes a page snippet and writes it to stdout - let mut process_snippet = |snip: PageSnippet<'_>| { + let mut process_snippet = |snip: PageSnippet<&str>| { if snip.is_empty() { Ok(()) } else { @@ -70,6 +68,7 @@ pub fn print_page( &mut process_snippet, !config.display.compact, config.display.show_title, + config.display.indent, ) .context("Could not write to stdout")?; } @@ -82,18 +81,17 @@ pub fn print_page( fn print_snippet( writer: &mut impl Write, - snip: PageSnippet<'_>, + snip: PageSnippet<&str>, style: &StyleConfig, ) -> io::Result<()> { use PageSnippet::*; match snip { - CommandName(s) => write!(writer, "{}", s.paint(style.command_name)), + CommandName(s) | Title(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) => writeln!(writer, " {}", s.paint(style.description)), - Text(s) => writeln!(writer, " {}", s.paint(style.example_text)), - Title(s) => writeln!(writer, " {}", s.paint(style.command_name)), + Description(s) => write!(writer, "{}", s.paint(style.description)), + Text(s) => write!(writer, "{}", s.paint(style.example_text)), Linebreak => writeln!(writer), } } diff --git a/tests/lib.rs b/tests/lib.rs index cb987db..d431b5f 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -301,6 +301,16 @@ fn test_missing_cache() { .stderr(contains("Page cache not found. Please run `tldr --update`")); } +#[test] +fn test_tealdeer_page_works_without_cache() { + TestEnv::new() + .command() + .args(["tealdeer"]) + .assert() + .success() + .stdout(contains("for your installed tealdeer version")); +} + #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] #[test] fn test_update_cache_default_features() { @@ -483,6 +493,26 @@ fn test_quiet_old_cache() { .stderr(contains("The cache hasn't been updated for ").not()); } +#[test] +fn test_warn_cache_age_never() { + let testenv = TestEnv::new().install_default_cache(); + + filetime::set_file_mtime( + testenv.cache_dir().join(TLDR_PAGES_DIR), + filetime::FileTime::from_unix_time(1, 0), + ) + .unwrap(); + + testenv.append_to_config("[updates]\nwarn_cache_age = \"never\"\n"); + + testenv + .command() + .args(["which"]) + .assert() + .success() + .stderr(contains("The cache hasn't been updated for ").not()); +} + #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")] #[test] fn test_create_cache_directory_path() { @@ -545,7 +575,7 @@ fn test_cache_location_permission_denied() { // Make cache directory unreadable let cache_dir = testenv.cache_dir(); let mut permissions = cache_dir.metadata().unwrap().permissions(); - permissions.set_mode(0); + permissions.set_mode(0o0); fs::set_permissions(cache_dir, permissions).unwrap(); testenv @@ -811,6 +841,24 @@ fn test_rendering_color_never() { ); } +/// An end-to-end integration test for the indent config option +#[test] +fn test_rendering_with_indentation() { + let testenv = TestEnv::new().install_default_cache(); + let expected_custom_indentation = include_str!("rendered/inkscape-compact-no-color.expected"); + + // Configure to set base and command indents + testenv.append_to_config("display.indent.base = 3\n"); + testenv.append_to_config("display.indent.command = 1\n"); + + testenv + .command() + .args(["--color", "never", "inkscape-v2"]) + .assert() + .success() + .stdout(diff(expected_custom_indentation)); +} + #[test] fn test_rendering_i18n() { _test_correct_rendering( @@ -1019,6 +1067,7 @@ fn test_search_language_precedence() { testenv.add_lang_entry(lang, lang, ""); } + #[expect(clippy::type_complexity)] let run = |cases: &[(Vec<(&str, &str)>, Vec<&str>, &str)]| { for (extra_env, extra_args, expected) in cases { let mut cmd = testenv.command(); diff --git a/tests/rendered/apt.ja.expected b/tests/rendered/apt.ja.expected index 22424e3..efdd35d 100644 --- a/tests/rendered/apt.ja.expected +++ b/tests/rendered/apt.ja.expected @@ -3,35 +3,35 @@ Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。 詳しくはこちら: - 利用可能なパーケージとバージョンのリストの更新(他の`apt`コマンドの前での実行を推奨): + 利用可能なパーケージとバージョンのリストの更新(他の`apt`コマンドの前での実行を推奨):  sudo apt update - 指定されたパッケージの検索: + 指定されたパッケージの検索:  apt search パッケージ - パッケージの情報を出力: + パッケージの情報を出力:  apt show パッケージ - パッケージのインストール、または利用可能な最新バージョンに更新: + パッケージのインストール、または利用可能な最新バージョンに更新:  sudo apt install パッケージ - パッケージの削除(`sudo apt remove --purge`の場合設定ファイルも削除): + パッケージの削除(`sudo apt remove --purge`の場合設定ファイルも削除):  sudo apt remove パッケージ - インストールされている全てのパッケージを最新のバージョンにアップグレード: + インストールされている全てのパッケージを最新のバージョンにアップグレード:  sudo apt upgrade - インストールできるすべてのパッケージを表示: + インストールできるすべてのパッケージを表示:  apt list - インストールされた全てのパッケージを表示(依存関係も表示): + インストールされた全てのパッケージを表示(依存関係も表示):  apt list --installed diff --git a/tests/rendered/inkscape-compact-no-color.expected b/tests/rendered/inkscape-compact-no-color.expected new file mode 100644 index 0000000..473bbe9 --- /dev/null +++ b/tests/rendered/inkscape-compact-no-color.expected @@ -0,0 +1,32 @@ + + 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 + diff --git a/tests/rendered/inkscape-default.expected b/tests/rendered/inkscape-default.expected index da909f2..3b37f0e 100644 --- a/tests/rendered/inkscape-default.expected +++ b/tests/rendered/inkscape-default.expected @@ -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 diff --git a/tests/rendered/inkscape-with-config.expected b/tests/rendered/inkscape-with-config.expected index e79b219..33540a3 100644 --- a/tests/rendered/inkscape-with-config.expected +++ b/tests/rendered/inkscape-with-config.expected @@ -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 diff --git a/tests/rendered/inkscape-with-title.expected b/tests/rendered/inkscape-with-title.expected index 7e7d428..ff2de4a 100644 --- a/tests/rendered/inkscape-with-title.expected +++ b/tests/rendered/inkscape-with-title.expected @@ -1,34 +1,34 @@ - inkscape + 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: + 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