Compare commits

...

68 commits

Author SHA1 Message Date
Niklas Mohrin
d07fb02e86
Remove cleanup code for old pages directory (#514)
In preparation for 1.9 release.
2026-08-19 18:41:44 +02:00
Niklas Mohrin
f9fab32812
Update dependencies (#513)
Running `cargo update -v` shows that we could update `zip` from 5 to 8,
but our current version 5.1.1 is still less than a year old and there is
no reason to introduce this update churn (for example, Debian still has
zip 5 in testing). We can still release a patch update later to bump zip
to version 8, if this makes packaging easier.
2026-08-19 15:37:22 +02:00
Niklas Mohrin
fbc7520f8c
Update screenshots 2026-08-19 14:57:36 +02:00
Niklas Mohrin
911277cd21
Update completions 2026-08-19 14:49:58 +02:00
Niklas Mohrin
43157e78c7
Fix clippy lints 2026-08-19 14:49:58 +02:00
Niklas Mohrin
39094354b6
More placeholder_variants tests 2026-08-19 14:49:58 +02:00
Niklas Mohrin
1414194988
Placeholder variants (#512)
Closes #508
2026-08-18 21:06:43 +02:00
Niklas Mohrin
8b00800a69
Run clippy lints with MSRV
This helps with reproducibility.
2026-08-18 15:51:10 +02:00
Niklas Mohrin
5f837f4dd6
Bump edition to 2024 (#510) 2026-08-18 15:50:17 +02:00
Niklas Mohrin
82b9c88f39
Remove benchmarks/Dockerfile 2026-08-17 20:27:55 +02:00
Niklas Mohrin
b7da9a34f3
Remove github pages workflow 2026-08-17 20:04:40 +02:00
Niklas Mohrin
144ea1d727
Update README.md
Most importantly, the benchmark is out of date and no one is working on
updating it.
2026-08-17 19:58:05 +02:00
Niklas Mohrin
5d202fca81
Update docs link 2026-08-17 18:34:46 +02:00
Niklas Mohrin
fabb378368
Add readthedocs configuration and update mdbook 2026-08-17 18:34:46 +02:00
Niklas Mohrin
e86ca1aa86
Update screenshots 2026-08-17 17:42:46 +02:00
Marshall Porter
65ec680ea3
ci: build with --locked (#503) 2026-08-17 00:55:31 +02:00
Marshall Porter
d15718f672
ci: test on native Windows ARM64 (#504) 2026-08-17 00:53:03 +02:00
Marshall Porter
c80a935b89
ci: add native Windows ARM64 release (#502) 2026-08-16 23:25:35 +02:00
VXNCXNX
21ab081dae
Allow --no-auto-update without a command (#501) 2026-08-16 23:25:20 +02:00
VXNCXNX
64db5accfa
Silence the missing-cache message under --quiet (#500) 2026-08-16 23:25:00 +02:00
Niklas Mohrin
8bd3a0d4aa
Use plain style for indents in output (#499)
Fixes #485. This adds a new variant `PageSnippet::Indent(usize)` so that
indents don't use the style of the following snippet.
2026-08-13 01:03:02 +02:00
Giftzwerg02
1c68f99c2a
Add --override-config flag (#458)
Co-authored-by: Niklas Mohrin <dev@niklasmohrin.de>
2026-08-13 00:01:30 +02:00
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
Niklas Mohrin
b3cd7b1c21
Release v1.8.1 2025-11-11 22:27:52 +01:00
Niklas Mohrin
e769114d8b
Enable ureq's socks-proxy feature (#451) 2025-11-11 17:33:19 +01:00
dependabot[bot]
e1213158e4
Bump actions/upload-artifact from 4 to 5 (#447)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  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-11-02 00:19:23 +01:00
dependabot[bot]
6c1d702769
Bump actions/download-artifact from 5 to 6 (#448)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/download-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>
2025-11-02 00:18:45 +01:00
Niklas Mohrin
d49c4a9e05
Release v1.8.0 2025-10-03 22:59:18 +02:00
Niklas Mohrin
49626977ff
Run cargo +nightly clippy --fix and formatting (#442) 2025-10-03 22:44:39 +02:00
Niklas Mohrin
9a83b58d51
Bump MSRV to 1.85 and update dependencies (#441)
The latest version of base64ct requires 1.85, and I don't want to think about whether older versions of crypto libraries are safe.
2025-10-03 22:38:02 +02:00
Niklas Mohrin
2b127fd67e
Highlight code examples in user docs (#440)
* Highlight code examples in user docs
* Add missing quotes to archive_source example
2025-10-03 20:59:22 +02:00
Niklas Mohrin
911508ce33
Add search.platforms config option and search all platforms by default (#435) 2025-10-03 20:15:34 +02:00
hex1c
5b306756af
Add display.show_title option to display command titles in output (#439) 2025-10-03 19:53:25 +02:00
Niklas Mohrin
abb7e8ac55
Remove native-tls from default feature set (#436) 2025-09-22 16:06:36 +02:00
Niklas Mohrin
92b6c64c87
Merge pull request #430 from niklasmohrin/language-config
Add `search.languages` and `updates.download_languages` settings
2025-09-22 00:05:24 +02:00
Niklas Mohrin
c741146db5
Add updates.download_languages setting 2025-09-22 00:00:09 +02:00
Niklas Mohrin
a74b7120bd
Add search.languages setting 2025-09-21 23:59:50 +02:00
Niklas Mohrin
3fa96a5bb2
Add test config::test::language::with_encoding 2025-09-21 23:59:13 +02:00
Niklas Mohrin
7e014093cf
Move existing tests from config module into test submodule 2025-09-21 23:59:13 +02:00
Niklas Mohrin
94f9030d36
Move Language related functionality into config module 2025-09-21 23:59:05 +02:00
dependabot[bot]
5cfb817e99
Bump actions/download-artifact from 4 to 5 (#433)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  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>
Co-authored-by: Niklas Mohrin <dev@niklasmohrin.de>
2025-09-03 17:17:51 +02:00
dependabot[bot]
4377366c97
Bump actions/checkout from 4 to 5 (#434)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  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-09-03 16:52:36 +02:00
Niklas Mohrin
630b7f4423
Allow references in Config (#429)
For #251, I want to use the `Language<'_>` type inside `Config`. The languages will either refer to values read from the config file, or to static strings from `get_languages_from_env`, so just using `Language<'static>` is not an option.

Instead, some input for the `Config` needs to be persisted in the main function for the duration of the program so that the config can reference it. At first I was hoping that this input would be the `contents` string from `RawConfig::load`, but as it turns out you cannot (in general) deserialize strings from toml without having to alter them, for example when they contain escapes like `\n`. Thus, the toml parser seemingly doesn't even try and just throws an error when deserializing into a borrowed string (even if it could in theory just return the correct substring from the input). Given that `RawConfig` should stay static then, the raw config itself is the next best thing to keep alive and have the config reference into.

While this change might seem a bit drastic for little benefit, I am actually pretty happy with it because I want to unify the configuration anyways at some point so that the CLI arguments, environment variables, and the config file are merged at the beginning of the program and then only a single config is used for the everything (no more `enable_styles` everywhere!). At this time, the `Config` would have references into `Cli` anyways, and having the `ConfigLoader` as an entity for this merging also seems natural.
2025-08-05 23:46:49 +02:00
Niklas Mohrin
1e87db7ab7
Cache rewrite (#416) 2025-08-01 16:03:01 +02:00
Niklas Mohrin
d1be7d6bb9
Introduce Language struct (#425)
* Remove unneeded Clone bound on Dedup
* Introduce Language struct to represent language strings
* Move language directory name logic into own method on Language type
2025-06-06 23:25:30 +02:00
Niklas Mohrin
43ab2cb920
Bump MSRV to 1.80 (#426) 2025-06-06 23:13:47 +02:00
Niklas Mohrin
bc820c5f10
Upload binaries from build step as artifact (#423) 2025-06-06 22:52:44 +02:00
MHS-0
9bb95ad11d
Add an option to specify a custom config file to be used (#422)
Co-authored-by: Niklas Mohrin <dev@niklasmohrin.de>
2025-05-31 21:43:39 +02:00
49 changed files with 3581 additions and 1777 deletions

View file

@ -14,41 +14,57 @@ jobs:
name: run tests name: run tests
strategy: strategy:
matrix: matrix:
platform: [ubuntu-latest, macos-latest, windows-latest] platform: [ubuntu-latest, macos-latest, windows-latest, windows-11-arm]
toolchain: [stable, 1.75.0] toolchain: [stable, 1.88.0] # MSRV
include:
- platform: windows-latest
exe_suffix: .exe
- platform: windows-11-arm
exe_suffix: .exe
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@master
with: with:
toolchain: ${{ matrix.toolchain }} toolchain: ${{ matrix.toolchain }}
- run: mkdir artifacts
- name: Build with default features - name: Build with default features
run: cargo build run: |
cargo build --locked
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-default${{ matrix.exe_suffix}}
- name: Build with logging and Rustls with webpki roots - name: Build with logging and Rustls with webpki roots
run: cargo build --features logging,rustls-with-webpki-roots --no-default-features run: |
cargo build --locked --features logging,rustls-with-webpki-roots --no-default-features
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-logging-rustls-webpki${{ matrix.exe_suffix}}
- name: Build with native TLS backend - name: Build with native TLS backend
# expects runners have the proper Native SSL library run: |
run: cargo build --features native-tls --no-default-features # expects runners have the proper Native SSL library
cargo build --locked --features native-tls --no-default-features
cp target/debug/tldr${{ matrix.exe_suffix}} artifacts/tldr-native-tls${{ matrix.exe_suffix}}
- uses: actions/upload-artifact@v7
with:
name: tldr-debug-build-${{ matrix.platform }}-rust-${{ matrix.toolchain }}
path: artifacts/
- name: Run tests - name: Run tests
run: cargo test -- --test-threads 1 run: cargo test --locked -- --test-threads 1
clippy: clippy:
name: run clippy lints name: run clippy lints
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@master
with: with:
toolchain: stable toolchain: 1.88.0 # MSRV
components: clippy components: clippy
- name: run clippy lints - name: run clippy lints
run: cargo clippy --features logging run: cargo clippy --locked --all-targets --features logging
fmt: fmt:
name: run rustfmt name: run rustfmt
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master - uses: dtolnay/rust-toolchain@master
with: with:
toolchain: stable toolchain: stable
@ -60,20 +76,17 @@ jobs:
name: build docs name: build docs
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Setup mdBook - run: ./scripts/get-mdbook.sh
uses: peaceiris/actions-mdbook@v2
with:
mdbook-version: '0.4.4'
- name: Setup toolchain - name: Setup toolchain
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: stable toolchain: stable
- name: Build - name: Build
run: cargo build run: cargo build --locked
- name: Ensure that docs can be built - name: Ensure that docs can be built
run: cd docs && mdbook build run: ./mdbook build docs
- name: Generate usage string - name: Generate usage string
run: cargo run -- --help > docs/src/usage-actual.txt run: cargo run --locked -- --help > docs/src/usage-actual.txt
- name: Ensure that usage string is up to date - name: Ensure that usage string is up to date
run: diff docs/src/usage{,-actual}.txt run: diff docs/src/usage{,-actual}.txt

View file

@ -1,24 +0,0 @@
name: GitHub Pages
on:
push:
tags:
- "v[1-9]*" # push events matching `v` followed by anything larger than 0, e.g. v1.0, v20.15.10
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v2
with:
mdbook-version: '0.4.4'
- run: cd docs && mdbook build
- name: Deploy
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/book

View file

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

7
.readthedocs.yaml Normal file
View file

@ -0,0 +1,7 @@
version: 2
build:
os: ubuntu-26.04
commands:
- ./scripts/get-mdbook.sh
- ./mdbook build docs --dest-dir $READTHEDOCS_OUTPUT/html

View file

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

1039
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,11 @@ license = "MIT OR Apache-2.0"
name = "tealdeer" name = "tealdeer"
readme = "README.md" readme = "README.md"
repository = "https://github.com/tealdeer-rs/tealdeer/" repository = "https://github.com/tealdeer-rs/tealdeer/"
documentation = "https://tealdeer-rs.github.io/tealdeer/" documentation = "https://docs.tealdeer.org"
version = "1.7.2" version = "1.8.1"
include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"] include = ["/src/**/*", "/tests/**/*", "/Cargo.toml", "/README.md", "/LICENSE-*", "/screenshot.png", "completion/*"]
rust-version = "1.75" rust-version = "1.88" # MSRV
edition = "2021" edition = "2024"
[[bin]] [[bin]]
name = "tldr" name = "tldr"
@ -21,17 +21,16 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1" 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 } clap = { version = "4", features = ["std", "derive", "help", "usage", "cargo", "error-context", "color", "wrap_help"], default-features = false }
env_logger = { version = "0.11", optional = true } env_logger = { version = "0.11", optional = true }
etcetera = "0.11.0"
log = "0.4" log = "0.4"
serde = "1.0.21" serde = "1.0.21"
serde_derive = "1.0.21" serde_derive = "1.0.21"
ureq = { version = "3.0.8", default-features = false, features = ["gzip"] } ureq = { version = "3.0.8", default-features = false, features = ["gzip", "socks-proxy"] }
toml = "0.8.19" toml = "1"
walkdir = "2.0.1"
yansi = "1" yansi = "1"
zip = { version = "2.3.0", default-features = false, features = ["deflate"] } zip = { version = "5.1.1", default-features = false, features = ["deflate"] }
[target.'cfg(not(windows))'.dependencies] [target.'cfg(not(windows))'.dependencies]
pager = "0.16" pager = "0.16"
@ -44,7 +43,8 @@ tempfile = "3.1.0"
filetime = "0.2.10" filetime = "0.2.10"
[features] [features]
default = ["native-tls", "rustls-with-webpki-roots", "rustls-with-native-roots"] # native-tls is not enabled by default, because it is difficult to build for musl
default = ["rustls-with-webpki-roots", "rustls-with-native-roots"]
logging = ["env_logger"] logging = ["env_logger"]
# At least one of variants for `ureq` HTTP client must be selected. # At least one of variants for `ureq` HTTP client must be selected.

View file

@ -19,7 +19,7 @@ binaries on the [GitHub releases page](https://github.com/tealdeer-rs/tealdeer/r
## Docs (Installing, Usage, Configuration) ## Docs (Installing, Usage, Configuration)
User documentation is available at <https://tealdeer-rs.github.io/tealdeer/>! User documentation is available at <https://docs.tealdeer.org>!
The docs are generated using [mdbook](https://rust-lang.github.io/mdBook/index.html). The docs are generated using [mdbook](https://rust-lang.github.io/mdBook/index.html).
They can be edited through the markdown files in the `docs/src/` directory. They can be edited through the markdown files in the `docs/src/` directory.
@ -31,7 +31,6 @@ High level project goals:
- [x] Download and cache pages - [x] Download and cache pages
- [x] Don't require a network connection for anything besides updating the cache - [x] Don't require a network connection for anything besides updating the cache
- [x] Command line interface similar or equivalent to the [NodeJS client][node-gh]
- [x] Comply with the [tldr client specification][client-spec] - [x] Comply with the [tldr client specification][client-spec]
- [x] Advanced highlighting and configuration - [x] Advanced highlighting and configuration
- [x] Be fast - [x] Be fast
@ -39,29 +38,6 @@ High level project goals:
A tool like `tldr` should be as frictionless as possible to use and show the A tool like `tldr` should be as frictionless as possible to use and show the
output as fast as possible. output as fast as possible.
We think that `tealdeer` reaches these goals. We put together a (more or less)
reproducible benchmark that compiles a handful of clients from source and
measures the execution times on a cold disk cache. The benchmarking is run in a
Docker container using sharkdp's [`hyperfine`][hyperfine-gh]
([Dockerfile][benchmark-dockerfile]).
| Client (50 runs, 17.10.2021) | Programming Language | Mean in ms | Deviation in ms | Comments |
| :---: | :---: | :---: | :---: | :---: |
| [`outfieldr`][outfieldr-gh] | Zig | 9.1 | 0.5 | no user configuration |
| `tealdeer` | Rust | 13.2 | 0.5 | |
| [`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 | |
As you can see, `tealdeer` is one of the fastest of the tested clients.
However, we strive for useful features and code quality over raw performance,
even if that means that we don't come out on top in this friendly competition.
That said, we are still optimizing the code, for example when the `outfieldr`
developers [suggested to switch][outfieldr-comment-tls] to a native TLS
implementation instead of the native libraries.
## Development ## Development
@ -87,10 +63,22 @@ To run lints:
$ cargo clean && cargo clippy $ 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) ## MSRV (Minimally Supported Rust Version)
When publishing a tealdeer release, the Rust version required to build it When publishing a tealdeer release, the Rust version required to build it
should be stable for at least a month. should be stable for at least a month. The current MSRV can always be found in
the `rust-version` field in `Cargo.toml`.
## License ## License
@ -112,18 +100,7 @@ be dual licensed as above, without any additional terms or conditions.
Thanks to @severen for coming up with the name "tealdeer"! 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
[outfieldr-gh]: https://gitlab.com/ve-nt/outfieldr
[python-gh]: https://github.com/tldr-pages/tldr-python-client
[benchmark-dockerfile]: https://github.com/tealdeer-rs/tealdeer/blob/main/benchmarks/Dockerfile
[client-spec]: https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md [client-spec]: https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md
[hyperfine-gh]: https://github.com/sharkdp/hyperfine
[outfieldr-comment-tls]: https://github.com/tealdeer-rs/tealdeer/issues/129#issuecomment-833596765
<!-- Badges --> <!-- Badges -->
[github-actions]: https://github.com/tealdeer-rs/tealdeer/actions?query=branch%3Amain [github-actions]: https://github.com/tealdeer-rs/tealdeer/actions?query=branch%3Amain

View file

@ -1,139 +0,0 @@
# Benchmark Dockerfile for tealdeer
#
# To run the benchmarks, execute
#
# docker build --pull -t tldr-benchmark .
# docker run --privileged --rm -it tldr-benchmark
#
# as root in the directory of this Dockerfile. This will build the compared
# clients and benchmark them with `hyperfine` at the end.
#
# The `--privileged` flag is needed to drop the disk caches before every run. If
# you want to test with hot caches or don't want to use this flag, you will have
# to remove the `--prepare` line from the `hyperfine` command at the end of this
# file and rebuild the image.
################################################################################
FROM rust AS tealdeer-builder
WORKDIR /build
RUN git clone https://github.com/tealdeer-rs/tealdeer.git \
&& cd tealdeer \
&& cargo build --release \
&& mkdir /build-outputs \
&& cp target/release/tldr /build-outputs/tealdeer
################################################################################
FROM ubuntu:latest AS tldr-c-builder
WORKDIR /build
RUN apt-get update && apt-get install -y build-essential git && rm -rf /var/lib/apt/lists/*
RUN git clone https://github.com/tldr-pages/tldr-c-client.git \
&& cd tldr-c-client \
&& DEBIAN_FRONTEND=noninteractive ./deps.sh \
&& make \
&& mkdir /build-outputs /deps \
&& cp tldr /build-outputs/tldr-c \
&& cp deps.sh /deps/tldr-c-deps.sh
################################################################################
FROM haskell AS haskell-builder
WORKDIR /build
RUN git clone https://github.com/psibi/tldr-hs.git \
&& cd tldr-hs \
&& stack build --install-ghc
RUN git clone https://github.com/gutjuri/fast-tldr \
&& cd fast-tldr \
&& stack build --install-ghc
RUN mkdir /build-outputs \
&& find tldr-hs/.stack-work/dist -type f -iname tldr -exec mv '{}' /build-outputs/tldr-hs \; \
&& find fast-tldr/.stack-work/dist -type f -iname tldr -exec mv '{}' /build-outputs/fast-tldr \;
################################################################################
FROM node:slim AS node-builder
WORKDIR /build-outputs
RUN npm install tldr \
&& cp $(which node) . \
&& echo './node -- ./node_modules/.bin/tldr "$@"' > tldr-node \
&& chmod +x tldr-node
################################################################################
FROM euantorano/zig:0.8.0 AS zig-builder
WORKDIR /build
RUN apk add git \
&& git clone https://gitlab.com/ve-nt/outfieldr.git \
&& cd outfieldr \
&& git submodule init \
&& git submodule update \
&& zig build -Drelease-safe \
&& mkdir /build-outputs \
&& cp bin/tldr /build-outputs/outfieldr
################################################################################
FROM ubuntu:latest AS benchmark
ENV LANG="en_US.UTF-8"
WORKDIR /deps
RUN apt-get update && apt-get install -y wget unzip python3 python3-venv && rm -rf /var/lib/apt/lists/*
COPY --from=tldr-c-builder /deps/* ./
RUN for file in *; do DEBIAN_FRONTEND=noninteractive sh $file; done
WORKDIR /clients
COPY --from=tealdeer-builder /build-outputs/* ./
COPY --from=tldr-c-builder /build-outputs/* ./
COPY --from=haskell-builder /build-outputs/* ./
RUN wget -qO tldr-bash https://4e4.win/tldr && chmod +x tldr-bash
COPY --from=node-builder /build-outputs/node /build-outputs/tldr-node ./
COPY --from=node-builder /build-outputs/node_modules/ ./node_modules/
COPY --from=zig-builder /build-outputs/* ./
# python is really hard to isolate in a package, using pyinstaller didn't really work either, so for now we just use it like this
RUN python3 -m venv tldr-python \
&& cd tldr-python \
&& bash -c 'source bin/activate; pip install wheel; pip install tldr; deactivate' \
&& cd .. \
&& echo '#!/bin/bash' > tldr-python.bash \
&& echo 'source tldr-python/bin/activate; tldr $@' >> tldr-python.bash \
&& chmod +x tldr-python.bash
# Update all the individual caches
RUN bash -c 'mkdir -p /caches/{tealdeer,tldr-c,tldr-hs,fast-tldr,tldr-bash,tldr-node,tldr-python,outfieldr/.local/share}' \
&& TEALDEER_CACHE_DIR=/caches/tealdeer ./tealdeer -u \
&& TLDR_CACHE_DIR=/caches/tldr-c ./tldr-c -u \
&& XDG_DATA_HOME=/caches/tldr-hs ./tldr-hs -u \
&& XDG_DATA_HOME=/caches/fast-tldr ./fast-tldr -u \
&& XDG_DATA_HOME=/caches/tldr-bash ./tldr-bash -u \
&& HOME=/caches/tldr-node ./tldr-node -u \
&& HOME=/caches/tldr-python ./tldr-python.bash -u \
&& HOME=/caches/outfieldr ./outfieldr -u
WORKDIR /tools
RUN wget -q https://github.com/sharkdp/hyperfine/releases/download/v1.11.0/hyperfine_1.11.0_amd64.deb && dpkg -i hyperfine_1.11.0_amd64.deb
ENV PAGE="tar"
WORKDIR /clients
CMD hyperfine \
--warmup 10 \
--runs 50 \
--prepare 'sync; echo 3 | tee /proc/sys/vm/drop_caches' \
"TEALDEER_CACHE_DIR=/caches/tealdeer ./tealdeer $PAGE" \
"TLDR_CACHE_DIR=/caches/tldr-c ./tldr-c $PAGE" \
"XDG_DATA_HOME=/caches/tldr-hs ./tldr-hs $PAGE" \
"XDG_DATA_HOME=/caches/fast-tldr ./fast-tldr $PAGE" \
"XDG_DATA_HOME=/caches/tldr-bash TLDR_LESS=0 ./tldr-bash $PAGE" \
"HOME=/caches/tldr-python ./tldr-python.bash $PAGE" \
"HOME=/caches/outfieldr ./outfieldr $PAGE" \
"HOME=/caches/tldr-node ./tldr-node $PAGE"

View file

@ -3,21 +3,27 @@
# https://github.com/tealdeer-rs/tealdeer/ # https://github.com/tealdeer-rs/tealdeer/
# #
complete -c tldr -s h -l help -d 'Print the help message.' -f complete -c tldr -s h -l help -d 'Print the help message' -f
complete -c tldr -s v -l version -d 'Show version information.' -f complete -c tldr -s v -l version -d 'Show version information' -f
complete -c tldr -s l -l list -d 'List all commands in the cache.' -f complete -c tldr -s l -l list -d 'List all commands in the cache' -f
complete -c tldr -s f -l render -d 'Render a specific markdown file.' -r complete -c tldr -l edit-page -d 'Edit custom page with `EDITOR`' -f
complete -c tldr -s p -l platform -d 'Override the operating system.' -xa 'linux macos sunos windows android freebsd netbsd openbsd' complete -c tldr -l edit-patch -d 'Edit custom patch with `EDITOR`' -f
complete -c tldr -s L -l language -d 'Override the language' -x complete -c tldr -s f -l render -d 'Render a specific markdown file' -r
complete -c tldr -s u -l update -d 'Update the local cache.' -f complete -c tldr -s p -l platform -d 'Override the operating system' -xa 'linux macos sunos windows android freebsd netbsd openbsd common'
complete -c tldr -l no-auto-update -d 'If auto update is configured, disable it for this run.' -f complete -c tldr -s L -l language -d 'Override the language' -x
complete -c tldr -s c -l clear-cache -d 'Clear the local cache.' -f complete -c tldr -s u -l update -d 'Update the local cache' -f
complete -c tldr -l pager -d 'Use a pager to page output.' -f complete -c tldr -l no-auto-update -d 'If auto update is configured, disable it for this run' -f
complete -c tldr -s r -l raw -d 'Display the raw markdown instead of rendering it.' -f complete -c tldr -s c -l clear-cache -d 'Clear the local cache' -f
complete -c tldr -s q -l quiet -d 'Suppress informational messages.' -f complete -c tldr -s L -l config-path -d 'Override config file location' -r
complete -c tldr -l show-paths -d 'Show file and directory paths used by tealdeer.' -f complete -c tldr -s L -l override-config -d 'Override config values after reading config file' -x
complete -c tldr -l seed-config -d 'Create a basic config.' -f complete -c tldr -l pager -d 'Use a pager to page output' -f
complete -c tldr -l color -d 'Controls when to use color.' -xa 'always auto never' complete -c tldr -s r -l raw -d 'Display the raw markdown instead of rendering it' -f
complete -c tldr -s q -l quiet -d 'Suppress informational messages' -f
complete -c tldr -l show-paths -d 'Show file and directory paths used by tealdeer' -f
complete -c tldr -l seed-config -d 'Create a basic config' -f
complete -c tldr -l color -d 'Controls when to use color' -xa 'always auto never'
complete -c tldr -l short-options -d 'Display the short variants of placeholders' -f
complete -c tldr -l long-options -d 'Display the long variants of placeholders' -f
function __tealdeer_entries function __tealdeer_entries
if set entries (tldr --list 2>/dev/null) if set entries (tldr --list 2>/dev/null)

View file

@ -14,6 +14,8 @@ _tealdeer() {
args+=( args+=(
"($I -l --list)"{-l,--list}"[List all commands in the cache]" "($I -l --list)"{-l,--list}"[List all commands in the cache]"
"($I)--edit-page[Edit custom page with EDITOR]"
"($I)--edit-patch[Edit custom patch with EDITOR]"
"($I -f --render)"{-f,--render}"[Render a specific markdown file]:file:_files" "($I -f --render)"{-f,--render}"[Render a specific markdown file]:file:_files"
"($I -p --platform)"{-p,--platform}'[Override the operating system]:platform:(( "($I -p --platform)"{-p,--platform}'[Override the operating system]:platform:((
linux linux
@ -24,11 +26,14 @@ _tealdeer() {
freebsd freebsd
netbsd netbsd
openbsd openbsd
common
))' ))'
"($I -L --language)"{-L,--language}"[Override the language settings]:lang" "($I -L --language)"{-L,--language}"[Override the language settings]:lang"
"($I -u --update)"{-u,--update}"[Update the local cache]" "($I -u --update)"{-u,--update}"[Update the local cache]"
"($I)--no-auto-update[If auto update is configured, disable it for this run]" "($I)--no-auto-update[If auto update is configured, disable it for this run]"
"($I -c --clear-cache)"{-c,--clear-cache}"[Clear the local cache]" "($I -c --clear-cache)"{-c,--clear-cache}"[Clear the local cache]"
"($I)--config-path[Override config file location]"
"($I)--override-config[Override config values after reading config file]"
"($I)--pager[Use a pager to page output]" "($I)--pager[Use a pager to page output]"
"($I -r --raw)"{-r,--raw}"[Display the raw markdown instead of rendering it]" "($I -r --raw)"{-r,--raw}"[Display the raw markdown instead of rendering it]"
"($I -q --quiet)"{-q,--quiet}"[Suppress informational messages]" "($I -q --quiet)"{-q,--quiet}"[Suppress informational messages]"
@ -39,6 +44,8 @@ _tealdeer() {
auto auto
never never
))" ))"
"($I)--short-options[Display the short variants of placeholders]"
"($I)--long-options[Display the long variants of placeholders]"
'(- *)'{-h,--help}'[Display help]' '(- *)'{-h,--help}'[Display help]'
'(- *)'{-v,--version}'[Show version information]' '(- *)'{-v,--version}'[Show version information]'
'1: :_applications' '1: :_applications'

View file

@ -1,6 +1,5 @@
[book] [book]
authors = ["Danilo Bargen"] authors = ["Danilo Bargen", "Niklas Mohrin"]
language = "en" language = "en"
multilingual = false
src = "src" src = "src"
title = "Tealdeer User Manual" title = "Tealdeer User Manual"

View file

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

View file

@ -9,11 +9,15 @@ The configuration file path follows OS conventions (e.g.
`$XDG_CONFIG_HOME/tealdeer/config.toml` on Linux). The paths can be queried `$XDG_CONFIG_HOME/tealdeer/config.toml` on Linux). The paths can be queried
with the following command: with the following command:
$ tldr --show-paths ```shell
$ tldr --show-paths
```
Creating the config file can be done manually or with the help of `tldr`: Creating the config file can be done manually or with the help of `tldr`:
$ tldr --seed-config ```shell
$ tldr --seed-config
```
On Linux, this will usually be `~/.config/tealdeer/config.toml`. On Linux, this will usually be `~/.config/tealdeer/config.toml`.
@ -22,13 +26,14 @@ On Linux, this will usually be `~/.config/tealdeer/config.toml`.
Here's an example configuration file. Note that this example does not contain Here's an example configuration file. Note that this example does not contain
all possible config options. For details on the things that can be configured, all possible config options. For details on the things that can be configured,
please refer to the subsections of this documentation page please refer to the subsections of this documentation page
([display](config_display.html), [style](config_style.html), ([display](config_display.html), [style](config_style.html), [search](config_search.html),
[updates](config_updates.html) or [directories](config_directories.html)). [updates](config_updates.html) or [directories](config_directories.html)).
```toml ```toml
[display] [display]
compact = false compact = false
use_pager = true use_pager = true
show_title = false
[style.command_name] [style.command_name]
foreground = "red" foreground = "red"
@ -52,3 +57,16 @@ auto_update = true
The directory where the configuration file resides may be overwritten by the The directory where the configuration file resides may be overwritten by the
environment variable `TEALDEER_CONFIG_DIR`. Remember to use an absolute path. environment variable `TEALDEER_CONFIG_DIR`. Remember to use an absolute path.
Variable expansion will not be performed on the path. Variable expansion will not be performed on the path.
## Override Config Values
Individual config values can be overridden using the `--override-config` command
line argument. The overrides take place after reading the user config file, but
before the raw config is evaluated.
```shell
$ tldr --override-config "display.compact = true" tealdeer
```
Each override is of the form `<name> = <value>` where `name` is a config key and
`value` is any TOML value.

View file

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

View file

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

33
docs/src/config_search.md Normal file
View file

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

View file

@ -10,7 +10,7 @@ Using the config file, the style (e.g. colors or underlines) can be customized.
- `command_name`: The command name as part of the example code - `command_name`: The command name as part of the example code
- `example_text`: The text that describes an example - `example_text`: The text that describes an example
- `example_code`: The example itself (except the `command_name` and `example_variable`) - `example_code`: The example itself (except the `command_name` and `example_variable`)
- `example_variable`: The variables in the example - `example_variable`: The variables (placeholders) in the example
## Attributes ## Attributes
@ -26,16 +26,22 @@ Colors can be specified in one of three ways:
Example: Example:
foreground = "green" ```toml
foreground = "green"
```
- 256 color ANSI code (*tealdeer v1.5.0+*) - 256 color ANSI code (*tealdeer v1.5.0+*)
Example: Example:
foreground = { ansi = 4 } ```toml
foreground = { ansi = 4 }
```
- 24-bit RGB color (*tealdeer v1.5.0+*) - 24-bit RGB color (*tealdeer v1.5.0+*)
Example: Example:
background = { rgb = { r = 255, g = 255, b = 255 } } ```toml
background = { rgb = { r = 255, g = 255, b = 255 } }
```

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Before After
Before After

View file

@ -1,4 +1,4 @@
tealdeer 1.7.2: A fast TLDR client tealdeer 1.8.1: A fast TLDR client
Danilo Bargen <mail@dbrgn.ch>, Niklas Mohrin <dev@niklasmohrin.de> Danilo Bargen <mail@dbrgn.ch>, Niklas Mohrin <dev@niklasmohrin.de>
Usage: tldr [OPTIONS] [COMMAND]... Usage: tldr [OPTIONS] [COMMAND]...
@ -7,24 +7,32 @@ Arguments:
[COMMAND]... The command to show (e.g. `tar` or `git log`) [COMMAND]... The command to show (e.g. `tar` or `git log`)
Options: Options:
-l, --list List all commands in the cache -l, --list List all commands in the cache
--edit-page Edit custom page with `EDITOR` --edit-page Edit custom page with `EDITOR`
--edit-patch Edit custom patch with `EDITOR` --edit-patch Edit custom patch with `EDITOR`
-f, --render <FILE> Render a specific markdown file -f, --render <FILE> Render a specific markdown file
-p, --platform <PLATFORM> Override the operating system, can be specified multiple times in order -p, --platform <PLATFORM> Override the operating system, can be specified multiple times
of preference [possible values: linux, macos, sunos, windows, android, in order of preference [possible values: linux, macos, sunos,
freebsd, netbsd, openbsd, common] windows, android, freebsd, netbsd, openbsd, common]
-L, --language <LANGUAGE> Override the language -L, --language <LANGUAGE> Override the language
-u, --update Update the local cache -u, --update Update the local cache
--no-auto-update If auto update is configured, disable it for this run --no-auto-update If auto update is configured, disable it for this run
-c, --clear-cache Clear the local cache -c, --clear-cache Clear the local cache
--pager Use a pager to page output --config-path <FILE> Override config file location
-r, --raw Display the raw markdown instead of rendering it --override-config <OVERRIDE> Override config values after reading config file (example:
-q, --quiet Suppress informational messages `updates.auto_update = true`)
--show-paths Show file and directory paths used by tealdeer --pager Use a pager to page output
--seed-config Create a basic config -r, --raw Display the raw markdown instead of rendering it
--color <WHEN> Control whether to use color [possible values: always, auto, never] -q, --quiet Suppress informational messages
-v, --version Print the version --show-paths Show file and directory paths used by tealdeer
-h, --help Print help --seed-config Create a basic config
--color <WHEN> Control whether to use color [possible values: always, auto,
never]
--short-options Display the short variants of placeholders
--long-options Display the long variants of placeholders
-v, --version Print the version
-h, --help Print help
To view the user documentation, please visit https://tealdeer-rs.github.io/tealdeer/. To view the user documentation, please visit https://docs.tealdeer.org.
To view usage examples, run tldr tldr or tldr tealdeer.

View file

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

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://docs.tealdeer.org>.
> This page shows tealdeer specific functionality. See tldr tldr for more examples.
- Render a local markdown file as a tldr page:
`tldr {{[-f|--render]}} {{path/to/file.md}}`
- Show the raw markdown source of a page instead of rendering it:
`tldr {{[-r|--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 {{[-c|--clear-cache]}}`
- If auto update is configured, disable it for this run:
`tldr --no-auto-update`

8
scripts/get-mdbook.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh
set -ex
wget -O mdbook.tar.gz https://github.com/rust-lang/mdBook/releases/download/v0.5.4/mdbook-v0.5.4-x86_64-unknown-linux-musl.tar.gz
echo "5222beabd3e37dc5be0d18ff99b79058469354db5c220153a1b92db5ba12be89 mdbook.tar.gz" > sha256sums
sha256sum --check sha256sums
tar xvf mdbook.tar.gz

View file

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

View file

@ -2,7 +2,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::{arg, builder::ArgAction, command, ArgGroup, Parser}; use clap::{ArgGroup, Parser, builder::ArgAction};
use crate::types::{ColorOptions, PlatformType}; use crate::types::{ColorOptions, PlatformType};
@ -18,7 +18,9 @@ use crate::types::{ColorOptions, PlatformType};
{usage-heading} {usage} {usage-heading} {usage}
{all-args}{after-help}", {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://docs.tealdeer.org.
To view usage examples, run tldr tldr or tldr tealdeer.",
arg_required_else_help = true, arg_required_else_help = true,
help_expected = true, help_expected = true,
group = ArgGroup::new("command_or_file").args(&["command", "render"]), group = ArgGroup::new("command_or_file").args(&["command", "render"]),
@ -67,13 +69,21 @@ pub(crate) struct Cli {
pub update: bool, pub update: bool,
/// If auto update is configured, disable it for this run /// If auto update is configured, disable it for this run
#[arg(long = "no-auto-update", requires = "command_or_file")] #[arg(long = "no-auto-update")]
pub no_auto_update: bool, pub no_auto_update: bool,
/// Clear the local cache /// Clear the local cache
#[arg(short = 'c', long = "clear-cache")] #[arg(short = 'c', long = "clear-cache")]
pub clear_cache: bool, pub clear_cache: bool,
/// Override config file location
#[arg(long = "config-path", value_name = "FILE")]
pub config_path: Option<PathBuf>,
/// Override config values after reading config file (example: `updates.auto_update = true`)
#[arg(long, action = ArgAction::Append, value_name = "OVERRIDE")]
pub override_config: Vec<String>,
/// Use a pager to page output /// Use a pager to page output
#[arg(long = "pager", requires = "command_or_file")] #[arg(long = "pager", requires = "command_or_file")]
pub pager: bool, pub pager: bool,
@ -98,6 +108,14 @@ pub(crate) struct Cli {
#[arg(long = "color", value_name = "WHEN")] #[arg(long = "color", value_name = "WHEN")]
pub color: Option<ColorOptions>, pub color: Option<ColorOptions>,
/// Display the short variants of placeholders
#[arg(long)]
pub short_options: bool,
/// Display the long variants of placeholders
#[arg(long)]
pub long_options: bool,
/// Print the version /// Print the version
// Note: We override the version flag because clap uses `-V` by default, // Note: We override the version flag because clap uses `-V` by default,
// while TLDR specification requires `-v` to be used. // while TLDR specification requires `-v` to be used.

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2,25 +2,80 @@
use log::debug; 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. /// Represents a snippet from a page of a specific highlighting class.
pub enum PageSnippet<'a> { pub enum PageSnippet<T> {
CommandName(&'a str), CommandName(T),
Variable(&'a str), Placeholder(T),
NormalCode(&'a str), PlaceholderVariants { short: T, long: T },
Description(&'a str), NormalCode(T),
Text(&'a str), Description(T),
Text(T),
Title(T),
Indent(usize),
Linebreak, 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: Fn(T) -> U,
{
match self {
PageSnippet::CommandName(s) => PageSnippet::CommandName(f(s)),
PageSnippet::Placeholder(s) => PageSnippet::Placeholder(f(s)),
PageSnippet::PlaceholderVariants { short, long } => PageSnippet::PlaceholderVariants {
short: f(short),
long: f(long),
},
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::Indent(n) => PageSnippet::Indent(n),
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::Placeholder(s), PageSnippet::Placeholder(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::PlaceholderVariants {
short: left_short,
long: left_long,
},
PageSnippet::PlaceholderVariants {
short: right_short,
long: right_long,
},
) => left_short == right_short && left_long == right_long,
(PageSnippet::Indent(n), PageSnippet::Indent(m)) => n == m,
(PageSnippet::Linebreak, PageSnippet::Linebreak) => true,
_ => false,
}
}
}
impl PageSnippet<&str> {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
use PageSnippet::*; use PageSnippet::*;
match self { match self {
CommandName(s) | Variable(s) | NormalCode(s) | Description(s) | Text(s) => s.is_empty(), CommandName(s) | Placeholder(s) | NormalCode(s) | Description(s) | Text(s)
| Title(s) => s.is_empty(),
PageSnippet::PlaceholderVariants { short, long } => short.is_empty() && long.is_empty(),
Indent(n) => *n == 0,
Linebreak => false, Linebreak => false,
} }
} }
@ -31,10 +86,12 @@ pub fn highlight_lines<L, F, E>(
lines: L, lines: L,
process_snippet: &mut F, process_snippet: &mut F,
keep_empty_lines: bool, keep_empty_lines: bool,
show_title: bool,
indent: Indent,
) -> Result<(), E> ) -> Result<(), E>
where where
L: Iterator<Item = LineType>, L: Iterator<Item = LineType>,
F: for<'snip> FnMut(PageSnippet<'snip>) -> Result<(), E>, F: for<'snip> FnMut(PageSnippet<&'snip str>) -> Result<(), E>,
{ {
let mut command = String::new(); let mut command = String::new();
for line in lines { for line in lines {
@ -45,51 +102,131 @@ where
} }
} }
LineType::Title(title) => { LineType::Title(title) => {
debug!("Ignoring title"); if show_title {
process_snippet(PageSnippet::Linebreak)?;
process_snippet(PageSnippet::Indent(indent.base))?;
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, // This is safe as long as the parsed title is only the command,
// and the iterator yields values in order of appearance. // and the iterator yields values in order of appearance.
command = title; command = title;
debug!("Detected command name: {}", &command); debug!("Detected command name: {command}");
}
LineType::Description(text) => {
process_snippet(PageSnippet::Indent(indent.base))?;
process_snippet(PageSnippet::Description(&text))?;
process_snippet(PageSnippet::Linebreak)?;
}
LineType::ExampleText(text) => {
process_snippet(PageSnippet::Indent(indent.base))?;
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) => { LineType::ExampleCode(text) => {
process_snippet(PageSnippet::NormalCode(" "))?; process_snippet(PageSnippet::Indent(indent.command))?;
highlight_code(&command, &text, process_snippet)?; highlight_code(&command, &text, process_snippet)?;
process_snippet(PageSnippet::Linebreak)?; process_snippet(PageSnippet::Linebreak)?;
} }
LineType::Other(text) => debug!("Unknown line type: {:?}", text), LineType::Other(text) => debug!("Unknown line type: {text:?}"),
} }
} }
process_snippet(PageSnippet::Linebreak)?; process_snippet(PageSnippet::Linebreak)?;
Ok(()) Ok(())
} }
/// Highlight code examples including user variables in {{ curly braces }}. /// Highlight code examples.
fn highlight_code<'a, E>( /// - parse placeholders (`{{ curly braces }}`)
command: &'a str, /// - replace escaped placeholder markers (`\{\{` and `\}\}`)
text: &'a str, fn highlight_code<E>(
process_snippet: &mut impl FnMut(PageSnippet<'a>) -> Result<(), E>, command: &str,
mut text: &str,
process_snippet: &mut impl FnMut(PageSnippet<&str>) -> Result<(), E>,
) -> Result<(), E> { ) -> Result<(), E> {
let variable_splits = text // We replace escaped placeholder markers at the end so that our replacing does not interfere
.split("}}") // with finding the actual markers.
.map(|s| s.split_once("{{").unwrap_or((s, ""))); // NOTE: This is not optimal, as it allocates one String for each `replace`
for (code_segment, variable) in variable_splits { let replace_escaped = |s: &str| s.replace(r"\{\{", "{{").replace(r"\}\}", "}}");
highlight_code_segment(command, code_segment, process_snippet)?;
process_snippet(PageSnippet::Variable(variable))?; 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,
)?;
}
let placeholder_content = replace_escaped(placeholder_content);
if let Some(s) = placeholder_content.strip_prefix('[')
&& let Some(s) = s.strip_suffix(']')
&& let Some((short, long)) = s.split_once('|')
{
process_snippet(PageSnippet::PlaceholderVariants { short, long })?;
} else {
process_snippet(PageSnippet::Placeholder(&placeholder_content))?;
}
text = &text[end_marker + 2..];
} }
if !text.is_empty() {
highlight_code_segment(command, &replace_escaped(text), process_snippet)?;
}
Ok(()) 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 /// Yields `NormalCode` and `CommandName` in alternating order according to the occurrences of
/// `command_name` in `segment`. Variables are not detected here, see `highlight_code` /// `command_name` in `segment`. Placeholders are not detected here, see `highlight_code`
/// instead. /// instead.
fn highlight_code_segment<'a, E>( fn highlight_code_segment<'a, E>(
command_name: &'a str, command_name: &'a str,
mut segment: &'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> { ) -> Result<(), E> {
if !command_name.is_empty() { if !command_name.is_empty() {
let mut search_start = 0; let mut search_start = 0;
@ -119,20 +256,17 @@ fn is_freestanding_substring(surrounding: &str, substring: (usize, usize)) -> bo
let char_before_is_okay = surrounding[..start] let char_before_is_okay = surrounding[..start]
.chars() .chars()
.last() .last()
.filter(|prev_char| !prev_char.is_whitespace()) .is_none_or(char::is_whitespace);
.is_none();
let char_after_is_okay = surrounding[end..] let char_after_is_okay = surrounding[end..]
.chars() .chars()
.next() .next()
.filter(|next_char| !next_char.is_whitespace()) .is_none_or(char::is_whitespace);
.is_none();
char_before_is_okay && char_after_is_okay char_before_is_okay && char_after_is_okay
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use PageSnippet::*;
#[test] #[test]
fn test_is_freestanding_substring() { fn test_is_freestanding_substring() {
@ -159,80 +293,251 @@ 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 yielded = Vec::new();
let mut process_snippet = |snip: PageSnippet<'a>| { let mut process_snippet = |snip: PageSnippet<&str>| {
if !snip.is_empty() { if !snip.is_empty() {
yielded.push(snip); yielded.push(snip.map(str::to_string));
} }
Ok::<(), ()>(()) Ok::<(), ()>(())
}; };
highlight_code_segment(cmd, segment, &mut process_snippet) highlight_code(cmd, segment, &mut process_snippet).expect("highlight code segment failed");
.expect("highlight code segment failed");
yielded yielded
} }
#[test] mod highlight_code_segment {
fn test_highlight_code_segment() { use super::*;
assert!(run("make", "").is_empty()); use PageSnippet::*;
assert_eq!(
&run("make", "make all CC=clang -q"), #[test]
&[CommandName("make"), NormalCode(" all CC=clang -q")] fn test_highlight_code_segment() {
); assert!(run("make", "").is_empty());
assert_eq!( assert_eq!(
&run("make", " make money --always-make"), &run("make", "make all CC=clang -q"),
&[ &[CommandName("make"), NormalCode(" all CC=clang -q")]
NormalCode(" "), );
CommandName("make"), assert_eq!(
NormalCode(" money --always-make") &run("make", " make money --always-make"),
] &[
); NormalCode(" "),
assert_eq!( CommandName("make"),
&run("git commit", "git commit -m 'git commit'"), NormalCode(" money --always-make")
&[CommandName("git commit"), NormalCode(" -m 'git commit'"),] ]
); );
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] mod placeholders {
fn test_i18n() { use super::*;
assert_eq!( use PageSnippet::*;
&run("mäke", "mäke höhlenrätselbücher"),
&[CommandName("mäke"), NormalCode(" höhlenrätselbücher")] #[test]
); fn placeholder_vs_escaped() {
assert_eq!( assert_eq!(
&run( run("ping", "ping {{example.com}}"),
"Müll", [
"1000 Gründe warum Müll heute größer ist als Müll früher, ärgerlich" CommandName("ping"),
), NormalCode(" "),
&[ Placeholder("example.com"),
NormalCode("1000 Gründe warum "), ],
CommandName("Müll"), );
NormalCode(" heute größer ist als "), assert_eq!(
CommandName("Müll"), run(
NormalCode(" früher, ärgerlich") "docker inspect",
] r"docker inspect --format '\{\{range.NetworkSettings.Networks\}\}\{\{.IPAddress\}\}\{\{end\}\}' {{container}}"
); ),
assert_eq!( [
&run( CommandName("docker inspect"),
"übergang", NormalCode(
"die Zustandsübergangsfunktion übergang Änderungen", " --format '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "
), ),
&[ Placeholder("container"),
NormalCode("die Zustandsübergangsfunktion "), ],
CommandName("übergang"), );
NormalCode(" Änderungen") assert_eq!(
], run("mount", r"mount \\{{computer_name}}\{{share_name}} Z:"),
); [
CommandName("mount"),
NormalCode(r" \\"),
Placeholder("computer_name"),
NormalCode(r"\"),
Placeholder("share_name"),
NormalCode(" Z:"),
],
);
assert_eq!(run("", r"\{"), [NormalCode(r"\{")]);
assert_eq!(run("", r"\{{a"), [NormalCode(r"\{{a")]);
assert_eq!(run("", r"\{{a}}"), [NormalCode(r"\"), Placeholder("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 "),
Placeholder("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(" "), Placeholder("}")]
);
// And these are just to document the current behavior
assert_eq!(run("", "{{{}}}"), [Placeholder("{}")]);
assert_eq!(run("", "{{{{}}}"), [Placeholder("{{}")]);
assert_eq!(run("", "{{{}}}}"), [Placeholder("{}}")]);
}
#[test]
fn escaped_inside_placeholder() {
assert_eq!(
run(
"playerctl",
r#"playerctl metadata {{[-f|--format]}} "{{Now playing: \{\{artist\}\} - \{\{album\}\} - \{\{title\}\}}}""#
),
[
CommandName("playerctl"),
NormalCode(" metadata "),
PlaceholderVariants {
short: "-f",
long: "--format"
},
NormalCode(" \""),
Placeholder("Now playing: {{artist}} - {{album}} - {{title}}"),
NormalCode("\""),
],
);
}
#[test]
fn placeholder_inside_escaped() {
assert_eq!(
run("test", r"test \{\{{{var}} normal\}\}"),
[
CommandName("test"),
NormalCode(" {{"),
Placeholder("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"), Placeholder("x")],);
}
} }
#[test] mod placeholder_variants {
fn test_empty_command() { use super::*;
let segment = "some code"; use PageSnippet::*;
let snippets = [NormalCode(segment)];
assert_eq!(run("", segment), snippets); #[test]
assert_eq!(run(" ", segment), snippets); fn missing_marker() {
assert_eq!(run(" \t ", segment), snippets); assert_eq!(
run("foo", "{{[short|long]}}"),
[PlaceholderVariants {
short: "short",
long: "long"
}]
);
assert_eq!(run("foo", "{{short|long]}}"), [Placeholder("short|long]")]);
assert_eq!(run("foo", "{{[short|long}}"), [Placeholder("[short|long")]);
assert_eq!(run("foo", "{{[shortlong]}}"), [Placeholder("[shortlong]")]);
}
/// The character `[` is a valid command name
#[test]
fn command_name_interaction() {
for name in ["[", "]", "|"] {
assert_eq!(
run(name, "{{[short|long]}}"),
[PlaceholderVariants {
short: "short",
long: "long"
}]
);
}
}
#[test]
fn empty_variant() {
for name in ["[", "]", "|"] {
assert_eq!(
run(name, "{{[|long]}}"),
[PlaceholderVariants {
short: "",
long: "long"
}]
);
assert_eq!(
run(name, "{{[short|]}}"),
[PlaceholderVariants {
short: "short",
long: ""
}]
);
assert_eq!(run(name, "{{[|]}}"), [] as [PageSnippet::<String>; 0]);
}
}
} }
} }

View file

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

View file

@ -15,6 +15,8 @@
#![allow(clippy::similar_names)] #![allow(clippy::similar_names)]
#![allow(clippy::struct_excessive_bools)] #![allow(clippy::struct_excessive_bools)]
#![allow(clippy::too_many_lines)] #![allow(clippy::too_many_lines)]
#![allow(clippy::unnecessary_debug_formatting)]
#![allow(clippy::while_let_loop)]
#[cfg(not(any( #[cfg(not(any(
feature = "native-tls", feature = "native-tls",
@ -33,9 +35,12 @@ use std::{
process::{Command, ExitCode}, process::{Command, ExitCode},
}; };
use anyhow::{anyhow, Context, Result}; use anyhow::{Context, Result, anyhow};
use app_dirs::AppInfo; use cache::CacheConfig;
use clap::Parser; use clap::Parser;
use config::{ConfigLoader, Language, StyleConfig, TlsBackend};
use log::debug;
use types::PlatformType;
mod cache; mod cache;
mod cli; mod cli;
@ -48,115 +53,68 @@ mod types;
mod utils; mod utils;
use crate::{ use crate::{
cache::{Cache, CacheFreshness, PageLookupResult, TLDR_PAGES_DIR}, cache::{Cache, PageLookupResult, TLDR_PAGES_DIR},
cli::Cli, cli::Cli,
config::{get_config_dir, get_config_path, make_default_config, Config, PathWithSource}, config::{
extensions::Dedup, Config, PathWithSource, PlaceholderFormat, get_config_dir, make_default_config,
supported_tls_backends_string,
},
output::print_page, output::print_page,
types::{ColorOptions, PlatformType}, types::ColorOptions,
utils::{print_error, print_warning}, utils::{print_error, print_warning},
}; };
const NAME: &str = "tealdeer"; const NAME: &str = "tealdeer";
const APP_INFO: AppInfo = AppInfo { static TEALDEER_PAGE: &str =
name: NAME, include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/pages/tealdeer.md"));
author: NAME,
};
/// The cache should be updated if it was explicitly requested,
/// or if an automatic update is due and allowed.
fn should_update_cache(cache: &Cache, args: &Cli, config: &Config) -> bool {
args.update
|| (!args.no_auto_update
&& config.updates.auto_update
&& cache
.last_update()
.map_or(true, |ago| ago >= config.updates.auto_update_interval))
}
#[derive(PartialEq)]
enum CheckCacheResult {
CacheFound,
CacheMissing,
}
/// Check the cache for freshness. If it's stale or missing, show a warning.
fn check_cache(cache: &Cache, args: &Cli, enable_styles: bool) -> CheckCacheResult {
match cache.freshness() {
CacheFreshness::Fresh => CheckCacheResult::CacheFound,
CacheFreshness::Stale(_) if args.quiet => CheckCacheResult::CacheFound,
CacheFreshness::Stale(age) => {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
CheckCacheResult::CacheFound
}
CacheFreshness::Missing => {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://tealdeer-rs.github.io/tealdeer/config_updates.html");
CheckCacheResult::CacheMissing
}
}
}
/// Clear the cache /// Clear the cache
fn clear_cache(cache: &Cache, quietly: bool) -> Result<()> { fn clear_cache(cache: Cache, quietly: bool) -> Result<()> {
let cache_dir_found = cache.clear().context("Could not clear cache")?; let cache_dir = cache.config().pages_directory.display();
cache.clear().context("Could not clear cache")?;
if !quietly { if !quietly {
let cache_dir = cache.cache_dir().display(); eprintln!("Successfully cleared cache at `{cache_dir}`.");
if cache_dir_found {
eprintln!("Successfully cleared cache at `{cache_dir}`.");
} else {
eprintln!("Cache directory not found at `{cache_dir}`, nothing to do.");
}
} }
Ok(()) Ok(())
} }
/// Update the cache /// Update the cache
fn update_cache(cache: &Cache, archive_source: &str, quietly: bool) -> Result<()> { fn update_cache(
cache cache: &mut Cache,
.update(archive_source) archive_source: &str,
tls_backend: TlsBackend,
quietly: bool,
) -> Result<()> {
let downloaded_languages = cache
.update(archive_source, tls_backend)
.context("Could not update cache")?; .context("Could not update cache")?;
if !quietly { if !quietly {
eprintln!("Successfully updated cache."); eprintln!("Successfully updated cache.");
eprint!("Pages for the following languages were downloaded: ");
let language_strings: Vec<_> = downloaded_languages
.into_iter()
.map(|lang| lang.0)
.collect();
if language_strings.is_empty() {
eprintln!("(none)");
} else {
eprintln!("{}", language_strings.join(", "));
}
} }
Ok(()) Ok(())
} }
/// Show file paths /// Show file paths
fn show_paths(config: &Config) { fn show_paths(config: &Config) {
let config_dir = get_config_dir().map_or_else( let config_dir = {
|e| format!("[Error: {e}]"), let (mut path, source) = get_config_dir();
|(mut path, source)| { path.push(""); // Trailing path separator
path.push(""); // Trailing path separator match path.to_str() {
match path.to_str() { Some(path) => format!("{path} ({source})"),
Some(path) => format!("{path} ({source})"), None => "[Invalid]".to_string(),
None => "[Invalid]".to_string(), }
} };
}, let config_path = config.file_path.to_string();
);
let config_path = get_config_path().map_or_else(
|e| format!("[Error: {e}]"),
|(path, _)| path.display().to_string(),
);
let cache_dir = config.directories.cache_dir.to_string(); let cache_dir = config.directories.cache_dir.to_string();
let pages_dir = { let pages_dir = {
let mut path = config.directories.cache_dir.path.clone(); let mut path = config.directories.cache_dir.path.clone();
@ -175,8 +133,8 @@ fn show_paths(config: &Config) {
println!("Custom pages dir: {custom_pages_dir}"); println!("Custom pages dir: {custom_pages_dir}");
} }
fn create_config() -> Result<()> { fn create_config(path: Option<&Path>) -> Result<()> {
let config_file_path = make_default_config().context("Could not create seed config")?; let config_file_path = make_default_config(path).context("Could not create seed config")?;
eprintln!( eprintln!(
"Successfully created seed config file here: {}", "Successfully created seed config file here: {}",
config_file_path.to_str().unwrap() config_file_path.to_str().unwrap()
@ -192,42 +150,6 @@ fn init_log() {
#[cfg(not(feature = "logging"))] #[cfg(not(feature = "logging"))]
fn init_log() {} fn init_log() {}
fn get_languages(env_lang: Option<&str>, env_language: Option<&str>) -> Vec<String> {
// Language list according to
// https://github.com/tldr-pages/tldr/blob/main/CLIENT-SPECIFICATION.md#language
if env_lang.is_none() {
return vec!["en".to_string()];
}
let env_lang = env_lang.unwrap();
// Create an iterator that contains $LANGUAGE (':' separated list) followed by $LANG (single language)
let locales = env_language.unwrap_or("").split(':').chain([env_lang]);
let mut lang_list = Vec::new();
for locale in locales {
// Language plus country code (e.g. `en_US`)
if locale.len() >= 5 && locale.chars().nth(2) == Some('_') {
lang_list.push(&locale[..5]);
}
// Language code only (e.g. `en`)
if locale.len() >= 2 && locale != "POSIX" {
lang_list.push(&locale[..2]);
}
}
lang_list.push("en");
lang_list.clear_duplicates();
lang_list.into_iter().map(str::to_string).collect()
}
fn get_languages_from_env() -> Vec<String> {
get_languages(
std::env::var("LANG").ok().as_deref(),
std::env::var("LANGUAGE").ok().as_deref(),
)
}
fn spawn_editor(custom_pages_dir: &Path, file_name: &str) -> Result<()> { fn spawn_editor(custom_pages_dir: &Path, file_name: &str) -> Result<()> {
create_dir_all(custom_pages_dir).context("Failed to create custom pages directory")?; create_dir_all(custom_pages_dir).context("Failed to create custom pages directory")?;
@ -279,7 +201,26 @@ fn main() -> ExitCode {
fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> { fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
// Look up config file, if none is found fall back to default config. // Look up config file, if none is found fall back to default config.
let config = Config::load(enable_styles).context("Could not load config")?; debug!("Loading config");
let config_loader = match &args.config_path {
Some(path) if !args.seed_config => ConfigLoader::read(path.clone(), &args.override_config)
.context("Could not read config from given path")?,
_ => ConfigLoader::read_default_path(&args.override_config)
.context("Could not read config from default path")?,
};
let mut config = config_loader.load()?;
// Override styles if needed
if !enable_styles {
config.style = StyleConfig::default();
}
config.display.placeholder_format = match (args.short_options, args.long_options) {
(false, false) => config.display.placeholder_format, // keep old value
(true, false) => PlaceholderFormat::Short,
(false, true) => PlaceholderFormat::Long,
(true, true) => PlaceholderFormat::Both,
};
let custom_pages_dir = config let custom_pages_dir = config
.directories .directories
@ -313,148 +254,191 @@ fn try_main(args: Cli, enable_styles: bool) -> Result<ExitCode> {
// Create a basic config and exit // Create a basic config and exit
if args.seed_config { if args.seed_config {
create_config()?; create_config(args.config_path.as_deref())?;
return Ok(ExitCode::SUCCESS); return Ok(ExitCode::SUCCESS);
} }
let platforms = compute_platforms(args.platforms.as_ref());
// If a local file was passed in, render it and exit // If a local file was passed in, render it and exit
if let Some(file) = args.render { if let Some(file) = args.render {
let path = PageLookupResult::with_page(file); let reader = PageLookupResult::with_page(file).reader()?;
print_page(&path, args.raw, enable_styles, args.pager, &config)?; print_page(reader, args.raw, enable_styles, args.pager, &config)?;
return Ok(ExitCode::SUCCESS); return Ok(ExitCode::SUCCESS);
} }
// Instantiate cache. This will not yet create the cache directory! // The tealdeer page is embedded in the binary, no cache needed
let cache = Cache::new( if command == "tealdeer" {
&config.directories.cache_dir.path, print_page(
enable_styles, TEALDEER_PAGE.as_bytes(),
config.updates.tls_backend, args.raw,
); enable_styles,
args.pager,
// Clear cache, pass through &config,
if args.clear_cache { )?;
clear_cache(&cache, args.quiet)?; return Ok(ExitCode::SUCCESS);
} }
if should_update_cache(&cache, &args, &config) { if let Some(platforms) = args.platforms {
update_cache(&cache, &config.updates.archive_source, args.quiet)?; config.search.platforms = platforms;
} else if (args.list || !args.command.is_empty()) if !config.search.platforms.contains(&PlatformType::Common) {
&& check_cache(&cache, &args, enable_styles) == CheckCacheResult::CacheMissing config.search.platforms.push(PlatformType::Common);
{ }
// Cache is needed, but missing }
return Ok(ExitCode::FAILURE);
let (search_languages, download_languages): (&[_], &[_]) = match args.language.as_deref() {
Some(lang) => (&[Language(lang)], &[Language(lang)]),
None => (&config.search.languages, &config.updates.download_languages),
};
let cache_config = CacheConfig {
pages_directory: &config.directories.cache_dir.path().join(TLDR_PAGES_DIR),
custom_pages_directory: config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
platforms: &config.search.platforms,
search_languages,
download_languages,
};
if args.clear_cache {
if let Some(cache) = Cache::open(cache_config)? {
clear_cache(cache, args.quiet)?;
}
return Ok(ExitCode::SUCCESS);
}
let cache = if args.update || config.updates.auto_update && !args.no_auto_update {
let (mut cache, was_created) = Cache::open_or_create(cache_config)?;
if was_created || args.update || cache.age()? >= config.updates.auto_update_interval {
let result = update_cache(
&mut cache,
config.updates.archive_source,
config.updates.tls_backend,
args.quiet,
);
if let Err(e) = result {
print_error(enable_styles, &e);
eprintln!();
eprintln!(
"Note: Update errors are often caused by unexpected or missing TLS certificates."
);
eprintln!(
"You are currently using the following TLS backend: {}",
config.updates.tls_backend,
);
eprintln!(
"Try changing the updates.tls_backend setting in the config file, for example:"
);
eprintln!();
eprintln!(" [updates]");
eprintln!(" tls_backend = \"rustls-with-native-roots\"");
eprintln!();
eprintln!(
"This build of tealdeer has support for the following options: {}",
supported_tls_backends_string(),
);
return Ok(ExitCode::FAILURE);
}
}
cache
} else if args.list || !command.is_empty() {
// Cache is needed for these commands to work
let Some(cache) = Cache::open(cache_config)? else {
if !args.quiet {
print_error(
enable_styles,
&anyhow::anyhow!(
"Page cache not found. Please run `tldr --update` to download the cache."
),
);
println!("\nNote: You can optionally enable automatic cache updates by adding the");
println!("following config to your config file:\n");
println!(" [updates]");
println!(" auto_update = true\n");
println!("The path to your config file can be looked up with `tldr --show-paths`.");
println!("To create an initial config file, use `tldr --seed-config`.\n");
println!("You can find more tips and tricks in our docs:\n");
println!(" https://docs.tealdeer.org");
}
return Ok(ExitCode::FAILURE);
};
if let Some(max_cache_age) = config.updates.warn_cache_age {
let age = cache.age()?;
if age > max_cache_age && !args.quiet {
print_warning(
enable_styles,
&format!(
"The cache hasn't been updated for {} days.\n\
You should probably run `tldr --update` soon.",
age.as_secs() / 24 / 3600
),
);
}
}
cache
} else {
// There is nothing left to do
return Ok(ExitCode::SUCCESS);
}; };
// List cached commands and exit
if args.list { if args.list {
println!( for page in cache.list_pages()? {
"{}", println!("{page}");
cache.list_pages(custom_pages_dir, &platforms).join("\n") }
);
return Ok(ExitCode::SUCCESS); return Ok(ExitCode::SUCCESS);
} }
// Show command from cache // Show command from cache
if !command.is_empty() { if !command.is_empty() {
// Collect languages // TODO: Remove this check 1 year after version 1.7.0 was released
let languages = args if cache.old_custom_pages_exist()? {
.language print_warning(
.map_or_else(get_languages_from_env, |lang| vec![lang]); enable_styles,
&format!(
"Custom pages using the old naming convention were found in {}.\n\
Please rename them to follow the new convention:\n\
- `<name>.page` `<name>.page.md`\n\
- `<name>.patch` `<name>.patch.md`",
cache
.config()
.custom_pages_directory
.expect("Old custom pages can only exist in custom pages directory")
.display(),
),
);
}
// Search for command in cache let Some(result) = cache.find_page(&command) else {
let Some(lookup_result) = cache.find_page(
&command,
&languages,
config
.directories
.custom_pages_dir
.as_ref()
.map(PathWithSource::path),
&platforms,
) else {
if !args.quiet { if !args.quiet {
print_warning( print_warning(
enable_styles, enable_styles,
&format!( &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\ 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); 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) Ok(ExitCode::SUCCESS)
} }
/// Returns the passed or default platform types and appends `PlatformType::Common` as fallback.
fn compute_platforms(platforms: Option<&Vec<PlatformType>>) -> Vec<PlatformType> {
match platforms {
Some(p) => {
let mut result = p.clone();
if !result.contains(&PlatformType::Common) {
result.push(PlatformType::Common);
}
result
}
None => vec![PlatformType::current(), PlatformType::Common],
}
}
#[cfg(test)]
mod test {
use crate::get_languages;
mod language {
use super::*;
#[test]
fn missing_lang_env() {
let lang_list = get_languages(None, Some("de:fr"));
assert_eq!(lang_list, ["en"]);
let lang_list = get_languages(None, None);
assert_eq!(lang_list, ["en"]);
}
#[test]
fn missing_language_env() {
let lang_list = get_languages(Some("de"), None);
assert_eq!(lang_list, ["de", "en"]);
}
#[test]
fn preference_order() {
let lang_list = get_languages(Some("de"), Some("fr:cn"));
assert_eq!(lang_list, ["fr", "cn", "de", "en"]);
}
#[test]
fn country_code_expansion() {
let lang_list = get_languages(Some("pt_BR"), None);
assert_eq!(lang_list, ["pt_BR", "pt", "en"]);
}
#[test]
fn ignore_posix_and_c() {
let lang_list = get_languages(Some("POSIX"), None);
assert_eq!(lang_list, ["en"]);
let lang_list = get_languages(Some("C"), None);
assert_eq!(lang_list, ["en"]);
}
#[test]
fn no_duplicates() {
let lang_list = get_languages(Some("de"), Some("fr:de:cn:de"));
assert_eq!(lang_list, ["fr", "de", "cn", "en"]);
}
}
}

View file

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

View file

@ -118,18 +118,14 @@ impl PlatformType {
#[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, clap::ValueEnum)] #[derive(Debug, Eq, PartialEq, Copy, Clone, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ColorOptions { pub enum ColorOptions {
Always, Always,
#[default]
Auto, Auto,
Never, Never,
} }
impl Default for ColorOptions {
fn default() -> Self {
Self::Auto
}
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub enum LineType { pub enum LineType {
Empty, Empty,
@ -205,6 +201,8 @@ pub enum PathSource {
EnvVar, EnvVar,
/// Config file /// Config file
ConfigFile, ConfigFile,
/// CLI argument override
Cli,
} }
impl fmt::Display for PathSource { impl fmt::Display for PathSource {
@ -216,6 +214,7 @@ impl fmt::Display for PathSource {
Self::OsConvention => "OS convention", Self::OsConvention => "OS convention",
Self::EnvVar => "env variable", Self::EnvVar => "env variable",
Self::ConfigFile => "config file", Self::ConfigFile => "config file",
Self::Cli => "command line argument",
} }
) )
} }

View file

@ -0,0 +1,32 @@
# playerctl
> Control media players via MPRIS.
> More information: <https://github.com/altdesktop/playerctl#using-the-cli>.
- Toggle play:
`playerctl play-pause`
- Skip to the next track:
`playerctl next`
- Go back to the previous track:
`playerctl previous`
- List all players:
`playerctl {{[-l|--list-all]}}`
- Send a command to a specific player:
`playerctl {{[-p|--player]}} {{player_name}} {{play-pause|next|previous|...}}`
- Send a command to all players:
`playerctl {{[-a|--all-players]}} {{play-pause|next|previous|...}}`
- Display metadata about the current track:
`playerctl metadata {{[-f|--format]}} "{{Now playing: \{\{artist\}\} - \{\{album\}\} - \{\{title\}\}}}"`

View file

@ -1,7 +1,7 @@
//! Integration tests. //! Integration tests.
use std::{ use std::{
fs::{self, create_dir_all, File}, fs::{self, File, create_dir_all},
io::{self, Write}, io::{self, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
@ -11,6 +11,7 @@ use std::{
use assert_cmd::prelude::*; use assert_cmd::prelude::*;
use predicates::{ use predicates::{
boolean::PredicateBooleanExt, boolean::PredicateBooleanExt,
ord::eq,
prelude::predicate::str::{contains, diff, is_empty, is_match}, prelude::predicate::str::{contains, diff, is_empty, is_match},
}; };
use tempfile::{Builder as TempfileBuilder, TempDir}; use tempfile::{Builder as TempfileBuilder, TempDir};
@ -36,14 +37,11 @@ impl TestEnv {
features: vec![], features: vec![],
}; };
create_dir_all(&this.cache_dir()).unwrap(); create_dir_all(this.cache_dir()).unwrap();
create_dir_all(&this.config_dir()).unwrap(); create_dir_all(this.config_dir()).unwrap();
create_dir_all(&this.custom_pages_dir()).unwrap(); create_dir_all(this.custom_pages_dir()).unwrap();
this.append_to_config(format!( this.init_config();
"directories.cache_dir = '{}'\n",
this.cache_dir().to_str().unwrap(),
));
this this
} }
@ -69,6 +67,33 @@ impl TestEnv {
.write_all(content.as_ref().as_bytes()) .write_all(content.as_ref().as_bytes())
.expect("Failed to append to config file."); .expect("Failed to append to config file.");
} }
fn delete_config(&self) {
fs::remove_file(self.config_dir().join("config.toml")).unwrap();
}
fn init_config(&self) {
self.append_to_config(format!(
"directories.cache_dir = '{}'\n",
self.cache_dir().to_str().unwrap(),
));
}
fn create_secondary_config(self) -> Self {
self.append_to_secondary_config(format!(
"directories.cache_dir = '{}'\n",
self.cache_dir().to_str().unwrap(),
));
self
}
fn append_to_secondary_config(&self, content: impl AsRef<str>) {
File::options()
.create(true)
.append(true)
.open(self.config_dir().join("config-secondary.toml"))
.expect("Failed to open config file")
.write_all(content.as_ref().as_bytes())
.expect("Failed to append to config file.");
}
fn remove_initial_config(self) -> Self { fn remove_initial_config(self) -> Self {
let _ = fs::remove_file(self.config_dir().join("config.toml")); let _ = fs::remove_file(self.config_dir().join("config.toml"));
@ -82,7 +107,21 @@ impl TestEnv {
/// Add entry for that environment to an OS-specific subfolder. /// Add entry for that environment to an OS-specific subfolder.
fn add_os_entry(&self, os: &str, name: &str, contents: &str) { fn add_os_entry(&self, os: &str, name: &str, contents: &str) {
let dir = self.cache_dir().join(TLDR_PAGES_DIR).join("pages").join(os); self.add_os_lang_entry(os, "en", name, contents);
}
/// Add entry for that environment to a language-specific subfolder.
fn add_lang_entry(&self, lang: &str, name: &str, contents: &str) {
self.add_os_lang_entry("common", lang, name, contents);
}
/// Add entry for that environment to an OS- and language specific subfolder.
fn add_os_lang_entry(&self, os: &str, lang: &str, name: &str, contents: &str) {
let dir = self
.cache_dir()
.join(TLDR_PAGES_DIR)
.join(format!("pages.{lang}"))
.join(os);
create_dir_all(&dir).unwrap(); create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{name}.md")), contents.as_bytes()).unwrap(); fs::write(dir.join(format!("{name}.md")), contents.as_bytes()).unwrap();
@ -129,6 +168,19 @@ impl TestEnv {
} }
let run = build.run().expect("Failed to build tealdeer for testing"); let run = build.run().expect("Failed to build tealdeer for testing");
let mut cmd = run.command(); let mut cmd = run.command();
// Avoid inheriting those from the test process. We can't just use .env_clear() because
// this breaks tests on Windows in GitHub Actions.
let relevant_env_variables = [
"LANG",
"LANGUAGE",
"TEALDEER_CACHE_DIR",
"EDITOR",
"NO_COLOR",
];
for variable_name in relevant_env_variables {
cmd.env_remove(variable_name);
}
cmd.env("TEALDEER_CONFIG_DIR", self.config_dir().to_str().unwrap()); cmd.env("TEALDEER_CONFIG_DIR", self.config_dir().to_str().unwrap());
cmd cmd
} }
@ -183,6 +235,61 @@ fn test_cannot_build_without_tls_feature() {
let _ = TestEnv::new().no_default_features().command(); let _ = TestEnv::new().no_default_features().command();
} }
#[test]
fn test_load_the_correct_config() {
let testenv = TestEnv::new()
.install_default_cache()
.create_secondary_config();
testenv.append_to_secondary_config(include_str!("style-config.toml"));
let expected_default = include_str!("rendered/inkscape-default.expected");
let expected_with_config = include_str!("rendered/inkscape-with-config.expected");
testenv
.command()
.args(["--color", "always", "inkscape-v2"])
.assert()
.success()
.stdout(diff(expected_default));
testenv
.command()
.args([
"--color",
"always",
"--config-path",
testenv
.config_dir()
.join("config-secondary.toml")
.to_str()
.unwrap(),
"inkscape-v2",
])
.assert()
.success()
.stdout(diff(expected_with_config));
}
#[test]
fn test_fail_on_custom_config_path_is_directory() {
let testenv = TestEnv::new();
let error = if cfg!(windows) {
"Access is denied"
} else {
"Is a directory"
};
testenv
.command()
.args([
"--config-path",
testenv.config_dir().to_str().unwrap(),
"sl",
])
.assert()
.failure()
.stderr(contains(error));
}
#[test] #[test]
fn test_missing_cache() { fn test_missing_cache() {
TestEnv::new() TestEnv::new()
@ -193,6 +300,16 @@ fn test_missing_cache() {
.stderr(contains("Page cache not found. Please run `tldr --update`")); .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")] #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")]
#[test] #[test]
fn test_update_cache_default_features() { fn test_update_cache_default_features() {
@ -282,6 +399,23 @@ fn test_quiet_cache() {
.stdout(is_empty()); .stdout(is_empty());
} }
#[test]
fn test_clear_only_pages_directory() {
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.args(["--clear-cache"])
.assert()
.success()
.stderr(contains(format!(
"Successfully cleared cache at `{}`.",
testenv.cache_dir().join(TLDR_PAGES_DIR).to_str().unwrap(),
)));
assert!(testenv.cache_dir().is_dir());
assert!(!testenv.cache_dir().join(TLDR_PAGES_DIR).exists());
}
#[test] #[test]
fn test_warn_invalid_tls_backend() { fn test_warn_invalid_tls_backend() {
let testenv = TestEnv::new() let testenv = TestEnv::new()
@ -311,6 +445,21 @@ fn test_quiet_failures() {
.stdout(is_empty()); .stdout(is_empty());
} }
#[test]
fn test_quiet_missing_cache() {
let testenv = TestEnv::new();
for args in [["--list", "--quiet"], ["sl", "--quiet"]] {
testenv
.command()
.args(args)
.assert()
.failure()
.stdout(is_empty())
.stderr(is_empty());
}
}
#[test] #[test]
fn test_quiet_old_cache() { fn test_quiet_old_cache() {
let testenv = TestEnv::new().install_default_cache(); let testenv = TestEnv::new().install_default_cache();
@ -336,6 +485,26 @@ fn test_quiet_old_cache() {
.stderr(contains("The cache hasn't been updated for ").not()); .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")] #[cfg_attr(feature = "ignore-online-tests", ignore = "online test")]
#[test] #[test]
fn test_create_cache_directory_path() { fn test_create_cache_directory_path() {
@ -356,38 +525,59 @@ fn test_create_cache_directory_path() {
.assert() .assert()
.success() .success()
.stderr(contains(format!( .stderr(contains(format!(
"Successfully created cache directory path `{}`.", "Successfully created cache directory `{}`.",
internal_cache_dir.to_str().unwrap() internal_cache_dir.join(TLDR_PAGES_DIR).to_str().unwrap()
))) )))
.stderr(contains("Successfully updated cache.")); .stderr(contains("Successfully updated cache."));
assert!(internal_cache_dir.is_dir()); assert!(internal_cache_dir.is_dir());
} }
#[cfg_attr(feature = "ignore-online-tests", ignore = "online test")]
#[test] #[test]
fn test_cache_location_not_a_directory() { fn test_cache_location_not_a_directory() {
let testenv = TestEnv::new().remove_initial_config(); let testenv = TestEnv::new();
let cache_dir = &testenv.cache_dir(); let cache_dir = &testenv.cache_dir();
let internal_file = cache_dir.join("internal"); File::create(cache_dir.join(TLDR_PAGES_DIR)).unwrap();
File::create(&internal_file).unwrap();
testenv.append_to_config(format!(
"directories.cache_dir = '{}'\n",
internal_file.to_str().unwrap()
));
testenv testenv
.command() .command()
.arg("--update") .arg("--list")
.assert() .assert()
.failure() .failure()
.stderr(contains(format!( .stderr(contains(format!(
"Cache directory path `{}` is not a directory", "Cache directory `{}` exists, but is not a directory.",
internal_file.display(), cache_dir.join(TLDR_PAGES_DIR).display(),
))); )));
} }
#[cfg(unix)]
#[test]
fn test_cache_location_permission_denied() {
use std::os::unix::fs::PermissionsExt;
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.arg("--list")
.assert()
.success()
.stderr(contains("Permission denied").not());
// Make cache directory unreadable
let cache_dir = testenv.cache_dir();
let mut permissions = cache_dir.metadata().unwrap().permissions();
permissions.set_mode(0o0);
fs::set_permissions(cache_dir, permissions).unwrap();
testenv
.command()
.arg("--list")
.assert()
.failure()
.stderr(contains("Permission denied"));
}
#[test] #[test]
fn test_cache_location_source() { fn test_cache_location_source() {
let testenv = TestEnv::new().remove_initial_config(); let testenv = TestEnv::new().remove_initial_config();
@ -438,8 +628,9 @@ fn test_setup_seed_config() {
.failure() .failure()
.stderr(contains("A configuration file already exists")); .stderr(contains("A configuration file already exists"));
let testenv = testenv.remove_initial_config(); assert!(testenv.config_dir().join("config.toml").is_file());
let testenv = testenv.remove_initial_config();
testenv testenv
.command() .command()
.args(["--seed-config"]) .args(["--seed-config"])
@ -448,6 +639,48 @@ fn test_setup_seed_config() {
.stderr(contains("Successfully created seed config file here")); .stderr(contains("Successfully created seed config file here"));
assert!(testenv.config_dir().join("config.toml").is_file()); assert!(testenv.config_dir().join("config.toml").is_file());
// Create parent directories as needed for the default config path.
fs::remove_dir_all(testenv.config_dir()).unwrap();
testenv
.command()
.args(["--seed-config"])
.assert()
.success()
.stderr(contains("Successfully created seed config file here"));
assert!(testenv.config_dir().join("config.toml").is_file());
// Write the default config to --config-path if specified by the user
// at the same time.
let custom_config_path = testenv.config_dir().join("config_custom.toml");
testenv
.command()
.args([
"--seed-config",
"--config-path",
custom_config_path.to_str().unwrap(),
])
.assert()
.success()
.stderr(contains("Successfully created seed config file here"));
assert!(custom_config_path.is_file());
// DON'T create parent directories for a custom config path.
fs::remove_dir_all(testenv.config_dir()).unwrap();
testenv
.command()
.args([
"--seed-config",
"--config-path",
custom_config_path.to_str().unwrap(),
])
.assert()
.failure()
.stderr(contains("Could not create config file"));
assert!(!custom_config_path.is_file());
} }
#[test] #[test]
@ -504,11 +737,41 @@ fn test_os_specific_page() {
.success(); .success();
} }
#[test]
fn test_config_platforms() {
let testenv = TestEnv::new();
testenv.add_os_entry("sunos", "sunos-command", "");
let set_config_platforms = |platforms| {
testenv.delete_config();
testenv.init_config();
testenv.append_to_config(format!("search.platforms = {platforms}"));
};
// By default all platforms are searched
testenv.command().arg("sunos-command").assert().success();
set_config_platforms("[]");
testenv.command().arg("sunos-command").assert().failure();
set_config_platforms("['linux']");
testenv.command().arg("sunos-command").assert().failure();
set_config_platforms("['sunos']");
testenv.command().arg("sunos-command").assert().success();
set_config_platforms("['linux', 'all']");
testenv.command().arg("sunos-command").assert().success();
set_config_platforms("['current', 'all']");
testenv.command().arg("sunos-command").assert().success();
}
#[test] #[test]
fn test_markdown_rendering() { fn test_markdown_rendering() {
let testenv = TestEnv::new().install_default_cache(); let testenv = TestEnv::new().install_default_cache();
let expected = include_str!("cache/pages/common/which.md"); let expected = include_str!("cache/pages.en/common/which.md");
testenv testenv
.command() .command()
.args(["--raw", "which"]) .args(["--raw", "which"])
@ -570,6 +833,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] #[test]
fn test_rendering_i18n() { fn test_rendering_i18n() {
_test_correct_rendering( _test_correct_rendering(
@ -596,6 +877,42 @@ fn test_correct_rendering_with_config() {
.stdout(diff(expected)); .stdout(diff(expected));
} }
/// An end-to-end integration test for rendering with show_title config option enabled.
#[test]
fn test_show_title_config() {
// Test that default behavior without show_title shows no title
let testenv = TestEnv::new().install_default_cache();
let expected_no_title = include_str!("rendered/inkscape-default.expected");
testenv
.command()
.args(["--color", "always", "inkscape-v2"])
.assert()
.success()
.stdout(diff(expected_no_title));
// Configure to enable show_title
testenv.append_to_config("display.show_title = true\n");
let expected_no_color = include_str!("rendered/inkscape-with-title-no-color.expected");
testenv
.command()
.args(["inkscape-v2"])
.assert()
.success()
.stdout(diff(expected_no_color));
let expected = include_str!("rendered/inkscape-with-title.expected");
testenv
.command()
.args(["--color", "always", "inkscape-v2"])
.assert()
.success()
.stdout(diff(expected));
}
#[test] #[test]
fn test_spaces_find_command() { fn test_spaces_find_command() {
let testenv = TestEnv::new().install_default_cache(); let testenv = TestEnv::new().install_default_cache();
@ -709,6 +1026,14 @@ fn test_macos_is_alias_for_osx() {
.args(["--platform", "osx", "--list"]) .args(["--platform", "osx", "--list"])
.assert() .assert()
.stdout("maconly\n"); .stdout("maconly\n");
testenv.append_to_config("search.platforms = ['osx']\n");
testenv.command().arg("--list").assert().stdout("maconly\n");
testenv.delete_config();
testenv.init_config();
testenv.append_to_config("search.platforms = ['macos']\n");
testenv.command().arg("--list").assert().stdout("maconly\n");
} }
#[test] #[test]
@ -727,6 +1052,89 @@ fn test_common_platform_is_used_as_fallback() {
.success(); .success();
} }
#[test]
fn test_search_language_precedence() {
let testenv = TestEnv::new();
for lang in ["en", "de", "it", "fr", "pl", "nl"] {
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();
for (key, value) in extra_env {
cmd.env(key, value);
}
cmd.args(extra_args);
cmd.arg("--list");
cmd.assert().success().stdout(eq(*expected));
}
};
let env_cases = &[
(vec![], vec![], "en\n"),
(vec![("LANGUAGE", "de:it")], vec![], "en\n"),
(
vec![("LANG", "fr"), ("LANGUAGE", "de:it")],
vec![],
"de\nen\nfr\nit\n",
),
(
vec![("LANG", "fr"), ("LANGUAGE", "de:it")],
vec!["--language", "pl"],
"pl\n",
),
];
run(env_cases);
// Environment is only used when config setting is not set
testenv.append_to_config("search.languages = ['nl']\n");
let config_cases = &[
(vec![], vec![], "nl\n"),
(vec![("LANGUAGE", "de:it")], vec![], "nl\n"),
(vec![("LANG", "fr"), ("LANGUAGE", "de:it")], vec![], "nl\n"),
(
vec![("LANG", "fr"), ("LANGUAGE", "de:it")],
vec!["--language", "pl"],
"pl\n",
),
];
run(config_cases);
// The above update setting does not change anything
testenv.append_to_config("updates.download_languages = ['cz']");
run(config_cases);
testenv.delete_config();
testenv.init_config();
testenv.append_to_config("updates.download_languages = ['cz']");
run(env_cases);
}
#[cfg_attr(feature = "ignore-online-tests", ignore = "online test")]
#[test]
fn test_update_language_arg() {
let testenv = TestEnv::new();
testenv
.command()
.env("LANG", "it")
.arg("--update")
.assert()
.success()
.stderr(contains("it"))
.stderr(contains("en"));
testenv
.command()
.env("LANG", "en")
.args(["--language", "it"])
.arg("--update")
.assert()
.success()
.stderr(contains("it"))
.stderr(contains("en").not());
}
#[test] #[test]
fn test_list_flag_rendering() { fn test_list_flag_rendering() {
let testenv = TestEnv::new().write_custom_pages_config(); let testenv = TestEnv::new().write_custom_pages_config();
@ -882,6 +1290,19 @@ fn test_autoupdate_cache() {
check_cache_updated(false); check_cache_updated(false);
} }
/// Regression test: `--no-auto-update` should be usable together with `--list`,
/// since the auto-update gate in `main.rs` also applies to `--list`.
#[test]
fn test_no_auto_update_with_list() {
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.args(["--list", "--no-auto-update"])
.assert()
.success();
}
/// End-end test to ensure .page.md files overwrite pages in cache_dir /// End-end test to ensure .page.md files overwrite pages in cache_dir
#[test] #[test]
fn test_custom_page_overwrites() { fn test_custom_page_overwrites() {
@ -892,7 +1313,7 @@ fn test_custom_page_overwrites() {
// Add .page.md file to custom_pages_dir // Add .page.md file to custom_pages_dir
testenv.add_page_entry( testenv.add_page_entry(
"inkscape-v2", "inkscape-v2",
include_str!("cache/pages/common/inkscape-v2.md"), include_str!("cache/pages.en/common/inkscape-v2.md"),
); );
// Load expected output // Load expected output
@ -935,7 +1356,7 @@ fn test_custom_patch_does_not_append_to_custom() {
// In addition to the page in the cache, add the same page as a custom page. // In addition to the page in the cache, add the same page as a custom page.
testenv.add_page_entry( testenv.add_page_entry(
"inkscape-v2", "inkscape-v2",
include_str!("cache/pages/common/inkscape-v2.md"), include_str!("cache/pages.en/common/inkscape-v2.md"),
); );
// Load expected output // Load expected output
@ -998,7 +1419,7 @@ fn test_raw_render_file() {
let path = testenv let path = testenv
.cache_dir() .cache_dir()
.join(TLDR_PAGES_DIR) .join(TLDR_PAGES_DIR)
.join("pages/common/inkscape-v1.md"); .join("pages.en/common/inkscape-v1.md");
let mut args = vec!["--color", "never", "-f", &path.to_str().unwrap()]; let mut args = vec!["--color", "never", "-f", &path.to_str().unwrap()];
// Default render // Default render
@ -1018,7 +1439,7 @@ fn test_raw_render_file() {
.args(&args) .args(&args)
.assert() .assert()
.success() .success()
.stdout(diff(include_str!("cache/pages/common/inkscape-v1.md"))); .stdout(diff(include_str!("cache/pages.en/common/inkscape-v1.md")));
} }
fn touch_custom_page(testenv: &TestEnv) { fn touch_custom_page(testenv: &TestEnv) {
@ -1080,3 +1501,68 @@ fn test_custom_pages_dir_is_not_dir() {
.assert() .assert()
.failure(); .failure();
} }
mod placeholder_format {
use super::*;
#[test]
fn default_long() {
let testenv = TestEnv::new().install_default_cache();
testenv
.command()
.args(["--color=always", "playerctl"])
.assert()
.success()
.stdout(eq(include_str!("rendered/playerctl-long.expected")));
}
#[test]
fn config() {
let cases = [
("short", include_str!("rendered/playerctl-short.expected")),
("long", include_str!("rendered/playerctl-long.expected")),
("both", include_str!("rendered/playerctl-both.expected")),
];
for (setting, expected) in cases {
let testenv = TestEnv::new().install_default_cache();
testenv.append_to_config(format!("display.placeholder_format = \"{setting}\"\n"));
testenv
.command()
.args(["--color=always", "playerctl"])
.assert()
.success()
.stdout(eq(expected));
}
}
#[test]
fn cli() {
let testenv = TestEnv::new().install_default_cache();
testenv.append_to_config("display.placeholder_format = \"both\"\n");
testenv
.command()
.args(["--color=always", "--short-options", "playerctl"])
.assert()
.success()
.stdout(eq(include_str!("rendered/playerctl-short.expected")));
testenv
.command()
.args(["--color=always", "--long-options", "playerctl"])
.assert()
.success()
.stdout(eq(include_str!("rendered/playerctl-long.expected")));
testenv
.command()
.args([
"--color=always",
"--short-options",
"--long-options",
"playerctl",
])
.assert()
.success()
.stdout(eq(include_str!("rendered/playerctl-both.expected")));
}
}

View file

@ -5,33 +5,33 @@
利用可能なパーケージとバージョンのリストの更新他の`apt`コマンドの前での実行を推奨): 利用可能なパーケージとバージョンのリストの更新他の`apt`コマンドの前での実行を推奨):
 sudo apt update sudo apt update
指定されたパッケージの検索: 指定されたパッケージの検索:
 apt search パッケージ apt search パッケージ
パッケージの情報を出力: パッケージの情報を出力:
 apt show パッケージ apt show パッケージ
パッケージのインストール、または利用可能な最新バージョンに更新: パッケージのインストール、または利用可能な最新バージョンに更新:
 sudo apt install パッケージ sudo apt install パッケージ
パッケージの削除`sudo apt remove --purge`の場合設定ファイルも削除): パッケージの削除`sudo apt remove --purge`の場合設定ファイルも削除):
 sudo apt remove パッケージ sudo apt remove パッケージ
インストールされている全てのパッケージを最新のバージョンにアップグレード: インストールされている全てのパッケージを最新のバージョンにアップグレード:
 sudo apt upgrade sudo apt upgrade
インストールできるすべてのパッケージを表示: インストールできるすべてのパッケージを表示:
 apt list apt list
インストールされた全てのパッケージを表示依存関係も表示: インストールされた全てのパッケージを表示依存関係も表示:
 apt list --installed 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

@ -4,29 +4,29 @@
Open an SVG file in the Inkscape GUI: Open an SVG file in the Inkscape GUI:
 inkscape filename.svg 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 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 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 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 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 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 inkscape --use-inkscape=v3.0 file

View file

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

View file

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

View file

@ -0,0 +1,32 @@
Control media players via MPRIS.
More information: <https://github.com/altdesktop/playerctl#using-the-cli>.
Toggle play:
playerctl play-pause
Skip to the next track:
playerctl next
Go back to the previous track:
playerctl previous
List all players:
playerctl [-l|--list-all]
Send a command to a specific player:
playerctl [-p|--player] player_name play-pause|next|previous|...
Send a command to all players:
playerctl [-a|--all-players] play-pause|next|previous|...
Display metadata about the current track:
playerctl metadata [-f|--format] "Now playing: {{artist}} - {{album}} - {{title}}"

View file

@ -0,0 +1,32 @@
Control media players via MPRIS.
More information: <https://github.com/altdesktop/playerctl#using-the-cli>.
Toggle play:
playerctl play-pause
Skip to the next track:
playerctl next
Go back to the previous track:
playerctl previous
List all players:
playerctl --list-all
Send a command to a specific player:
playerctl --player player_name play-pause|next|previous|...
Send a command to all players:
playerctl --all-players play-pause|next|previous|...
Display metadata about the current track:
playerctl metadata --format "Now playing: {{artist}} - {{album}} - {{title}}"

View file

@ -0,0 +1,32 @@
Control media players via MPRIS.
More information: <https://github.com/altdesktop/playerctl#using-the-cli>.
Toggle play:
playerctl play-pause
Skip to the next track:
playerctl next
Go back to the previous track:
playerctl previous
List all players:
playerctl -l
Send a command to a specific player:
playerctl -p player_name play-pause|next|previous|...
Send a command to all players:
playerctl -a play-pause|next|previous|...
Display metadata about the current track:
playerctl metadata -f "Now playing: {{artist}} - {{album}} - {{title}}"