diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faf3111..2fc4485 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.87.0] # MSRV + toolchain: [stable, 1.85.0] include: - platform: windows-latest exe_suffix: .exe runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - 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@v7 + - uses: actions/upload-artifact@v5 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@v7 + - uses: actions/checkout@v5 - 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@v5 - uses: dtolnay/rust-toolchain@master with: toolchain: stable @@ -74,7 +74,7 @@ jobs: name: build docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v5 - name: Setup mdBook uses: peaceiris/actions-mdbook@v2 with: diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index ae9ae93..8746d42 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -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@v5 - name: Setup mdBook uses: peaceiris/actions-mdbook@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9337d6f..539f2ab 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@v7 + - uses: actions/checkout@v5 - 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@v5 - 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@v5 - 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@v5 - 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@v5 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@v7 + - uses: actions/checkout@v5 - 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@v7 + - uses: actions/upload-artifact@v5 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@v5 - 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@v5 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@v5 + - uses: actions/download-artifact@v6 - name: Upload binary if: startsWith(github.ref, 'refs/tags/') run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 644a30a..5a80bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,46 +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 @@ -606,14 +566,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 diff --git a/Cargo.lock b/Cargo.lock index 4fba42b..be9bd24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,18 @@ 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" @@ -368,16 +380,6 @@ 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" @@ -633,6 +635,12 @@ 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" @@ -1109,11 +1117,11 @@ name = "tealdeer" version = "1.8.1" dependencies = [ "anyhow", + "app_dirs2", "assert_cmd", "clap", "env_logger", "escargot", - "etcetera", "filetime", "log", "pager", @@ -1626,6 +1634,12 @@ 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 1d98992..aa7ae49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/tealdeer-rs/tealdeer/" documentation = "https://tealdeer-rs.github.io/tealdeer/" version = "1.8.1" include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"] -rust-version = "1.87" # MSRV +rust-version = "1.85" edition = "2021" [[bin]] @@ -21,9 +21,9 @@ 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" diff --git a/README.md b/README.md index 859d06f..230dfa8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/src/config_display.md b/docs/src/config_display.md index 007d64b..78656a4 100644 --- a/docs/src/config_display.md +++ b/docs/src/config_display.md @@ -37,35 +37,3 @@ 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 a8a10c8..9acba55 100644 --- a/docs/src/config_updates.md +++ b/docs/src/config_updates.md @@ -30,18 +30,6 @@ 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 6a04de7..f7fcc46 100644 --- a/docs/src/usage.txt +++ b/docs/src/usage.txt @@ -29,5 +29,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. diff --git a/pages/tealdeer.md b/pages/tealdeer.md deleted file mode 100644 index 948b278..0000000 --- a/pages/tealdeer.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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 afa3603..b181310 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1,6 +1,6 @@ use std::{ fs::{self, File}, - io::{Cursor, ErrorKind, Read}, + io::{BufReader, Cursor, ErrorKind, Read}, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; @@ -277,12 +277,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> { + 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(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 } 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 161d69d..d461a3e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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"]), diff --git a/src/config.rs b/src/config.rs index f0feeb4..e5e85e8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,15 @@ use std::{ - borrow::Cow, env, fmt, fs::{self, File}, io::{ErrorKind, Write}, - path::{Component, Path, PathBuf}, + path::{Path, PathBuf}, sync::LazyLock, time::Duration, }; -use anyhow::{anyhow, ensure, Context, Result}; +use anyhow::{anyhow, bail, ensure, Context, Result}; +use app_dirs::{get_app_root, AppDataType}; use clap::ValueEnum; -use log::info; use serde::Serialize as _; use serde_derive::{Deserialize, Serialize}; use yansi::{Color, Style}; @@ -32,70 +31,10 @@ 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 } @@ -224,25 +163,6 @@ 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 { @@ -251,10 +171,6 @@ 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, - }, } } } @@ -268,18 +184,7 @@ const fn default_auto_update_interval_hours() -> u64 { } 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, + "https://github.com/tldr-pages/tldr/releases/latest/download/".to_owned() } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -294,8 +199,6 @@ struct RawUpdatesConfig { pub tls_backend: RawTlsBackend, #[serde(default)] pub download_languages: Option>, - #[serde(default)] - pub warn_cache_age: Option, } impl Default for RawUpdatesConfig { @@ -306,7 +209,6 @@ impl Default for RawUpdatesConfig { archive_source: default_archive_source(), tls_backend: RawTlsBackend::default(), download_languages: None, - warn_cache_age: None, } } } @@ -421,13 +323,6 @@ 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)] @@ -437,7 +332,6 @@ 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)] @@ -489,11 +383,6 @@ 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])); @@ -571,31 +460,12 @@ impl TryFrom for TlsBackend { _ => Err(anyhow!( "Unsupported TLS backend: {}. This tealdeer build has support for the following options: {}", raw, - supported_tls_backends_string(), + SUPPORTED_TLS_BACKENDS.iter().map(std::string::ToString::to_string).collect::>().join(", ") )) } } } -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, @@ -627,17 +497,12 @@ 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 @@ -653,43 +518,42 @@ impl<'a> Config<'a> { 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); - + // If the user explicitly configured a cache directory, use that. PathWithSource { - path: resolved_path, + // 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), 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 { - // Resolve possible ~ prefixed path - let expanded_path = expand_home(path, home_path.as_deref())?; + .map(|path| PathWithSource { // Resolve possible relative path. - let resolved_path = relative_path_root.join(expanded_path); - - Ok(PathWithSource { - path: resolved_path, - source: PathSource::ConfigFile, - }) + path: relative_path_root.join(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, @@ -707,29 +571,6 @@ 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 @@ -778,7 +619,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(); + let path = get_default_config_path().context("Could not determine default config path.")?; Self::read_internal(path, true) } @@ -796,24 +637,30 @@ 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_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, + }) } /// Create default config file. @@ -823,7 +670,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() { @@ -872,63 +719,6 @@ 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 082f436..5270124 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -2,54 +2,21 @@ 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 { - 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), + Title(&'a str), Linebreak, } -#[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> { +impl PageSnippet<'_> { pub fn is_empty(&self) -> bool { use PageSnippet::*; @@ -68,14 +35,11 @@ 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 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 { @@ -87,29 +51,19 @@ 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}"); - } - 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)?; } @@ -121,86 +75,29 @@ where Ok(()) } -/// 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>, +/// 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 { - 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 +127,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 +167,80 @@ 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<&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 - 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); } } diff --git a/src/main.rs b/src/main.rs index 6678ceb..a0cd593 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,6 @@ #![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", @@ -36,6 +35,7 @@ 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, supported_tls_backends_string, Config, PathWithSource, - }, + config::{get_config_dir, make_default_config, Config, PathWithSource}, output::print_page, types::ColorOptions, 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, +}; /// Clear the cache fn clear_cache(cache: Cache, quietly: bool) -> Result<()> { @@ -105,14 +105,16 @@ fn update_cache( /// 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_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 = config.file_path.to_string(); let cache_dir = config.directories.cache_dir.to_string(); let pages_dir = { @@ -254,20 +256,8 @@ 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 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, - )?; + let path = PageLookupResult::with_page(file); + print_page(&path, args.raw, enable_styles, args.pager, &config)?; return Ok(ExitCode::SUCCESS); } @@ -315,36 +305,12 @@ 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 { - let result = update_cache( + 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 @@ -369,18 +335,16 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { 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 - ), - ); - } + 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 + ), + ); } cache @@ -417,27 +381,23 @@ fn try_main(args: Cli, enable_styles: bool) -> Result { ); } - let Some(result) = cache.find_page(&command) else { + let Some(lookup_result) = cache.find_page(&command) 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, - )?; + print_page(&lookup_result, args.raw, enable_styles, args.pager, &config)?; } Ok(ExitCode::SUCCESS) diff --git a/src/output.rs b/src/output.rs index 6243b44..5f1aeae 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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 { @@ -68,7 +70,6 @@ pub fn print_page( &mut process_snippet, !config.display.compact, config.display.show_title, - config.display.indent, ) .context("Could not write to stdout")?; } @@ -81,17 +82,18 @@ 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)), + Title(s) => writeln!(writer, " {}", s.paint(style.command_name)), Linebreak => writeln!(writer), } } diff --git a/tests/lib.rs b/tests/lib.rs index d431b5f..cb987db 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -301,16 +301,6 @@ 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() { @@ -493,26 +483,6 @@ 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() { @@ -575,7 +545,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(0o0); + permissions.set_mode(0); fs::set_permissions(cache_dir, permissions).unwrap(); testenv @@ -841,24 +811,6 @@ 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( @@ -1067,7 +1019,6 @@ 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 efdd35d..22424e3 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 deleted file mode 100644 index 473bbe9..0000000 --- a/tests/rendered/inkscape-compact-no-color.expected +++ /dev/null @@ -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 - diff --git a/tests/rendered/inkscape-default.expected b/tests/rendered/inkscape-default.expected index 3b37f0e..da909f2 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 33540a3..e79b219 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 ff2de4a..7e7d428 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