Compare commits

...

21 commits

Author SHA1 Message Date
Niklas Mohrin
37b0dee39f
Support XDG directory spec on macOS by switching app_dirs2 to etcetera (#467)
Closes #311

Similar to how `bat` does it, we now have a global struct which holds
all the queried system directories. We prefer the XDG directories, but
use the "native" directories as a fallback. This should ensure that when
MacOs users upgrade from an older version, their existing config and
cache are still used. After deleting the old directories, the new ones
should be used by tealdeer automatically.
2026-08-06 00:37:37 +02:00
Niklas Mohrin
28ed785001
Fix 1.97 clippy lints 2026-07-26 23:52:43 +02:00
Nikolaos Karaolidis
f8a2003bc2
Add updates.warn_cache_age config option (#492)
This is useful when the cache is managed externally, e.g. provisioned
from a Nix store path or by a package manager, where the directory's
mtime doesn't reflect the cache's real age, causing a spurious warning
on every invocation. Today the only way to silence it is `--quiet`,
which must be passed every call and hides all other output too.
2026-07-07 14:24:06 +02:00
RedHare
df5113ddaa
Added AI Policy to README (#489)
Addresses #479, and adds a section for an AI policy based on what is
outlined by @niklasmohrin in that issue. The AI Policy is located below
the "Development" section.
2026-07-04 23:05:15 +02:00
dependabot[bot]
51593d27eb
Bump actions/checkout from 6 to 7 (#493) 2026-07-04 22:59:46 +02:00
xtqqczze
d0108b23e4
Fix clippy lints on all targets (#481) 2026-05-07 15:07:27 +02:00
xtqqczze
4d33e8a279
Remove tldr-c entry from benchmark results in README.md (#480)
`tldr-c-client` is unmaintained.
2026-05-06 16:36:37 +02:00
Ellis Clayton
1252261d66
Support tilde (~) expansion on config paths (#476)
Allows directories in the configuration file (cache & custom pages) to
be relative to the user's home directory by expanding the common `~`
path prefix notation. Works for the current user only (`~diferentUser/`
syntax is not supported, and will cause an error if attempted).

Works for Linux/Unix (via `HOME` env var) and Windows (via `USERPROFILE`
env var).

Examples (assuming a user called "foo" on a Linux system):

```
~/my/custom-pages # /home/foo/custom-pages
~ # /home/foo
~bar/cache # error
```
2026-05-03 21:47:29 +02:00
Niklas Mohrin
24e7f383b8
Fix Rust 1.95 clippy lints 2026-04-17 22:08:42 +02:00
Niklas Mohrin
6c65c8f71c
Fix off-boundary string access in formatter (#474)
Closes #473

This regression was introduced in
593e9309b9 (#414) and leads to a panic
when trying to display pages where characters line up like in the issue
or the test. I checked other places and found that a similar panic could
occur when parsing language strings, so I added code to ignore them
instead.

- Add regression test
- Fix prefix check
- Skip non-ASCII locales
2026-04-17 22:01:45 +02:00
Pavel Timofeev
b8f7c0cc2d
Add display.indent config option (#471)
Fixes #394.
2026-04-17 21:59:34 +02:00
Pavel Timofeev
b19517097a
Add builtin tldr tealdeer page (#472)
Fixes #218.
2026-04-14 00:31:31 +02:00
dependabot[bot]
6f91c3a765
Bump actions/download-artifact from 7 to 8 (#469)
Bumps
[actions/download-artifact](https://github.com/actions/download-artifact)
from 7 to 8.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/download-artifact/releases">actions/download-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v8.0.0</h2>
<h2>v8 - What's new</h2>
<h3>Direct downloads</h3>
<p>To support direct uploads in <code>actions/upload-artifact</code>,
the action will no longer attempt to unzip all downloaded files.
Instead, the action checks the <code>Content-Type</code> header ahead of
unzipping and skips non-zipped files. Callers wishing to download a
zipped file as-is can also set the new <code>skip-decompress</code>
parameter to <code>false</code>.</p>
<h3>Enforced checks (breaking)</h3>
<p>A previous release introduced digest checks on the download. If a
download hash didn't match the expected hash from the server, the action
would log a warning. Callers can now configure the behavior on mismatch
with the <code>digest-mismatch</code> parameter. To be secure by
default, we are now defaulting the behavior to <code>error</code> which
will fail the workflow run.</p>
<h3>ESM</h3>
<p>To support new versions of the @actions/* packages, we've upgraded
the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Don't attempt to un-zip non-zipped downloads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/460">actions/download-artifact#460</a></li>
<li>Add a setting to specify what to do on hash mismatch and default it
to <code>error</code> by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/download-artifact/pull/461">actions/download-artifact#461</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/download-artifact/compare/v7...v8.0.0">https://github.com/actions/download-artifact/compare/v7...v8.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="70fc10c6e5"><code>70fc10c</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/461">#461</a>
from actions/danwkennedy/digest-mismatch-behavior</li>
<li><a
href="f258da9a50"><code>f258da9</code></a>
Add change docs</li>
<li><a
href="ccc058e5fb"><code>ccc058e</code></a>
Fix linting issues</li>
<li><a
href="bd7976ba57"><code>bd7976b</code></a>
Add a setting to specify what to do on hash mismatch and default it to
<code>error</code></li>
<li><a
href="ac21fcf45e"><code>ac21fcf</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/download-artifact/issues/460">#460</a>
from actions/danwkennedy/download-no-unzip</li>
<li><a
href="15999bff51"><code>15999bf</code></a>
Add note about package bumps</li>
<li><a
href="974686ed50"><code>974686e</code></a>
Bump the version to <code>v8</code> and add release notes</li>
<li><a
href="fbe48b1d27"><code>fbe48b1</code></a>
Update test names to make it clearer what they do</li>
<li><a
href="96bf374a61"><code>96bf374</code></a>
One more test fix</li>
<li><a
href="b8c4819ef5"><code>b8c4819</code></a>
Fix skip decompress test</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/download-artifact/compare/v7...v8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=7&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 14:53:06 +01:00
dependabot[bot]
41739c5bf9
Bump actions/upload-artifact from 6 to 7 (#468)
Bumps
[actions/upload-artifact](https://github.com/actions/upload-artifact)
from 6 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/upload-artifact/releases">actions/upload-artifact's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>v7 What's new</h2>
<h3>Direct Uploads</h3>
<p>Adds support for uploading single files directly (unzipped). Callers
can set the new <code>archive</code> parameter to <code>false</code> to
skip zipping the file during upload. Right now, we only support single
files. The action will fail if the glob passed resolves to multiple
files. The <code>name</code> parameter is also ignored with this
setting. Instead, the name of the artifact will be the name of the
uploaded file.</p>
<h3>ESM</h3>
<p>To support new versions of the <code>@actions/*</code> packages,
we've upgraded the package to ESM.</p>
<h2>What's Changed</h2>
<ul>
<li>Add proxy integration test by <a
href="https://github.com/Link"><code>@​Link</code></a>- in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
<li>Upgrade the module to ESM and bump dependencies by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/762">actions/upload-artifact#762</a></li>
<li>Support direct file uploads by <a
href="https://github.com/danwkennedy"><code>@​danwkennedy</code></a> in
<a
href="https://redirect.github.com/actions/upload-artifact/pull/764">actions/upload-artifact#764</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Link"><code>@​Link</code></a>- made
their first contribution in <a
href="https://redirect.github.com/actions/upload-artifact/pull/754">actions/upload-artifact#754</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/upload-artifact/compare/v6...v7.0.0">https://github.com/actions/upload-artifact/compare/v6...v7.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bbbca2ddaa"><code>bbbca2d</code></a>
Support direct file uploads (<a
href="https://redirect.github.com/actions/upload-artifact/issues/764">#764</a>)</li>
<li><a
href="589182c5a4"><code>589182c</code></a>
Upgrade the module to ESM and bump dependencies (<a
href="https://redirect.github.com/actions/upload-artifact/issues/762">#762</a>)</li>
<li><a
href="47309c993a"><code>47309c9</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/upload-artifact/issues/754">#754</a>
from actions/Link-/add-proxy-integration-tests</li>
<li><a
href="02a8460834"><code>02a8460</code></a>
Add proxy integration test</li>
<li>See full diff in <a
href="https://github.com/actions/upload-artifact/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 14:52:53 +01:00
Niklas Mohrin
47a936e736
Suggest trying different TLS backend when update fails (#465)
Closes #453

- Add note about changing tls_backend setting
- impl Display for TlsBackend
- Remove trailing slash in default archive source to make URL in error
look nicer
2026-02-21 00:32:23 +01:00
Niklas Mohrin
593e9309b9
Placeholder escaping (#414)
Closes #402

This adds special handling for escaped placeholders as required by the
client spec. The added tests include examples from current pages that
rely on this behavior.

The text replacements use `str::replace` which constructs a new
allocated `String`. I considered using a custom `replace_inplace` method
on `&mut str` (which works if the replacement string is at most as long
as the pattern to be replaced), but decided against it because I think
that the performance improvement is not significant enough to justify
adding `unsafe` code. It is also possible to avoid `unsafe` by
re-checking UTF-8 validity after all modifications, but the code still
felt a bit out of place for tealdeer. We can always add these
optimizations later if we want to.
2026-02-20 23:44:36 +01:00
Niklas Mohrin
8b97afe7aa
Add workflow_dispatch trigger for GitHub Pages workflow 2026-01-25 18:39:52 +01:00
Niklas Mohrin
75e5462312
Update CHANGELOG.md 2026-01-25 18:29:32 +01:00
dependabot[bot]
5ee1f28021
Bump actions/upload-artifact from 5 to 6 (#456)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 18:55:53 +01:00
dependabot[bot]
3a6fd99c85
Bump actions/download-artifact from 6 to 7 (#457)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 18:55:41 +01:00
dependabot[bot]
c5d62e5987
Bump actions/checkout from 5 to 6 (#454)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:32:38 +01:00
23 changed files with 940 additions and 269 deletions

View file

@ -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@v5
- 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:

View file

@ -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

View file

@ -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@v5
- 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@v5
- 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@v5
- 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@v6
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
- name: Upload binary
if: startsWith(github.ref, 'refs/tags/')
run: |

View file

@ -13,6 +13,46 @@ 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
@ -566,11 +606,14 @@ 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

36
Cargo.lock generated
View file

@ -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"
@ -380,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"
@ -635,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"
@ -1117,11 +1109,11 @@ name = "tealdeer"
version = "1.8.1"
dependencies = [
"anyhow",
"app_dirs2",
"assert_cmd",
"clap",
"env_logger",
"escargot",
"etcetera",
"filetime",
"log",
"pager",
@ -1634,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"

View file

@ -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.85"
rust-version = "1.87" # MSRV
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"

View file

@ -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

View file

@ -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,
}
```

View file

@ -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`

View file

@ -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.

42
pages/tealdeer.md Normal file
View file

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

View file

@ -1,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<BufReader<Box<dyn Read>>> {
pub fn reader(&self) -> Result<Box<dyn Read>> {
// Open page file
let page_file = File::open(&self.page_path)
.with_context(|| format!("Could not open page file at {}", self.page_path.display()))?;
@ -302,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<dyn Read>
} else {
Box::new(page_file) as Box<dyn Read>
}))
})
}
}
@ -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:?}")
}
}
}

View file

@ -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"]),

View file

@ -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<Self> {
use etcetera::{
app_strategy::choose_native_strategy, choose_app_strategy, AppStrategy, AppStrategyArgs,
};
let args = AppStrategyArgs {
top_level_domain: String::new(),
author: String::new(),
app_name: crate::NAME.to_string(),
};
// The app strategy prefers XDG on MacOs, whereas the native strategy returns paths which
// are used by installed applications. On Linux and Windows, the strategies are the same.
let app_dirs = choose_app_strategy(args.clone())?;
let native_dirs = choose_native_strategy(args)?;
// We prefer the XDG paths, but before tealdeer 1.9, we used only the native paths on MacOs.
// So if we find files in these locations, we keep using them.
let fallback = |app_dir: PathBuf, native_dir: PathBuf| {
if !app_dir.exists() && native_dir.exists() {
native_dir
} else {
app_dir
}
};
Ok(Self {
config: fallback(app_dirs.config_dir(), native_dirs.config_dir()),
cache: fallback(app_dirs.cache_dir(), native_dirs.cache_dir()),
data: fallback(app_dirs.data_dir(), native_dirs.data_dir()),
})
}
}
static SYSTEM_DIRECTORIES: LazyLock<SystemDirectories> = LazyLock::new(|| {
SystemDirectories::discover().expect("Failed to initialize system directories.")
});
pub(crate) fn supported_tls_backends_string() -> String {
SUPPORTED_TLS_BACKENDS
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<String>>()
.join(", ")
}
fn default_underline() -> bool {
false
}
const fn default_base_indent() -> usize {
2
}
const fn default_command_indent() -> usize {
6
}
fn default_bold() -> bool {
false
}
@ -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<Vec<String>>,
#[serde(default)]
pub warn_cache_age: Option<RawWarnCacheAge>,
}
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<Language<'a>>,
pub warn_cache_age: Option<Duration>,
}
#[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<RawTlsBackend> 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::<Vec<String>>().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<PathWithSource> {
// 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<Cow<'a, Path>> {
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<Self> {
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<PathWithSource> {
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<PathBuf> {
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();

View file

@ -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<T> {
CommandName(T),
Variable(T),
NormalCode(T),
Description(T),
Text(T),
Title(T),
Linebreak,
}
impl PageSnippet<'_> {
#[cfg_attr(not(test), allow(dead_code))]
impl<T> PageSnippet<T> {
pub fn map<F, U>(self, f: F) -> PageSnippet<U>
where
F: FnOnce(T) -> U,
{
match self {
PageSnippet::CommandName(s) => PageSnippet::CommandName(f(s)),
PageSnippet::Variable(s) => PageSnippet::Variable(f(s)),
PageSnippet::NormalCode(s) => PageSnippet::NormalCode(f(s)),
PageSnippet::Description(s) => PageSnippet::Description(f(s)),
PageSnippet::Text(s) => PageSnippet::Text(f(s)),
PageSnippet::Title(s) => PageSnippet::Title(f(s)),
PageSnippet::Linebreak => PageSnippet::Linebreak,
}
}
}
impl<T: PartialEq<U>, U> PartialEq<PageSnippet<U>> for PageSnippet<T> {
fn eq(&self, other: &PageSnippet<U>) -> bool {
match (self, other) {
(PageSnippet::CommandName(s), PageSnippet::CommandName(t))
| (PageSnippet::Variable(s), PageSnippet::Variable(t))
| (PageSnippet::NormalCode(s), PageSnippet::NormalCode(t))
| (PageSnippet::Description(s), PageSnippet::Description(t))
| (PageSnippet::Text(s), PageSnippet::Text(t))
| (PageSnippet::Title(s), PageSnippet::Title(t)) => s == t,
(PageSnippet::Linebreak, PageSnippet::Linebreak) => true,
_ => false,
}
}
}
impl PageSnippet<&str> {
pub fn is_empty(&self) -> bool {
use PageSnippet::*;
@ -35,11 +68,14 @@ pub fn highlight_lines<L, F, E>(
process_snippet: &mut F,
keep_empty_lines: bool,
show_title: bool,
indent: Indent,
) -> Result<(), E>
where
L: Iterator<Item = LineType>,
F: for<'snip> FnMut(PageSnippet<'snip>) -> 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<E>(
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<usize> {
let mut search_start = 0;
loop {
let marker_index = s.find_from(marker, search_start)?;
let overlaps_with_prefix = (forbidden_prefix.len() <= marker_index + 1) && {
let prefix_start = marker_index + 1 - forbidden_prefix.len();
// NOTE: The indices might not be valid character offsets, so we should do this
// comparison on raw bytes. If prefix_start is indeed not a character offset than the
// comparison is guaranteed to return false because forbidden_prefix[0] definitely _is_
// the start of a (single byte, ASCII) character.
&s.as_bytes()[prefix_start..=marker_index] == forbidden_prefix.as_bytes()
};
if !overlaps_with_prefix {
return Some(marker_index);
}
// The next valid marker cannot include the first character of the current match
search_start = marker_index + 1;
}
}
/// Yields `NormalCode` and `CommandName` in alternating order according to the occurrences of
/// `command_name` in `segment`. Variables are not detected here, see `highlight_code`
/// instead.
fn highlight_code_segment<'a, E>(
command_name: &'a str,
mut segment: &'a str,
process_snippet: &mut impl FnMut(PageSnippet<'a>) -> 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<PageSnippet<'a>> {
fn run<'a>(cmd: &'a str, segment: &'a str) -> Vec<PageSnippet<String>> {
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 <https://github.com/tealdeer-rs/tealdeer/issues/473>
fn prefix_check_character_boundary() {
assert_eq!("Ä".len(), 2);
assert_eq!(run("", r"Äxx{{x}}"), [NormalCode("Äxx"), Variable("x")],);
}
}
}

View file

@ -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<ExitCode> {
// 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<ExitCode> {
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<ExitCode> {
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<ExitCode> {
);
}
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)

View file

@ -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),
}
}

View file

@ -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();

View file

@ -3,35 +3,35 @@
Ubuntuのバージョンが16.04か、それ以降で対話モードを使う場合`apt-get`の代わりとして使用します。
詳しくはこちら: <https://manned.org/apt.8>
利用可能なパーケージとバージョンのリストの更新他の`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

View file

@ -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

View file

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

View file

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

View file

@ -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