Compare commits

..
Author SHA1 Message Date
Keith Hall 67e2ec24e0 Merge pull request #4048 from qodelicious/fix/no-color-error-prefix
Respect NO_COLOR for [bat error] and [bat warning] prefixes
2026-11-02 22:07:29 +02:00
Keith Hall d0920557da Merge branch 'master' into fix/no-color-error-prefix 2026-11-02 21:51:30 +02:00
Keith Hall cb6bb8eb03 Merge pull request #4038 from mmustafasenoglu/fix/cache-auto-build-4017
Build cache automatically on first run when custom sources exist
2026-11-02 21:48:23 +02:00
Keith Hall 1752596aa1 Merge branch 'master' into fix/cache-auto-build-4017 2026-11-02 21:09:54 +02:00
Keith Hall 46eacfbbb8 Merge pull request #3911 from logarithmone1128/kubeconfig-yaml-mapping
Map .kube/config to YAML
2026-11-02 20:57:44 +02:00
Keith Hall d389ea3869 Merge branch 'master' into kubeconfig-yaml-mapping 2026-11-02 20:46:47 +02:00
Keith Hall 0db13cbd37 Merge pull request #4070 from ump45nose/ps1-completion-wrap-auto
fix(completions): the PowerShell completion offers an invalid --wrap value
2026-11-02 15:03:58 +02:00
Keith Hall 673bac99eb Merge branch 'master' into ps1-completion-wrap-auto 2026-11-02 14:52:13 +02:00
Keith Hall 6ca658c80a Merge pull request #4103 from jaeminbird/config-bom
Ignore a UTF-8 BOM at the start of the config file
2026-11-02 13:08:25 +02:00
Keith Hall 6b2e311d79 Merge branch 'master' into config-bom 2026-11-02 12:54:14 +02:00
Keith Hall 1a2189789f Merge pull request #4102 from Huang-404-Q/fix/repeatable-valued-options
Allow value-taking options to be repeated, like boolean flags
2026-11-02 12:53:08 +02:00
huang b5281b5a4f fix(cli): allow valued options to be repeated
The config file is prepended to the command line, so an option set in
both places is passed twice. Boolean flags were made repeatable in
#3912, but options that take a value were missed: --binary,
--italic-text, --nonprintable-notation, --fallback-syntax and
--squeeze-limit were rejected with 'cannot be used multiple times',
which aborts bat with exit code 2 before it reads any input.

Add self-overrides so that the last occurrence wins, matching the
behavior of --color, --theme, --wrap, --tabs and friends.
2026-11-02 13:50:48 +08:00
auto-merge-dependabot-prs[bot] e95c32b6f0 Merge pull request #4106 from sharkdp/dependabot/github_actions/vedantmgoyal9/winget-releaser-b7370d4d152efdce803a26cc6ca9eee58cb2ad54
build(deps): bump vedantmgoyal9/winget-releaser from a8fff44b123f115ce4c21543edf8b677376fc7e0 to b7370d4d152efdce803a26cc6ca9eee58cb2ad54
2026-11-01 03:16:44 +00:00
dependabot[bot] f059c8da38 build(deps): bump vedantmgoyal9/winget-releaser
Bumps [vedantmgoyal9/winget-releaser](https://github.com/vedantmgoyal9/winget-releaser) from a8fff44b123f115ce4c21543edf8b677376fc7e0 to b7370d4d152efdce803a26cc6ca9eee58cb2ad54.
- [Release notes](https://github.com/vedantmgoyal9/winget-releaser/releases)
- [Commits](https://github.com/vedantmgoyal9/winget-releaser/compare/a8fff44b123f115ce4c21543edf8b677376fc7e0...b7370d4d152efdce803a26cc6ca9eee58cb2ad54)

---
updated-dependencies:
- dependency-name: vedantmgoyal9/winget-releaser
  dependency-version: b7370d4d152efdce803a26cc6ca9eee58cb2ad54
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-01 03:03:56 +00:00
yuwk 9507f53270 Merge remote-tracking branch 'upstream/master' into ps1-completion-wrap-auto
Signed-off-by: yuwk <1729065730@qq.com>

# Conflicts:
#	CHANGELOG.md
2026-10-31 21:49:19 +08:00
jaeminbird 7df6406ca9 Ignore a UTF-8 BOM at the start of the config file 2026-10-30 01:59:32 -04:00
Keith Hall 8f5727ea38 Merge pull request #4068 from ump45nose/diag-list-bat-config-dir
Report BAT_CONFIG_DIR in bat --diagnostic
2026-10-29 21:45:30 +02:00
Keith Hall b4fdcec6a1 Merge branch 'master' into diag-list-bat-config-dir 2026-10-29 21:29:06 +02:00
Keith Hall fe901f5051 Merge pull request #4101 from PerchAnton/codex/bat-i686-runner
ci: pin the i686 GNU runner to Ubuntu 24.04
2026-10-28 21:36:22 +02:00
PerchAnton 3e2c1bdc8e docs: link the i686 runner fix in changelog 2026-10-28 13:50:21 +02:00
PerchAnton e423744a7d ci: pin i686 GNU runner to Ubuntu 24.04 2026-10-28 13:48:22 +02:00
Keith Hall a9aa73d215 Merge pull request #3895 from 1t1sCooL/fix-vcredist-link
docs: fix dead Visual C++ Redistributable link in README
2026-10-20 22:18:09 +03:00
Keith Hall f0a58595bd Merge branch 'master' into fix-vcredist-link 2026-10-20 22:07:28 +03:00
Keith Hall 2fd71ad588 Merge pull request #4091 from NavpreetSSidhu/fix-js-block-comment-hang
Fix hang on block comment after an expression in JavaScript
2026-10-20 22:03:22 +03:00
Navpreet Sidhu 49d9ac1d6e changelog: reference PR #4091 2026-10-20 21:40:03 +05:30
Navpreet Sidhu 54b1f9c6af Fix hang on block comment after an expression in JavaScript 2026-10-20 21:31:39 +05:30
Mikhail Alabugin b841eadf09 Merge upstream into PR branch, resolve conflicts 2026-10-20 13:59:30 +03:00
qodelicious c199cc427a Add changelog entry for #4048 2026-10-18 07:04:43 +00:00
qodelicious f3cc4b6f03 Respect NO_COLOR for [bat error] and [bat warning] prefixes
The error and warning prefixes were always painted red/yellow, even
when NO_COLOR is set, which bat otherwise honors for its output.
2026-10-18 07:04:20 +00:00
yuwk 4475d88b47 Merge remote-tracking branch 'up/master' into ps1-completion-wrap-auto
# Conflicts:
#	CHANGELOG.md
2026-10-18 00:28:36 +08:00
yuwk b49aae8b1f Merge remote-tracking branch 'up/master' into diag-list-bat-config-dir
# Conflicts:
#	CHANGELOG.md
2026-10-18 00:28:36 +08:00
Keith Hall f748c1a467 Merge pull request #4069 from injust/patch-1
Add syntax mapping for Podman `.quadlets` files
2026-10-17 14:57:57 +03:00
Keith Hall d3afbaeb06 Merge branch 'master' into patch-1 2026-10-17 14:48:35 +03:00
Keith Hall dbe6ec265d Merge pull request #4076 from cindykrafft/fix/utf16be-first-line
Fix the first line of UTF-16BE input swallowing the second line
2026-10-17 14:41:31 +03:00
Keith Hall bc0a67e07c Merge branch 'master' into fix/utf16be-first-line 2026-10-17 14:28:44 +03:00
Keith Hall fca6c542d4 Merge pull request #4067 from ump45nose/docs-strip-ansi-mode
Document StripAnsiMode and its variants
2026-10-17 14:27:03 +03:00
Claude c2adbf6caa Fix the first line of UTF-16BE input swallowing the second line
In UTF-16BE a newline is 00 0A, so the initial read_until(b'\n') usually
already ends exactly at the end of line 1. The BE branch then always called
read_utf16_line, which read on to the next newline, merging lines 1 and 2.
Only keep reading when the first read did not end on a complete 00 0A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVn4SeAXPsUajXT2rtHQfo
2026-10-16 19:50:14 +00:00
ump45nose fef8513f6e Add changelog entry for #4070 2026-10-16 11:19:17 +08:00
yuwk 61340f0e42 fix(completions): --wrap has no 'always' mode, the value is 'auto' 2026-10-16 07:43:59 +08:00
injust f48149b119 Add syntax mapping for Podman .quadlets files 2026-10-15 21:52:43 +00:00
yuwk ca5eeb7c2a Add changelog entry for BAT_CONFIG_DIR in --diagnostic 2026-10-16 02:04:46 +08:00
yuwk 9438a74e6d Report BAT_CONFIG_DIR in bat --diagnostic
BAT_CONFIG_DIR overrides the config directory (src/bin/bat/directories.rs) and is documented in the README, but it was missing from the environment list that bat --diagnostic prints, so a bug report never showed where bat was actually reading its config from. Extend diagnostic_sanity_check to cover it.
2026-10-16 02:04:02 +08:00
ump45nose 8c63b37205 changelog: note StripAnsiMode rustdoc (#4067) 2026-10-15 15:13:56 +08:00
yuwk d50b53d0ef Document StripAnsiMode and its variants with doc comments 2026-10-15 09:37:35 +08:00
Keith Hall 62b439dae3 Merge pull request #4064 from ump45nose/doc-range-check-result-variants
Document RangeCheckResult variants with doc comments
2026-10-14 22:41:06 +03:00
Keith Hall e8d05da87a Merge branch 'master' into doc-range-check-result-variants 2026-10-14 22:22:14 +03:00
Keith Hall edd161aefb Merge pull request #4065 from ump45nose/doc-wrapping-mode-variant
Document WrappingMode::NoWrapping's bool payload with a doc comment
2026-10-14 22:22:03 +03:00
Keith Hall b95f8d37bf Merge branch 'master' into doc-range-check-result-variants 2026-10-14 22:20:52 +03:00
yuwk 0e3074cfe1 Document WrappingMode::NoWrapping's bool payload with a doc comment
The explanation of what the payload means used //, so rustdoc dropped it
from the public WrappingMode page. WrappingMode is re-exported from
lib.rs, so the variant is documented like the neighbouring enums.
2026-10-15 02:49:37 +08:00
Keith Hall 4d916905d1 Merge pull request #4063 from ump45nose/config-doc-comments
Document 4 public Config fields with doc comments
2026-10-14 20:59:49 +03:00
yuwk c47a163f4b Document RangeCheckResult variants with doc comments 2026-10-15 01:55:16 +08:00
yuwk a56bca503b Fix doc comments on four Config fields
The use_lessopen, set_terminal_title, strip_ansi and sanitize fields were
annotated with // instead of ///, so they were silently missing from the
rustdoc output for the public bat::config::Config struct. Every sibling
field already uses ///.
2026-10-15 00:23:49 +08:00
Keith Hall d9559c69f5 Merge pull request #4035 from Mathjk/fix/aux-epipe
Fix panic on closed stdout in auxiliary output paths
2026-10-07 22:49:03 +03:00
Mustafa Senoglu 78ea14bd9a Add PR number to changelog entry 2026-10-02 21:02:35 +03:00
Mustafa Senoglu 6ae020f998 Build cache automatically on first run when custom sources exist
Custom themes and syntaxes dropped into the config directory were
silently ignored until 'bat cache --build' was run manually. Now bat
builds the cache on first run when sources are present but no cache
exists yet. A failed build only warns and falls back to the integrated
assets; --no-custom-assets opts out.

Fixes #4017
2026-10-02 21:01:45 +03:00
Mathjk 12cbb20de4 changelog: reference PR #4035 so the changelog check passes 2026-10-03 01:03:09 +08:00
Mathjk a9b112b6fe Exit quietly on broken pipe in auxiliary output paths
The main output path has exited with status 0 on EPIPE since #232, but
auxiliary outputs still used println!/print!/io::stdout().flush().unwrap()
and panicked (exit code 101) when the reader of a piped stdout went away
early, e.g. 'bat --completion bash | head -n0'.

Route them through the existing convention instead: write!/writeln! to
io::stdout() and '?' so that Error::Io(BrokenPipe) reaches
default_error_handler, which exits with status 0. This covers
--completion, --config-file, --diagnostic (bugreport's print() becomes
format() + writeln!), --generate-config-file, the 'cache' subcommand
progress messages, the OSC title sequence of --set-terminal-title, and
the 'built without feature' notices.

Closes #4034
2026-10-02 02:23:08 +08:00
auto-merge-dependabot-prs[bot] 4608fc959a Merge pull request #4027 from sharkdp/dependabot/github_actions/vedantmgoyal9/winget-releaser-a8fff44b123f115ce4c21543edf8b677376fc7e0
build(deps): bump vedantmgoyal9/winget-releaser from 19e706d4c9121098010096f9c495a70a7518b30f to a8fff44b123f115ce4c21543edf8b677376fc7e0
2026-10-01 02:13:09 +00:00
dependabot[bot] dcd725dc4e build(deps): bump vedantmgoyal9/winget-releaser
Bumps [vedantmgoyal9/winget-releaser](https://github.com/vedantmgoyal9/winget-releaser) from 19e706d4c9121098010096f9c495a70a7518b30f to a8fff44b123f115ce4c21543edf8b677376fc7e0.
- [Release notes](https://github.com/vedantmgoyal9/winget-releaser/releases)
- [Commits](https://github.com/vedantmgoyal9/winget-releaser/compare/19e706d4c9121098010096f9c495a70a7518b30f...a8fff44b123f115ce4c21543edf8b677376fc7e0)

---
updated-dependencies:
- dependency-name: vedantmgoyal9/winget-releaser
  dependency-version: a8fff44b123f115ce4c21543edf8b677376fc7e0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-01 02:03:55 +00:00
Keith Hall 4987f76709 Merge pull request #4015 from mikehasa/fix/track-strikethrough-sgr9
Track strikethrough (SGR 9) so it is not dropped across highlighted tokens
2026-09-22 21:13:30 +03:00
mikehasaandClaude Opus 4.8 156ef1d2f4 Track strikethrough (SGR 9) so it is not dropped across highlighted tokens
The `Attributes` ANSI style tracker in `src/vscreen.rs` declares, initializes,
clears (in `sgr_reset`), and re-emits (in `Display`) a `strike` field
documented as `ON: ^[9m / OFF: ^[29m`, but `update_with_sgr()` had no match
arm for SGR 9 (set strikethrough) or 29 (clear strikethrough). As a result
`strike` was only ever cleared, never set.

Because bat re-applies the tracked input ANSI style before each highlighted
text run (nu_ansi_term appends a full reset after every token), attributes
that are tracked — bold, dim, italic, underline — survive across tokens,
while strikethrough was silently dropped after the first token. This path is
active by default (`--strip-ansi`/`--sanitize` default to `never`).

Add the missing `9` and `29` arms, mirroring the sibling underline (4/24) and
italic (3/23) arms, so strikethrough is tracked and re-emitted like every
other supported attribute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:15:31 -07:00
LOG 279395d607 Merge remote-tracking branch 'origin/master' into kubeconfig-yaml-mapping 2026-09-07 09:13:17 +08:00
Mikhail Alabugin eca6c5b688 Merge branch 'master' into fix-vcredist-link 2026-09-05 16:01:25 +03:00
Keith Hall 7323a7514f Merge pull request #3912 from logarithmone1128/repeatable-boolean-flags
Allow boolean flags to be repeated
2026-09-04 22:10:30 +03:00
LOG cf72a432ac Allow boolean flags to be repeated
bat prepends the arguments from its config file to the command line, so a
user who has `--show-all` in their config and also types `-A` passes the
flag twice, and clap rejects that with "the argument '--show-all' cannot be
used multiple times" and exit code 2. The file is never displayed.

`--plain`, `--number`, `--number-nonblank`, `--force-colorization` and
`--no-paging` already avoid this by overriding themselves. The same is now
done for the eight remaining flags that affect how a file is displayed:
--show-all, --diff, --chop-long-lines, --squeeze-blank, --unbuffered,
--quiet-empty, --no-custom-assets and --set-terminal-title.

Repeating a flag produces byte-identical output to giving it once, and
--show-all still conflicts with --language as before.
2026-09-04 18:01:33 +08:00
LOG 5b30913c55 Add changelog entry 2026-09-04 17:38:09 +08:00
LOG 9fbbfb60ec Map .kube/config to YAML
kubectl's config file has no extension, so it falls back to plain text.
The format is YAML, and `**/.kube/config` follows the same shape as the
existing `**/.aws/config` and gcloud mappings.
2026-09-04 17:37:45 +08:00
Keith Hall 16b5f99d16 Merge pull request #3908 from cuishuang/master
Use parsed CLI arguments to detect number flags
2026-09-04 06:46:32 +03:00
cuishuang b784eaf0ae Use parsed CLI arguments to detect number flags
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-09-04 11:00:36 +08:00
Keith Hall 2ed8b010af Merge pull request #3877 from Matei02355/fix-binary-detection-across-lines-3554
Detect binary content beyond the first line
2026-09-03 22:07:15 +03:00
Keith Hall 5fee86b97b Merge branch 'master' into fix-binary-detection-across-lines-3554 2026-09-03 21:54:06 +03:00
Keith Hall 66ad54c27d Merge pull request #3897 from cuishuang/master
fix: use last occurrence in combined short flags
2026-09-03 21:52:16 +03:00
cuishuang 2ed7adc49f Merge upstream master to resolve PR conflicts 2026-09-03 20:07:25 +08:00
Mikhail Alabugin ea3909bbdf Add changelog entry 2026-09-02 11:10:47 +03:00
auto-merge-dependabot-prs[bot] 0acf9417cd Merge pull request #3903 from sharkdp/dependabot/cargo/console-0.16.4
build(deps): bump console from 0.16.3 to 0.16.4
2026-09-01 03:29:44 +00:00
dependabot[bot] b5c8e101f8 build(deps): bump console from 0.16.3 to 0.16.4
Bumps [console](https://github.com/console-rs/console) from 0.16.3 to 0.16.4.
- [Release notes](https://github.com/console-rs/console/releases)
- [Changelog](https://github.com/console-rs/console/blob/main/CHANGELOG.md)
- [Commits](https://github.com/console-rs/console/compare/0.16.3...0.16.4)

---
updated-dependencies:
- dependency-name: console
  dependency-version: 0.16.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 03:15:44 +00:00
auto-merge-dependabot-prs[bot] 383abc83c8 Merge pull request #3904 from sharkdp/dependabot/cargo/bytesize-2.7.0
build(deps): bump bytesize from 2.4.2 to 2.7.0
2026-09-01 03:10:37 +00:00
dependabot[bot] 7928b4be52 build(deps): bump bytesize from 2.4.2 to 2.7.0
Bumps [bytesize](https://github.com/bytesize-rs/bytesize) from 2.4.2 to 2.7.0.
- [Release notes](https://github.com/bytesize-rs/bytesize/releases)
- [Changelog](https://github.com/bytesize-rs/bytesize/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bytesize-rs/bytesize/compare/bytesize-v2.4.2...bytesize-v2.7.0)

---
updated-dependencies:
- dependency-name: bytesize
  dependency-version: 2.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 02:54:28 +00:00
auto-merge-dependabot-prs[bot] 036a201df9 Merge pull request #3901 from sharkdp/dependabot/cargo/indexmap-2.14.1
build(deps): bump indexmap from 2.14.0 to 2.14.1
2026-09-01 02:50:18 +00:00
dependabot[bot] 2da852dc78 build(deps): bump indexmap from 2.14.0 to 2.14.1
Bumps [indexmap](https://github.com/indexmap-rs/indexmap) from 2.14.0 to 2.14.1.
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.0...2.14.1)

---
updated-dependencies:
- dependency-name: indexmap
  dependency-version: 2.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 02:29:34 +00:00
auto-merge-dependabot-prs[bot] 65c311e9fb Merge pull request #3900 from sharkdp/dependabot/cargo/globset-0.4.20
build(deps): bump globset from 0.4.18 to 0.4.20
2026-09-01 02:25:57 +00:00
dependabot[bot] b3c9516a9b build(deps): bump globset from 0.4.18 to 0.4.20
Bumps [globset](https://github.com/BurntSushi/ripgrep) from 0.4.18 to 0.4.20.
- [Release notes](https://github.com/BurntSushi/ripgrep/releases)
- [Changelog](https://github.com/BurntSushi/ripgrep/blob/master/CHANGELOG.md)
- [Commits](https://github.com/BurntSushi/ripgrep/compare/globset-0.4.18...globset-0.4.20)

---
updated-dependencies:
- dependency-name: globset
  dependency-version: 0.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 02:03:42 +00:00
Keith Hall 5f12c62b3e Merge pull request #3899 from rootsec1/fix/cargo-install-manifest-diagnostic
fix: avoid Cargo parsing syntax fixture as a manifest
2026-08-31 21:20:32 +03:00
Abhishek Murthy 7892a8754b chore: add pull request number to changelog 2026-08-31 18:06:25 +00:00
Abhishek Murthy 400c5dc6f1 fix: avoid parsing syntax fixture as Cargo manifest 2026-08-31 17:47:24 +00:00
cuishuang 1cedc1b4c3 fix: use last occurrence in combined short flags
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-08-28 22:23:39 +08:00
cuishuang b8430080f0 fix: use last occurrence in combined short flags
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-08-28 22:21:23 +08:00
Mikhail Alabugin a3f202f367 docs: fix dead Visual C++ Redistributable link in README 2026-08-27 13:11:46 +03:00
Keith Hall b671e53c2c Merge pull request #3857 from MeGaurav4/master
feat: implement `-b` / `--number-nonblank` for `cat -b` compatibility
2026-08-11 17:39:20 +03:00
MeGaurav4 de2e651bf5 Merge remote-tracking branch 'origin/master' into pr-3857 2026-08-11 11:48:10 +05:30
MeGaurav4 061fb216cb feat: implement -b / --number-nonblank for cat -b compatibility
Adds a `-b` / `--number-nonblank` flag that numbers only non-blank
lines.  Blank lines (those containing only the line terminator) are
printed without a number and don't advance the counter, matching
`cat -b` behaviour.

- `-b` implies `--style=numbers` (same as `-n`), but only counts
  non-blank lines
- `-b` takes precedence over `-n`: both `-bn` and `-nb` behave like `-b`
- `-bp` disables numbering (plain mode wins)
- Works with files, stdin, and piped output (like `-n`)

Closes #3856.
2026-08-11 11:48:10 +05:30
Keith Hall af59a32183 Merge pull request #3876 from Matei02355/fix-log-syntax-performance-3866
fix(syntax): avoid repeated log level scans
2026-08-10 21:30:13 +03:00
Keith Hall c63838efeb Merge branch 'master' into fix-log-syntax-performance-3866 2026-08-10 21:19:46 +03:00
Matei6942 cecbb80093 fix(syntax): preserve log-level scopes in Syslog 2026-08-10 20:56:35 +03:00
Matei6942 3ca4a01be6 fix(syntax): use start-of-line anchor for log levels 2026-08-10 20:00:44 +03:00
Keith Hall 8112350da6 Merge pull request #3747 from mvanhorn/fix/3697-bat-auto-detect-language-from-stdin
chore: improve auto-detection of language from stdin via first-line patterns and add tests
2026-08-10 19:43:01 +03:00
Keith Hall a5deaf63ff Merge branch 'master' into fix/3697-bat-auto-detect-language-from-stdin 2026-08-10 19:32:11 +03:00
Keith Hall 2fb2415731 Merge pull request #3885 from latent-9/fix-doc-typos
Fix doubled word in README
2026-08-10 05:37:48 +03:00
latent-9 a58f23724a Fix doubled word in README 2026-08-09 18:05:20 +12:00
Matei6942 bb492fff47 Add changelog entry for #3877 2026-08-02 11:53:54 +03:00
Matei6942 a96b9e2230 Detect binary content beyond the first line 2026-08-02 11:52:49 +03:00
Matei6942 531d54b110 docs: add changelog entry for #3876 2026-08-02 10:52:39 +03:00
Matei6942 db69aab106 fix(syntax): avoid repeated log level scans 2026-08-02 10:52:05 +03:00
auto-merge-dependabot-prs[bot] 2ba8db9c14 Merge pull request #3874 from sharkdp/dependabot/cargo/minus-5.7.2
build(deps): bump minus from 5.7.1 to 5.7.2
2026-08-01 04:01:25 +00:00
dependabot[bot] d344844486 build(deps): bump minus from 5.7.1 to 5.7.2
Bumps [minus](https://github.com/AMythicDev/minus) from 5.7.1 to 5.7.2.
- [Changelog](https://github.com/AMythicDev/minus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AMythicDev/minus/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: minus
  dependency-version: 5.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:50:26 +00:00
auto-merge-dependabot-prs[bot] aeb0e4c457 Merge pull request #3869 from sharkdp/dependabot/cargo/gix-0.86.0
build(deps): bump gix from 0.85.0 to 0.86.0
2026-08-01 03:47:33 +00:00
dependabot[bot] 1cf12d49bd build(deps): bump gix from 0.85.0 to 0.86.0
Bumps [gix](https://github.com/GitoxideLabs/gitoxide) from 0.85.0 to 0.86.0.
- [Release notes](https://github.com/GitoxideLabs/gitoxide/releases)
- [Changelog](https://github.com/GitoxideLabs/gitoxide/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GitoxideLabs/gitoxide/compare/gix-v0.85.0...gix-v0.86.0)

---
updated-dependencies:
- dependency-name: gix
  dependency-version: 0.86.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:33:08 +00:00
auto-merge-dependabot-prs[bot] 7cb212646c Merge pull request #3871 from sharkdp/dependabot/cargo/terminal-colorsaurus-1.0.3
build(deps): bump terminal-colorsaurus from 1.0.0 to 1.0.3
2026-08-01 03:28:34 +00:00
dependabot[bot] 6573ca9307 build(deps): bump terminal-colorsaurus from 1.0.0 to 1.0.3
Bumps [terminal-colorsaurus](https://github.com/tautropfli/terminal-colorsaurus) from 1.0.0 to 1.0.3.
- [Release notes](https://github.com/tautropfli/terminal-colorsaurus/releases)
- [Changelog](https://github.com/tautropfli/terminal-colorsaurus/blob/main/changelog.md)
- [Commits](https://github.com/tautropfli/terminal-colorsaurus/compare/1.0.0...1.0.3)

---
updated-dependencies:
- dependency-name: terminal-colorsaurus
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 03:09:37 +00:00
auto-merge-dependabot-prs[bot] f04dc80bc7 Merge pull request #3873 from sharkdp/dependabot/cargo/execute-0.3.0
build(deps): bump execute from 0.2.15 to 0.3.0
2026-08-01 03:06:47 +00:00
dependabot[bot] e6ac6ce08d build(deps): bump execute from 0.2.15 to 0.3.0
Bumps [execute](https://github.com/magiclen/execute) from 0.2.15 to 0.3.0.
- [Commits](https://github.com/magiclen/execute/compare/v0.2.15...v0.3.0)

---
updated-dependencies:
- dependency-name: execute
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 02:38:14 +00:00
auto-merge-dependabot-prs[bot] 949f18e23a Merge pull request #3868 from sharkdp/dependabot/cargo/proc-macro2-1.0.107
build(deps): bump proc-macro2 from 1.0.106 to 1.0.107
2026-08-01 02:19:43 +00:00
dependabot[bot] 969b33dad1 build(deps): bump proc-macro2 from 1.0.106 to 1.0.107
Bumps [proc-macro2](https://github.com/dtolnay/proc-macro2) from 1.0.106 to 1.0.107.
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.106...1.0.107)

---
updated-dependencies:
- dependency-name: proc-macro2
  dependency-version: 1.0.107
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 02:03:54 +00:00
Keith Hall 73dc3258be Merge pull request #3862 from lenamonj/fix/sanitize-bidi-control-set
Fix --sanitize passing through the bidi control characters U+200E, U+200F and U+061C
2026-07-30 00:27:20 +03:00
lenamonj df38225653 Add CHANGELOG entry for #3862 2026-07-29 17:02:33 -04:00
lenamonj c2cc79daa7 Fix --sanitize passing through the bidi control characters U+200E, U+200F and U+061C 2026-07-29 17:02:11 -04:00
Keith Hall 0230cba42d Merge pull request #3828 from cyphercodes/fix-list-languages-paging-never
Respect paging mode for --list-languages
2026-07-29 23:49:08 +03:00
Keith Hall 227e0fa00f Merge branch 'master' into fix-list-languages-paging-never 2026-07-29 23:33:51 +03:00
auto-merge-dependabot-prs[bot] 384f2f5d13 Merge pull request #3821 from sharkdp/dependabot/cargo/regex-1.12.4
build(deps): bump regex from 1.12.3 to 1.13.1
2026-07-29 20:23:23 +00:00
dependabot[bot] 060ccb1385 build(deps): bump regex from 1.12.3 to 1.13.1
Bumps [regex](https://github.com/rust-lang/regex) from 1.12.3 to 1.13.1.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.3...1.13.1)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 20:09:58 +00:00
auto-merge-dependabot-prs[bot] feabb25209 Merge pull request #3823 from sharkdp/dependabot/cargo/serde_with-3.21.0
build(deps): bump serde_with from 3.19.0 to 3.21.0
2026-07-29 20:06:40 +00:00
dependabot[bot] 839f2300b7 build(deps): bump serde_with from 3.19.0 to 3.21.0
Bumps [serde_with](https://github.com/jonasbb/serde_with) from 3.19.0 to 3.21.0.
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.19.0...v3.21.0)

---
updated-dependencies:
- dependency-name: serde_with
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 19:46:45 +00:00
auto-merge-dependabot-prs[bot] ea0742babc Merge pull request #3820 from sharkdp/dependabot/cargo/quote-1.0.46
build(deps): bump quote from 1.0.45 to 1.0.47
2026-07-29 19:40:36 +00:00
dependabot[bot] bfa6e59978 build(deps): bump quote from 1.0.45 to 1.0.47
Bumps [quote](https://github.com/dtolnay/quote) from 1.0.45 to 1.0.47.
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.45...1.0.47)

---
updated-dependencies:
- dependency-name: quote
  dependency-version: 1.0.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 19:17:50 +00:00
auto-merge-dependabot-prs[bot] 9710a231d8 Merge pull request #3819 from sharkdp/dependabot/cargo/bytesize-2.4.0
build(deps): bump bytesize from 2.3.1 to 2.4.2
2026-07-29 19:14:35 +00:00
dependabot[bot] a9b67b2d99 build(deps): bump bytesize from 2.3.1 to 2.4.2
Bumps [bytesize](https://github.com/bytesize-rs/bytesize) from 2.3.1 to 2.4.2.
- [Release notes](https://github.com/bytesize-rs/bytesize/releases)
- [Changelog](https://github.com/bytesize-rs/bytesize/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bytesize-rs/bytesize/compare/bytesize-v2.3.1...bytesize-v2.4.2)

---
updated-dependencies:
- dependency-name: bytesize
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:54:55 +00:00
auto-merge-dependabot-prs[bot] b22be63fec Merge pull request #3822 from sharkdp/dependabot/cargo/unicode-segmentation-1.13.3
build(deps): bump unicode-segmentation from 1.13.2 to 1.13.3
2026-07-29 18:50:14 +00:00
Keith Hall 3a7a750969 Merge branch 'master' into fix-list-languages-paging-never 2026-07-29 21:25:10 +03:00
dependabot[bot] be5343c742 build(deps): bump unicode-segmentation from 1.13.2 to 1.13.3
Bumps [unicode-segmentation](https://github.com/unicode-rs/unicode-segmentation) from 1.13.2 to 1.13.3.
- [Commits](https://github.com/unicode-rs/unicode-segmentation/commits)

---
updated-dependencies:
- dependency-name: unicode-segmentation
  dependency-version: 1.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:21:51 +00:00
Keith Hall c371044276 Merge pull request #3861 from TyceHerrman/fix/cargo-audit-2026-07
chore(deps): resolve current cargo audit failures
2026-07-29 21:18:39 +03:00
Tyce Herrman d449ce5b7e chore(deps): resolve current cargo audit failures 2026-07-29 12:10:05 -04:00
cyphercodes 218afc30ac Respect paging mode for list languages 2026-07-04 06:31:26 +03:00
Keith Hall 78951393e2 Merge pull request #3729 from curious-rabbit/sanitize
add --sanitize option
2026-07-01 23:15:58 +03:00
curious-rabbit 0a52e4321f improve patch 2026-07-01 07:54:51 +02:00
curious-rabbit d72163160e add sanitize option 2026-07-01 07:09:49 +02:00
auto-merge-dependabot-prs[bot] 971f9679da Merge pull request #3817 from sharkdp/dependabot/submodules/assets/syntaxes/02_Extra/LESS-d076a4d
build(deps): bump assets/syntaxes/02_Extra/LESS from `836b47e` to `d076a4d`
2026-07-01 02:16:46 +00:00
dependabot[bot] 57d436861a build(deps): bump assets/syntaxes/02_Extra/LESS
Bumps [assets/syntaxes/02_Extra/LESS](https://github.com/danro/LESS-sublime) from `836b47e` to `d076a4d`.
- [Release notes](https://github.com/danro/LESS-sublime/releases)
- [Commits](https://github.com/danro/LESS-sublime/compare/836b47ec61a9c6a6445b4007e8353337fe63e2c9...d076a4dd416bcdb155bacf90cda72f7bf1c8abbb)

---
updated-dependencies:
- dependency-name: assets/syntaxes/02_Extra/LESS
  dependency-version: d076a4dd416bcdb155bacf90cda72f7bf1c8abbb
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 02:03:33 +00:00
Keith Hall bb4f48a124 Merge pull request #3816 from adnrivera/fix/ignored-suffix-firstline
Fix --ignored-suffix to fall back to first-line detection
2026-07-01 00:40:18 +03:00
Adrian Rivera c75cc01eef Fix --ignored-suffix to fall back to first-line detection
When an ignored suffix is also a registered extension (e.g. `.txt` maps to
"Plain Text"), `get_syntax_for_file_extension` matched the raw extension
before attempting the suffix strip. As a result `--ignored-suffix .txt` had
no effect on such files and first-line/shebang detection was never reached,
so a shell script saved as `*.txt` stayed unhighlighted.

Strip ignored suffixes first and detect on the remainder; only fall back to
the raw extension when no ignored suffix applies. When a suffix is stripped
but the remainder has no syntax, return `None` so the caller can use
first-line detection. Default behavior (no `--ignored-suffix`) is unchanged.

See #2745.
2026-06-30 12:20:14 -07:00
Keith Hall 835bd2a756 Merge pull request #3793 from Matei02355/fix-sml-definition-link
docs: fix SML syntax source link
2026-06-28 22:25:36 +03:00
Keith Hall 3409674d9c Merge branch 'master' into fix-sml-definition-link 2026-06-28 22:15:42 +03:00
Keith Hall 7de0c4ba47 Merge pull request #3809 from dhruvkb/patch-1
Include `.code-workspace` as a JSON extension
2026-06-27 22:21:18 +03:00
Keith Hall 20a818fe14 Merge branch 'master' into patch-1 2026-06-27 21:58:52 +03:00
Keith Hall 30b50a9eed Merge pull request #3814 from injust/dnf-repo
Add syntax mapping for DNF config
2026-06-27 21:57:37 +03:00
injust 088f1e4253 Add changelog entry 2026-06-27 12:50:29 -04:00
injust 53769d03b0 Add syntax mapping for DNF config 2026-06-27 12:50:28 -04:00
Keith Hall b7ae4585ad Merge pull request #3812 from greymoth-jp/fix/list-languages-narrow-width-underflow
fix: avoid usize underflow in --list-languages at narrow terminal width
2026-06-27 06:12:21 +03:00
Keith Hall a4609d1778 Merge branch 'master' into fix/list-languages-narrow-width-underflow 2026-06-27 06:03:16 +03:00
Keith Hall 8f70e0cf00 Merge pull request #3703 from blinxen/master
Replace libgit2 with gitoxide (#2)
2026-06-27 05:53:22 +03:00
blinxen e34595b53e Update gix to 0.85 2026-06-26 20:28:29 +02:00
blinxen 6ba745b74b cargo fmt 2026-06-26 20:27:49 +02:00
blinxen 98f09fd6f3 Add test with fauly git repo version 2026-06-26 20:27:49 +02:00
blinxen 69f40d9d8a Regenerate Cargo.lock after rebase 2026-06-26 20:27:49 +02:00
blinxen 6e6137ce10 Update to gix 0.83 2026-06-26 20:27:49 +02:00
blinxen df164cc2e3 fix immediate dereference of reference 2026-06-26 20:27:49 +02:00
blinxen f9eed8d619 cargo fmt 2026-06-26 20:27:49 +02:00
blinxen d695ad8f88 Add more tests for git diff 2026-06-26 20:27:49 +02:00
blinxen e60d184f97 Use histogram instead of myers for diffing 2026-06-26 20:27:49 +02:00
blinxen e2568c52cd Use correct PR number 2026-06-26 20:27:49 +02:00
blinxen c5e6f6aae3 Replace libgit2 with gitoxide 2026-06-26 20:27:49 +02:00
greymoth 8f93137246 doc(changelog): add #3812 bugfix entry 2026-06-26 22:13:51 +09:00
greymoth-jpandClaude Opus 4.8 fa00fb8ce8 fix(list-languages): clamp desired_width to avoid usize underflow at tiny --terminal-width
`get_languages` computed `desired_width = term_width - longest - separator.len()`.
When `--terminal-width` is smaller than the longest language name (e.g. 1), this
underflows `usize`: in overflow-checked builds it panics ("attempt to subtract with
overflow"), and in release it wraps to ~usize::MAX, silently disabling the extension
line-wrapping so `--terminal-width` is ignored for `--list-languages`.

Use `saturating_sub`, mirroring the fix applied to `print_snip` in #3804 (the snip
separator had the same `term_width - ...` underflow pattern). Output at normal widths
is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:24:01 +09:00
Dhruv Bhanushali d117bf2320 Summarise the change in the CHANGELOG.md file 2026-06-23 08:08:14 +04:00
Dhruv Bhanushali f4d836ee87 Include .code-workspace as a JSON extension 2026-06-23 08:01:50 +04:00
auto-merge-dependabot-prs[bot] 04576fd5a5 Merge pull request #3783 from sharkdp/dependabot/submodules/assets/syntaxes/02_Extra/vscode-wgsl-a285c38
build(deps): bump assets/syntaxes/02_Extra/vscode-wgsl from `acf2671` to `a285c38`
2026-06-17 17:51:31 +00:00
dependabot[bot] e7025d9bdb build(deps): bump assets/syntaxes/02_Extra/vscode-wgsl
Bumps [assets/syntaxes/02_Extra/vscode-wgsl](https://github.com/PolyMeilex/vscode-wgsl) from `acf2671` to `a285c38`.
- [Release notes](https://github.com/PolyMeilex/vscode-wgsl/releases)
- [Commits](https://github.com/PolyMeilex/vscode-wgsl/compare/acf26718d7a327377641e31d8f9a9dab376efa84...a285c38f74eba2eb5c5a06be8d95b9f581338509)

---
updated-dependencies:
- dependency-name: assets/syntaxes/02_Extra/vscode-wgsl
  dependency-version: a285c38f74eba2eb5c5a06be8d95b9f581338509
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 17:38:53 +00:00
Keith Hall 6d4a3cef53 Merge pull request #3789 from CosmicHorrorDev/caddy
feat: add syntax highlighting for `Caddyfile`
2026-06-17 20:35:17 +03:00
Cosmic Horror 6b8887133d feat: add syntax highlighting for Caddyfile 2026-06-17 04:44:53 -06:00
Keith Hall 5bb35b4bed Merge pull request #3804 from leeewee/fix-snip-terminal-width-1-panic
Fix capacity-overflow panic in snip separator at --terminal-width 1
2026-06-16 20:19:08 +03:00
weili d28aa4a8b4 Fix capacity-overflow panic in print_snip at --terminal-width 1
`InteractivePrinter::print_snip` computes the snip separator width as
`term_width - panel_count - snip_left_count - title_count`. At `--terminal-width 1`
the panel is disabled and `title_count == 2`, so the subtraction underflows
`usize` (release has no overflow-checks, so it wraps to ~usize::MAX); `str::repeat`
then aborts with "capacity overflow" whenever a snip separator is emitted (two or
more disjoint line ranges, or a diff gap) — which the default style does.

Use `saturating_sub` for the repeat counts so they clamp to 0 instead of
underflowing. Added an integration test.
2026-06-16 07:06:29 +00:00
Matei6942 e9b552d5d0 docs: fix SML syntax source link 2026-06-05 17:40:39 +03:00
auto-merge-dependabot-prs[bot] af1f53d9a9 Merge pull request #3786 from sharkdp/dependabot/cargo/nix-0.31.3
build(deps): bump nix from 0.31.2 to 0.31.3
2026-06-01 05:15:08 +00:00
dependabot[bot] 8033b1e7be build(deps): bump nix from 0.31.2 to 0.31.3
Bumps [nix](https://github.com/nix-rust/nix) from 0.31.2 to 0.31.3.
- [Changelog](https://github.com/nix-rust/nix/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nix-rust/nix/compare/v0.31.2...v0.31.3)

---
updated-dependencies:
- dependency-name: nix
  dependency-version: 0.31.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 05:03:33 +00:00
auto-merge-dependabot-prs[bot] 01408ce094 Merge pull request #3785 from sharkdp/dependabot/cargo/predicates-3.1.4
build(deps): bump predicates from 3.1.3 to 3.1.4
2026-06-01 05:00:15 +00:00
dependabot[bot] bcd1d3f45b build(deps): bump predicates from 3.1.3 to 3.1.4
Bumps [predicates](https://github.com/assert-rs/predicates-rs) from 3.1.3 to 3.1.4.
- [Changelog](https://github.com/assert-rs/predicates-rs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/assert-rs/predicates-rs/compare/v3.1.3...v3.1.4)

---
updated-dependencies:
- dependency-name: predicates
  dependency-version: 3.1.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 04:48:18 +00:00
auto-merge-dependabot-prs[bot] 4cd5db2a78 Merge pull request #3787 from sharkdp/dependabot/cargo/indexmap-2.14.0
build(deps): bump indexmap from 2.13.0 to 2.14.0
2026-06-01 04:44:39 +00:00
dependabot[bot] 67aa491313 build(deps): bump indexmap from 2.13.0 to 2.14.0
Bumps [indexmap](https://github.com/indexmap-rs/indexmap) from 2.13.0 to 2.14.0.
- [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md)
- [Commits](https://github.com/indexmap-rs/indexmap/compare/2.13.0...2.14.0)

---
updated-dependencies:
- dependency-name: indexmap
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 04:28:21 +00:00
auto-merge-dependabot-prs[bot] e490c0f322 Merge pull request #3782 from sharkdp/dependabot/cargo/minus-5.7.1
build(deps): bump minus from 5.6.1 to 5.7.1
2026-06-01 04:24:38 +00:00
dependabot[bot] 2d29ecb939 build(deps): bump minus from 5.6.1 to 5.7.1
Bumps [minus](https://github.com/AMythicDev/minus) from 5.6.1 to 5.7.1.
- [Changelog](https://github.com/AMythicDev/minus/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AMythicDev/minus/compare/v5.6.1...v5.7.1)

---
updated-dependencies:
- dependency-name: minus
  dependency-version: 5.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 03:59:42 +00:00
auto-merge-dependabot-prs[bot] 707b5949fd Merge pull request #3781 from sharkdp/dependabot/cargo/plist-1.9.0
build(deps): bump plist from 1.7.0 to 1.9.0
2026-06-01 03:50:11 +00:00
dependabot[bot] fd53693e1f build(deps): bump plist from 1.7.0 to 1.9.0
Bumps [plist](https://github.com/ebarnard/rust-plist) from 1.7.0 to 1.9.0.
- [Release notes](https://github.com/ebarnard/rust-plist/releases)
- [Changelog](https://github.com/ebarnard/rust-plist/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ebarnard/rust-plist/compare/v1.7.0...v1.9.0)

---
updated-dependencies:
- dependency-name: plist
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 03:30:20 +00:00
Keith Hall f3d0773468 Merge pull request #3761 from mvanhorn/fix/3760-completion-skip-pager
fix(completions): force --no-paging on bat invocations in completion scripts (#3760)
2026-05-20 07:00:37 +03:00
Matt Van Horn 66e336bcfb fix(completions): force --no-paging on bat invocations in completion scripts (#3760)
Every shell completion script (bash, zsh, fish, PowerShell) shells out
to bat to enumerate languages/themes for tab completion candidates. If
the user has wired bat into LESSOPEN (e.g. LESSOPEN='|-bat -f -pp %s'),
bat's normal pager auto-detection can engage when stdout looks like a
terminal at completion time and reflect ANSI escape sequences back into
the candidate list. The result is the issue's reproducer: tab completion
expands 'Per' to '\033[38;2;248;248;242mPerl' instead of 'Perl'.

The list-languages/list-themes calls are always meant to be machine-
readable, so they should never page. Pass --no-paging explicitly to
every bat invocation inside the four completion files. The flag is the
public alias for --paging=never (already documented in bat --help) and
is the same form completion scripts elsewhere in the codebase use.

Touches the four completion files only; no production code changes.
2026-05-19 04:19:31 -07:00
Keith Hall ce7ad9adca Merge pull request #3758 from injust/patch-1
Include `.ssh/` subdirectories in SSH Config syntax mapping
2026-05-18 20:26:18 +03:00
Keith Hall f9fa590c3d Merge branch 'master' into patch-1 2026-05-18 20:11:48 +03:00
Keith Hall 1e1c3773fb Merge pull request #3759 from injust/patch-2
Add Ghostty syntax mapping
2026-05-18 20:10:26 +03:00
Justin Su 0ef0342ff2 Add changelog entry 2026-05-18 03:33:50 -04:00
Justin Su de396794cf Add Ghostty syntax mapping 2026-05-18 03:29:12 -04:00
Justin Su 43c4957e18 Add changelog entry 2026-05-18 03:08:39 -04:00
Justin Su c9fa10e76b Include .ssh/ subdirectories in SSH Config syntax mapping 2026-05-18 02:45:50 -04:00
Matt Van Horn 530e83a455 feat: bat auto-detects language from stdin via first-line patterns
When piped input has no filename hint, bat falls back to plain text. Many
common formats can be reliably identified by shebang or magic-string in the
first line (#!/bin/bash, <?xml, {"json: ...}, etc.). Adds a first-line
pattern matcher to assets.rs.

Fixes #3697
2026-05-16 13:51:00 -07:00
Keith Hall 2cec8cdf25 Merge pull request #3737 from truffle-dev/fix/zsh-completion-language-only
fix: only offer language names in zsh tab completion for `-l`
2026-05-11 20:32:12 +03:00
truffle 138d70fd4c fix(zsh): drop redundant awk pipeline in language completion
`bat --list-languages` already emits each entry in `name:matchers`
form, which is the format `_describe` consumes directly. The previous
awk script split each line on `:` and re-emitted `$1:$2`, which is
byte-identical to the input.

Verified with `diff <(bat --list-languages) <(bat --list-languages |
awk -F: '{ printf("%s:%s\\n", $1, $2) }')` against the current
syntax set.
2026-05-10 14:10:54 +00:00
truffle 98df25434e fix: reference PR number in changelog entry 2026-05-10 09:20:40 +00:00
truffle 5f952066fb fix: only offer language names in zsh tab completion for -l
The previous awk script in `bat.zsh.in` split each line of
`bat --list-languages` on `:` or `,` and emitted every field as a
completion candidate, including the second column. That column lists
file matchers, which can be plain extensions (`rs`), globs (`*.rs`),
absolute paths (`/etc/profile`), or filenames (`Containerfile`). None
of those parse as `-l` arguments, so completing them produces
`unknown syntax` errors.

Switch to splitting on `:` only and emit the language name as the
completion value with the file-matcher list as its description, which
matches the bash completion's behavior.

Closes #3735.
2026-05-10 09:19:54 +00:00
Keith Hall f74082cee8 Merge pull request #3734 from kimjune01/fix/zsh-completion-force-plain
fix: pass --color=never --decorations=never in zsh completions
2026-05-09 13:52:47 +03:00
June Kim a0c95618c4 fix: pass --color=never --decorations=never in zsh completions
When BAT_OPTS contains --color=always or --decorations=always, the zsh
completion script's calls to --list-languages and --list-themes produce
ANSI escape codes that corrupt tab completion results.

Pass --color=never --decorations=never explicitly so completion output
is always plain text regardless of user config.

Fixes #3733
2026-05-09 03:35:54 -07:00
Keith Hall f57c1b6b1d Merge pull request #3726 from curious-rabbit/less
fix command injection in LESS template
2026-05-07 05:34:13 +03:00
Keith Hall 517de0463b Merge branch 'master' into less 2026-05-07 05:23:26 +03:00
curious-rabbit 3be4e30e00 improve patch 2026-05-06 10:58:02 +02:00
auto-merge-dependabot-prs[bot] 15fdc7637a Merge pull request #3717 from sharkdp/dependabot/cargo/terminal-colorsaurus-1.0.3
build(deps): bump terminal-colorsaurus from 1.0.1 to 1.0.3
2026-05-06 03:58:05 +00:00
dependabot[bot] 72fc697372 build(deps): bump terminal-colorsaurus from 1.0.1 to 1.0.3
Bumps [terminal-colorsaurus](https://github.com/tautropfli/terminal-colorsaurus) from 1.0.1 to 1.0.3.
- [Release notes](https://github.com/tautropfli/terminal-colorsaurus/releases)
- [Changelog](https://github.com/tautropfli/terminal-colorsaurus/blob/main/changelog.md)
- [Commits](https://github.com/tautropfli/terminal-colorsaurus/compare/1.0.1...1.0.3)

---
updated-dependencies:
- dependency-name: terminal-colorsaurus
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 03:45:27 +00:00
auto-merge-dependabot-prs[bot] b8d2781784 Merge pull request #3715 from sharkdp/dependabot/cargo/flate2-1.1.9
build(deps): bump flate2 from 1.1.2 to 1.1.9
2026-05-06 03:41:52 +00:00
dependabot[bot] 787a36acb6 build(deps): bump flate2 from 1.1.2 to 1.1.9
Bumps [flate2](https://github.com/rust-lang/flate2-rs) from 1.1.2 to 1.1.9.
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.2...1.1.9)

---
updated-dependencies:
- dependency-name: flate2
  dependency-version: 1.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 03:29:10 +00:00
auto-merge-dependabot-prs[bot] a79701a43e Merge pull request #3712 from sharkdp/dependabot/cargo/regex-1.12.3
build(deps): bump regex from 1.12.2 to 1.12.3
2026-05-06 03:26:32 +00:00
dependabot[bot] 3bcbc45ab0 build(deps): bump regex from 1.12.2 to 1.12.3
Bumps [regex](https://github.com/rust-lang/regex) from 1.12.2 to 1.12.3.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.2...1.12.3)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 03:09:47 +00:00
auto-merge-dependabot-prs[bot] 7953df44ea Merge pull request #3711 from sharkdp/dependabot/cargo/clap-4.6.1
build(deps): bump clap from 4.5.60 to 4.6.1
2026-05-06 03:06:46 +00:00
dependabot[bot] 443bcb9287 build(deps): bump clap from 4.5.60 to 4.6.1
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.60 to 4.6.1.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.60...clap_complete-v4.6.1)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 02:44:23 +00:00
auto-merge-dependabot-prs[bot] 0523b70473 Merge pull request #3714 from sharkdp/dependabot/cargo/bugreport-0.6.0
build(deps): bump bugreport from 0.5.1 to 0.6.0
2026-05-06 02:41:47 +00:00
dependabot[bot] f776d1ad3f build(deps): bump bugreport from 0.5.1 to 0.6.0
Bumps [bugreport](https://github.com/sharkdp/bugreport) from 0.5.1 to 0.6.0.
- [Release notes](https://github.com/sharkdp/bugreport/releases)
- [Commits](https://github.com/sharkdp/bugreport/compare/v0.5.1...v0.6.0)

---
updated-dependencies:
- dependency-name: bugreport
  dependency-version: 0.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 02:33:22 +00:00
Keith Hall cfc6d9c415 Merge pull request #3728 from Daeraxa/fix-fedora-section
Fix README Fedora section links
2026-05-06 04:59:37 +03:00
Daeraxa 89d7c86b19 update fedora package link 2026-05-05 23:53:13 +01:00
curious-rabbit c437ad4d27 fix command injection in LESS template 2026-05-06 00:46:35 +02:00
Daeraxa 1c6e763d75 Update readme to fix Fedora section 2026-05-05 23:42:30 +01:00
Keith Hall baebdd1789 Merge pull request #3623 from zachvalenta/makefile-syntax-for-justfiles
Makefile syntax for justfiles
2026-05-02 14:04:55 +03:00
Keith Hall f7b84a3b41 Merge branch 'master' into makefile-syntax-for-justfiles 2026-05-02 13:56:45 +03:00
auto-merge-dependabot-prs[bot] b483f90612 Merge pull request #3716 from sharkdp/dependabot/submodules/assets/syntaxes/02_Extra/typst-syntax-highlight-5f71d12
build(deps): bump assets/syntaxes/02_Extra/typst-syntax-highlight from `363f0e7` to `5f71d12`
2026-05-01 03:03:58 +00:00
dependabot[bot] 6edad56b00 build(deps): bump assets/syntaxes/02_Extra/typst-syntax-highlight
Bumps [assets/syntaxes/02_Extra/typst-syntax-highlight](https://github.com/hyrious/typst-syntax-highlight) from `363f0e7` to `5f71d12`.
- [Release notes](https://github.com/hyrious/typst-syntax-highlight/releases)
- [Commits](https://github.com/hyrious/typst-syntax-highlight/compare/363f0e767c938c615a14912c302db7936f025fc2...5f71d12fa129165bbe51aa867292555cdff6eb75)

---
updated-dependencies:
- dependency-name: assets/syntaxes/02_Extra/typst-syntax-highlight
  dependency-version: 5f71d12fa129165bbe51aa867292555cdff6eb75
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 02:43:12 +00:00
auto-merge-dependabot-prs[bot] e2174379ed Merge pull request #3713 from sharkdp/dependabot/submodules/assets/syntaxes/02_Extra/PureScript-1773f4f
build(deps): bump assets/syntaxes/02_Extra/PureScript from `5acebc1` to `1773f4f`
2026-05-01 02:38:41 +00:00
dependabot[bot] 816aea0625 build(deps): bump assets/syntaxes/02_Extra/PureScript
Bumps [assets/syntaxes/02_Extra/PureScript](https://github.com/tellnobody1/sublime-purescript-syntax) from `5acebc1` to `1773f4f`.
- [Release notes](https://github.com/tellnobody1/sublime-purescript-syntax/releases)
- [Commits](https://github.com/tellnobody1/sublime-purescript-syntax/compare/5acebc18503697be09df047591964e68e80fcf8e...1773f4fddb08560d6bcb354901088e61e9ea0908)

---
updated-dependencies:
- dependency-name: assets/syntaxes/02_Extra/PureScript
  dependency-version: 1773f4fddb08560d6bcb354901088e61e9ea0908
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 02:11:34 +00:00
zach valenta 5bf932d511 Merge branch 'master' into makefile-syntax-for-justfiles 2026-04-28 14:41:15 -04:00
zachvalenta 652d50e92e Makefile syntax for justfiles 2026-03-08 21:06:19 -04:00
59 changed files with 3875 additions and 989 deletions

No files matched your search

+4 -3
View File
@@ -166,7 +166,8 @@ jobs:
- { target: arm-unknown-linux-gnueabihf , os: ubuntu-latest , dpkg_arch: armhf, use-cross: true }
- { target: arm-unknown-linux-musleabihf, os: ubuntu-latest , dpkg_arch: musl-linux-armhf, use-cross: true }
- { target: i686-pc-windows-msvc , os: windows-2025 , }
- { target: i686-unknown-linux-gnu , os: ubuntu-latest , dpkg_arch: i386, use-cross: true }
# The timeout test's socketpair fails on the Ubuntu 26.04 runner.
- { target: i686-unknown-linux-gnu , os: ubuntu-24.04 , dpkg_arch: i386, use-cross: true }
- { target: i686-unknown-linux-musl , os: ubuntu-latest , dpkg_arch: musl-linux-i686, use-cross: true }
- { target: x86_64-apple-darwin , os: macos-15-intel, }
- { target: aarch64-apple-darwin , os: macos-latest , }
@@ -442,7 +443,7 @@ jobs:
echo "IS_RELEASE=${IS_RELEASE}" >> $GITHUB_OUTPUT
- name: Publish archives and packages
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
if: steps.is-release.outputs.IS_RELEASE
with:
files: |
@@ -457,7 +458,7 @@ jobs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f
- uses: vedantmgoyal9/winget-releaser@b7370d4d152efdce803a26cc6ca9eee58cb2ad54
with:
identifier: sharkdp.bat
installers-regex: '-pc-windows-msvc\.zip$'
+3
View File
@@ -281,3 +281,6 @@
[submodule "assets/syntaxes/02_Extra/Kotlin"]
path = assets/syntaxes/02_Extra/Kotlin
url = https://github.com/guille/sublime-kotlin
[submodule "assets/syntaxes/02_Extra/Caddy"]
path = assets/syntaxes/02_Extra/Caddy
url = https://github.com/caddyserver/sublimetext.git
+41
View File
@@ -3,11 +3,20 @@
## Other
- Pin the i686 GNU CI runner to Ubuntu 24.04 to avoid `socketpair` failures on Ubuntu 26.04, see #4101 (@PerchAnton)
- Fix dead Visual C++ Redistributable link in README, see #3895 (@1t1sCooL)
- Document `StripAnsiMode` and its variants in rustdoc, see #4067 (@ump45nose)
- Update Cargo dependencies to resolve current RustSec advisories, see #3861 (@TyceHerrman)
- Add instructions for removing fish help abbreviations to README, see #3655 (@claw-explorer). Closes #3536
- Add .NET slnx extension, see #3682 (@ltrzesniewski)
## Features
- Automatically build the syntax/theme cache on first run when custom themes or syntaxes are present but no cache exists yet, so newly installed dotfiles work without a manual `bat cache --build`. Use `--no-custom-assets` to opt out. Closes #4017, see #4038 (@mmustafasenoglu)
- Add `-b` / `--number-nonblank` flag to only number non-blank lines, for `cat -b` compatibility. Closes #3856, see #3857 (@MeGaurav4)
- Add a `--sanitize=<auto|always|never>` flag for safe display of untrusted input. It implies `--strip-ansi` at the same value and additionally substitutes terminal-active control bytes (cursor moves, charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the Unicode replacement character (U+FFFD). Mitigates Trojan-Source-style spoofing (CVE-2021-42574). See #3729 (@curious-rabbit)
- Map justfile, Justfile, .justfile, and *.justfile to Makefile syntax highlighting, see #3623 (@zachvalenta)
- Map `.kube/config` to YAML syntax highlighting, see #3911 (@logarithmone1128)
- Preserve `--diff` change markers and snip separators when `--plain` is set. Closes #3630, see #3643 (@mvanhorn)
- Added support for `hidden_file_extensions` from `.sublime-syntax` files, see #3613 (@Matei02355)
- Add word wrapping mode via `--wrap=word`, see #3597 (@veeceey)
@@ -21,6 +30,29 @@
- Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres)
## Bugfixes
- Fix the first line of UTF-16BE input swallowing the second line, which broke line numbers, `--line-range` and highlighting, see #XXXX (@cindykrafft)
- Respect `NO_COLOR` for the `[bat error]` and `[bat warning]` prefixes, see #4048 (@qodelicious)
- Offer the valid `--wrap` value `auto` instead of the rejected `always` in the PowerShell completion, see #4070 (@ump45nose)
- Report `BAT_CONFIG_DIR` in `bat --diagnostic`, so bug reports show a config directory that overrides `$XDG_CONFIG_HOME`. See #4068 (@ump45nose)
- Ignore a UTF-8 byte order mark at the start of the config file instead of treating it as part of the first argument, see #4103 (@jaeminbird)
- Fix `bat` hanging forever on JavaScript (and Svelte) code where an expression is followed by a block comment, e.g. `a /* x */`. Closes #4080, see #4091 (@NavpreetSSidhu)
- Fix the first line of UTF-16BE input swallowing the second line, which broke line numbers, `--line-range` and highlighting, see #4102 (@cindykrafft)
- Exit quietly with status 0 instead of panicking when stdout is closed early (broken pipe) on auxiliary output paths such as `--completion`, `--config-file`, `--diagnostic`, `--generate-config-file`, `--set-terminal-title` and `cache --build`/`--clear`, matching the main output path since #232, closes #4034, see #4035 (@Mathjk)
- Track strikethrough (SGR 9 / 29) in the ANSI style tracker so it is re-emitted like bold, dim, italic, and underline, and is no longer dropped after the first highlighted token when displaying ANSI input with syntax highlighting, see #4015 (@mikehasa)
- Allow boolean flags to be given more than once, so that a flag set in the config file can also be passed on the command line without erroring, see #3912 (@logarithmone1128)
- Allow options that take a value (`--binary`, `--italic-text`, `--nonprintable-notation`, `--fallback-syntax`, `--squeeze-limit`) to be given more than once, so that a value from the config file can be overridden on the command line, see #4102 (@Huang-404-Q)
- Use parsed CLI arguments to detect number flags, see #3908 (@cuishuang)
- Detect binary content beyond the first line, preventing encrypted files with early line breaks from being treated as text. Closes #3554, see #3877 (@Matei02355)
- Use the last occurrence of numbering and plain flags in combined short arguments, see #3897 (@cuishuang)
- Avoid a spurious Cargo manifest error during installation from Git, see #3899 (@rootsec1)
- Avoid repeated scans of long lines in Log syntax highlighting, see #3876 (@Matei02355)
- Fix `--list-languages` respecting `--paging=never`, see #3828 (@cyphercodes)
- Fix `--sanitize` passing through the bidi control characters U+200E, U+200F and U+061C, see #3862 (@lenamonj)
- `--strip-ansi`: also strip 8-bit C1 introducers (U+0090, U+0098, U+009B, U+009D, U+009E, U+009F) and DCS/SOS/PM/APC sequence bodies, which previously passed through. See #3729 (@curious-rabbit)
- Fix `--ignored-suffix` not falling back to first-line/shebang detection when the ignored suffix is also a registered extension (e.g. `--ignored-suffix .txt` on a shebang script), see #2745 and #3816 (@adnrivera)
- Fix `capacity overflow` panic when printing a snip separator at `--terminal-width=1` with multiple line ranges. Closes #3803, see #3804 (@leeewee)
- Pass `--no-paging` to `bat` invocations inside the bash / zsh / fish / PowerShell shell completion scripts so that shell-level pager wiring (e.g. `LESSOPEN='|-bat -f -pp %s'`) cannot inject ANSI escape sequences into the completion candidates. Closes #3760 (@mvanhorn)
- Quote filenames before substituting them into `$LESSOPEN` / `$LESSCLOSE` templates, preventing shell injection when a filename contains shell metacharacters, see #3726 (@curious-rabbit)
- Fix `--list-themes` unconditionally probing the terminal via OSC 10/11 even when `--theme` was set to an explicit value, see #3700 (regression introduced in bc42149a). (@optimistiCli)
- Fix inverted `$LESSCLOSE` warning so bat warns on nonzero exit, not on success. See #3654 (@cuiweixie)
- Sanitize control characters in filenames before displaying them in the file header, error messages, and the terminal title, preventing ANSI escape injection via crafted filenames. Closes #3054, see #3691 (@curious-rabbit)
@@ -41,6 +73,8 @@
- Fixed bug caused by using `--plain` and `--terminal-width=N` flags simultaneously, see #3529 (@H4k1l)
- Fixed syntax tests path, see #3610 (@foxfromworld)
- Fix zsh tab completion word-splitting language names containing spaces (e.g. `HTML (Jinja2)`, `Apache Conf`), see #3693 (@YoshKoz)
- Fix zsh tab completion offering invalid `-l` arguments (file globs, paths, hidden filenames) sourced from the second column of `--list-languages`. Closes #3735, see #3737 (@truffle-dev)
- Fix `usize` underflow in `--list-languages` when `--terminal-width` is smaller than the longest language name, see #3812 (@greymoth-jp)
## Other
- Use git version of cross. See #3533 (@OctopusET)
@@ -48,6 +82,7 @@
- Allow home and end keys to be used with builtin pager, see #3651 (@keith-hall)
- Builtin syntax mapping: cleanup matcher glob parsing logic #3652 (@cyqsimon)
- Statically link the CRT for MSVC builds via Cargo config to avoid runtime DLL dependencies. Closes #3634, see #3692 (@barry3406)
- Replace `libgit2` with a pure Rust implementation of git called `gitoxide`, see PR #3703 (@blinxen)
## Syntaxes
@@ -59,6 +94,12 @@
- Map several Google Cloud CLI config files to their appropriate syntax #3635 (@victor-gp)
- Map all ignore dotfiles to Git Ignore syntax #3636 (@victor-gp)
- Improved Kotlin syntax, see #3699 (@guille)
- Include subdirectories in SSH Config syntax mapping, see #3758 (@injust)
- Add Ghostty syntax mapping, see #3759 (@injust)
- Add syntax highlighting for `Caddyfile` #3789 (@CosmicHorrorDev)
- Include `.code-workspace` as a JSON extension #3809 (@dhruvkb)
- Add syntax mapping for DNF repo configuration files, see #3814 (@injust)
- Add syntax mapping for Podman `.quadlets` files, see #4069 (@injust)
## Themes
Generated
+1580 -680
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -31,8 +31,7 @@ minimal-application = [
"regex-onig",
"wild",
]
git = ["git2"] # Support indicating git modifications
vendored-libgit2 = ["git2/vendored-libgit2"]
git = ["gix"] # Support indicating git modifications
paging = [ "shell-words", "grep-cli", "minus"] # Support applying a pager on the output
lessopen = ["execute"] # Support $LESSOPEN preprocessor
build-assets = ["syntect/yaml-load", "syntect/plist-load", "regex", "walkdir"]
@@ -52,7 +51,7 @@ thiserror = "2.0"
wild = { version = "2.2", optional = true }
content_inspector = "0.2.4"
shell-words = { version = "1.1.1", optional = true }
minus = { version = "5.6", optional = true, features = [
minus = { version = "5.7", optional = true, features = [
"dynamic_output",
"search",
] }
@@ -64,22 +63,23 @@ serde_yaml = "0.9.28"
semver = "1.0"
path_abs = { version = "0.5", default-features = false }
clircle = { version = "0.6.1", default-features = false }
bugreport = { version = "0.5.0", optional = true }
bugreport = { version = "0.6.0", optional = true }
etcetera = { version = "0.11.0", optional = true }
grep-cli = { version = "0.1.12", optional = true }
regex = { version = "1.12.2", optional = true }
regex = { version = "1.12.3", optional = true }
walkdir = { version = "2.5", optional = true }
bytesize = { version = "2.3.1" }
encoding_rs = "0.8.35"
execute = { version = "0.2.15", optional = true }
execute = { version = "0.3.0", optional = true }
terminal-colorsaurus = "1.0"
unicode-segmentation = "1.13.2"
itertools = "0.14.0"
[dependencies.git2]
version = "0.20"
[dependencies.gix]
version = "0.86"
optional = true
default-features = false
features = ["sha1", "blob-diff"]
[dependencies.syntect]
version = "5.3.0"
@@ -87,18 +87,18 @@ default-features = false
features = ["parsing"]
[dependencies.clap]
version = "4.5.60"
version = "4.6.1"
optional = true
features = ["wrap_help", "cargo"]
[target.'cfg(target_os = "macos")'.dependencies]
plist = "1.7.0"
plist = "1.9.0"
[dev-dependencies]
assert_cmd = "2.0.16"
expect-test = "1.5.0"
serial_test = { version = "2.0.0", default-features = false }
predicates = "3.1.3"
predicates = "3.1.4"
wait-timeout = "0.2.1"
tempfile = "3.27.0"
serde = { version = "1.0", features = ["derive"] }
@@ -108,13 +108,13 @@ nix = { version = "0.31", default-features = false, features = ["term"] }
[build-dependencies]
anyhow = "1.0.97"
indexmap = { version = "2.13.0", features = ["serde"] }
indexmap = { version = "2.14.0", features = ["serde"] }
itertools = "0.14.0"
once_cell = "1.20"
prettyplease = "0.2.37"
proc-macro2 = "1.0.106"
quote = "1.0.45"
regex = "1.12.2"
regex = "1.12.3"
serde = "1.0"
serde_derive = "1.0"
serde_with = { version = "3.17.0", default-features = false, features = ["macros"] }
@@ -123,7 +123,7 @@ toml = { version = "1.1.1", features = ["preserve_order"] }
walkdir = "2.5"
[build-dependencies.clap]
version = "4.5.60"
version = "4.6.1"
optional = true
features = ["wrap_help", "cargo"]
+8 -4
View File
@@ -307,7 +307,7 @@ pacman -S bat
### On Fedora
You can install [the `bat` package](https://koji.fedoraproject.org/koji/packageinfo?packageID=27506) from the official [Fedora Modular](https://docs.fedoraproject.org/en-US/modularity/using-modules/) repository.
You can install [the `bat` package](https://packages.fedoraproject.org/pkgs/rust-bat/bat/) from the official sources:
```bash
dnf install bat
@@ -417,7 +417,7 @@ scoop install bat
You can download prebuilt binaries from the [Release page](https://github.com/sharkdp/bat/releases),
You will need to install the [Visual C++ Redistributable](https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads) package.
You will need to install the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist) package.
### From binaries
@@ -794,7 +794,7 @@ Example configuration file:
### Prerequisites
You will need to install the [Visual C++ Redistributable](https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads) package.
You will need to install the [Visual C++ Redistributable](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist) package.
### Paging
@@ -843,6 +843,10 @@ If your version of `bat` supports the `--strip-ansi=auto` option, it can be used
before syntax highlighting. Alternatively, you may disable both syntax highlighting and wrapping by
passing the `--color=never --wrap=never` options to `bat`.
For untrusted input, the `--sanitize=auto|always|never` option additionally replaces terminal-active
control bytes and Unicode bidi / zero-width formatting characters with the Unicode replacement
character. It implies `--strip-ansi` at the same value.
> [!NOTE]
> The `auto` option of `--strip-ansi` avoids removing escape sequences when the syntax is plain text.
@@ -898,7 +902,7 @@ cargo install --path . --locked --force
```
If you want to build an application that uses `bat`'s pretty-printing
features as a library, check out the [the API documentation](https://docs.rs/bat/).
features as a library, check out the [API documentation](https://docs.rs/bat/).
Note that you have to use either `regex-onig` or `regex-fancy` as a feature
when you depend on `bat` as a library.
+9 -3
View File
@@ -9,17 +9,17 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
$ArrayCompletion = @('bash', 'fish', 'zsh', 'ps1')
$ArrayWhen = @('auto', 'never', 'always')
$ArrayYesNo = @('never', 'always')
$ArrayWrap = @('always', 'never', 'character', 'word')
$ArrayWrap = @('auto', 'never', 'character', 'word')
$ArrayBinary = @('no-printing', 'as-text')
$ArrayPrint = @('unicode', 'caret')
function Get-MyThemes(){
$themes = {{PROJECT_EXECUTABLE}} --list-themes | ForEach-Object {$_ -replace "^(.*)$", '''$1'''} | select-object
$themes = {{PROJECT_EXECUTABLE}} --no-paging --list-themes | ForEach-Object {$_ -replace "^(.*)$", '''$1'''} | select-object
return $themes
}
function Get-MyLanguages(){
$themes = {{PROJECT_EXECUTABLE}} --list-languages | ForEach-Object{[pscustomobject]@{MyParameter=$_.Substring(0,$_.IndexOf(":")).Trim();MyDescription=$_.Substring($_.IndexOf(":")+1)}} | select-object
$themes = {{PROJECT_EXECUTABLE}} --no-paging --list-languages | ForEach-Object{[pscustomobject]@{MyParameter=$_.Substring(0,$_.IndexOf(":")).Trim();MyDescription=$_.Substring($_.IndexOf(":")+1)}} | select-object
return $themes
}
@@ -100,6 +100,11 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
break
}
'*;--sanitize' {
$ArrayWhen |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
break
}
'*;--strip-ansi' {
$ArrayWhen |
ForEach-Object {[System.Management.Automation.CompletionResult]::new($_, $_, [CompletionResultType]::ParameterValue, $_)}
@@ -158,6 +163,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
[CompletionResult]::new('--ignored-suffix' , 'ignored-suffix' , [CompletionResultType]::ParameterName, 'Ignore extension. For example: ''bat --ignored-suffix ".dev" my_file.json.dev'' will use JSON syntax, and ignore ''.dev''')
[CompletionResult]::new('--squeeze-blank' , 'squeeze-blank' , [CompletionResultType]::ParameterName, 'Squeeze consecutive empty lines into a single empty line.')
[CompletionResult]::new('--squeeze-limit' , 'squeeze-limit' , [CompletionResultType]::ParameterName, 'Set the maximum number of consecutive empty lines to be printed.')
[CompletionResult]::new('--sanitize' , 'sanitize' , [CompletionResultType]::ParameterName, 'Specify when to sanitize untrusted input for safe display. Implies --strip-ansi and also replaces terminal-active and bidi / zero-width bytes. (auto, always, *never*).')
[CompletionResult]::new('--strip-ansi' , 'strip-ansi' , [CompletionResultType]::ParameterName, 'Specify when to strip ANSI escape sequences from the input. The automatic mode will remove escape sequences unless the syntax highlighting language is plain text. (auto, always, *never*).')
# [CompletionResult]::new('-p' , 'p' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').')
[CompletionResult]::new('--plain' , 'plain' , [CompletionResultType]::ParameterName, 'Show plain style (alias for ''--style=plain'').')
+8 -3
View File
@@ -80,7 +80,7 @@ _bat() {
-l | --language)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$(
"$1" --list-languages | while IFS=: read -r lang _; do
"$1" --no-paging --list-languages | while IFS=: read -r lang _; do
printf "%s\n" "$lang"
done
)" -- "$cur"))
@@ -132,6 +132,10 @@ _bat() {
COMPREPLY=($(compgen -W "auto never always" -- "$cur"))
return 0
;;
--sanitize)
COMPREPLY=($(compgen -W "auto never always" -- "$cur"))
return 0
;;
--completion)
COMPREPLY=($(compgen -W "bash fish zsh ps1" -- "$cur"))
return 0
@@ -150,14 +154,14 @@ _bat() {
;;
--theme)
local IFS=$'\n'
COMPREPLY=($(compgen -W "auto${IFS}auto:always${IFS}auto:system${IFS}dark${IFS}light${IFS}$("$1" --list-themes)" -- "$cur"))
COMPREPLY=($(compgen -W "auto${IFS}auto:always${IFS}auto:system${IFS}dark${IFS}light${IFS}$("$1" --no-paging --list-themes)" -- "$cur"))
__bat_escape_completions
return 0
;;
--theme-dark | \
--theme-light)
local IFS=$'\n'
COMPREPLY=($(compgen -W "$("$1" --list-themes)" -- "$cur"))
COMPREPLY=($(compgen -W "$("$1" --no-paging --list-themes)" -- "$cur"))
__bat_escape_completions
return 0
;;
@@ -221,6 +225,7 @@ _bat() {
--list-themes
--squeeze-blank
--squeeze-limit
--sanitize
--strip-ansi
--style
--line-range
+6 -5
View File
@@ -15,11 +15,11 @@ function __bat_complete_files -a token
end
function __bat_complete_one_language -a comp
command $bat --list-languages | string split -f1 : | string match -e "$comp"
command $bat --no-paging --list-languages | string split -f1 : | string match -e "$comp"
end
function __bat_complete_list_languages
for spec in (command $bat --list-languages)
for spec in (command $bat --no-paging --list-languages)
set -l name (string split -f1 : $spec)
for ext in (string split -f2 : $spec | string split ,)
test -n "$ext"; or continue
@@ -221,6 +221,7 @@ complete -c $bat -s s -l squeeze-blank -d "Squeeze consecutive empty lines into
complete -c $bat -l squeeze-limit -x -d "Set the maximum number of consecutive empty lines to be printed" -n __bat_no_excl_args
complete -c $bat -l strip-ansi -x -a "auto never always" -d "Specify when to strip ANSI escape sequences from the input" -n __bat_no_excl_args
complete -c $bat -l sanitize -x -a "auto never always" -d "Specify when to sanitize untrusted input for safe display" -n __bat_no_excl_args
complete -c $bat -s p -l plain -d "Disable decorations" -n __bat_no_excl_args
@@ -234,11 +235,11 @@ complete -c $bat -l tabs -x -a "$tabs_opts" -d "Set tab width" -n __bat_no_excl_
complete -c $bat -l terminal-width -x -d "Set terminal <width>, +<offset>, or -<offset>" -n __bat_no_excl_args
complete -c $bat -l theme -x -a "$special_themes(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme" -n __bat_no_excl_args
complete -c $bat -l theme -x -a "$special_themes(command $bat --no-paging --list-themes | command cat)" -d "Set the syntax highlighting theme" -n __bat_no_excl_args
complete -c $bat -l theme-dark -x -a "(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme for dark backgrounds" -n __bat_no_excl_args
complete -c $bat -l theme-dark -x -a "(command $bat --no-paging --list-themes | command cat)" -d "Set the syntax highlighting theme for dark backgrounds" -n __bat_no_excl_args
complete -c $bat -l theme-light -x -a "(command $bat --list-themes | command cat)" -d "Set the syntax highlighting theme for light backgrounds" -n __bat_no_excl_args
complete -c $bat -l theme-light -x -a "(command $bat --no-paging --list-themes | command cat)" -d "Set the syntax highlighting theme for light backgrounds" -n __bat_no_excl_args
complete -c $bat -s u -l unbuffered -d "Enable unbuffered input reading for streaming use cases" -n __bat_no_excl_args
+9 -3
View File
@@ -54,6 +54,7 @@ _{{PROJECT_EXECUTABLE}}_main() {
--squeeze-blank'[squeeze consecutive empty lines into a single empty line]'
--squeeze-limit='[set the maximum number of consecutive empty lines]:limit:'
--strip-ansi='[specify when to strip ANSI escape sequences]:when:(auto never always)'
--sanitize='[specify when to sanitize untrusted input for safe display]:when:(auto never always)'
--style='[comma-separated list of style elements to display]: : _values "style [default]"
default auto full plain changes header header-filename header-filesize grid rule numbers snip'
\*{-r+,--line-range=}'[only print the specified line range]:start\:end'
@@ -90,20 +91,25 @@ _{{PROJECT_EXECUTABLE}}_main() {
languages)
local IFS=$'\n'
local -a languages
languages=( ${(f)"$({{PROJECT_EXECUTABLE}} --list-languages | awk -F':|,' '{ for (i = 1; i <= NF; ++i) printf("%s:%s\n", $i, $1) }')"} )
# `--list-languages` emits one `name:matchers` line per language,
# which `_describe` parses as `value:description`. Only the
# language name is offered as the completion value; the matchers
# show up as the menu description. See
# https://github.com/sharkdp/bat/issues/3735.
languages=( ${(f)"$({{PROJECT_EXECUTABLE}} --no-paging --color=never --decorations=never --list-languages)"} )
_describe 'language' languages && ret=0
;;
themes)
local -a themes expl
themes=(${(f)"$(_call_program themes {{PROJECT_EXECUTABLE}} --list-themes)"} )
themes=(${(f)"$(_call_program themes {{PROJECT_EXECUTABLE}} --no-paging --color=never --decorations=never --list-themes)"} )
_wanted themes expl 'theme' compadd -a themes && ret=0
;;
theme_preferences)
local -a themes expl
themes=(auto dark light auto:always auto:system ${(f)"$(_call_program themes {{PROJECT_EXECUTABLE}} --list-themes)"} )
themes=(auto dark light auto:always auto:system ${(f)"$(_call_program themes {{PROJECT_EXECUTABLE}} --no-paging --color=never --decorations=never --list-themes)"} )
_wanted themes expl 'theme' compadd -a themes && ret=0
;;
+10
View File
@@ -12,3 +12,13 @@ index 05a4fed6..78a7bf55 100644
scope: source.js
variables:
bin_digit: '[01_]'
@@ -93,7 +93,8 @@ variables:
(?! \+\+ | -- )
(?=
!= |
- [-+*/%><=&|^\[(;,.:?] |
+ [-+*%><=&|^\[(;,.:?] |
+ /(?!\*) |
(?:in|instanceof){{identifier_break}}
)
))
+23 -4
View File
@@ -38,22 +38,41 @@ contexts:
scope: markup.underline.link.scheme.log
push: url-host
log_level_lines:
- match: (?=.*{{error}})
# Prevent retrying each lookahead at every position.
- match: ^(?=.*{{error}})
push:
- error_line_meta
- main_pop_at_eol
- match: (?=.*{{warning}})
- match: ^(?=.*{{warning}})
push:
- warning_line_meta
- main_pop_at_eol
- match: (?=.*{{info}})
- match: ^(?=.*{{info}})
push:
- info_line_meta
- main_pop_at_eol
- match: (?=.*{{debug}})
- match: ^(?=.*{{debug}})
push:
- debug_line_meta
- main_pop_at_eol
# For syntaxes that embed Log after consuming a prefix. Call this context once.
log_level_lines_at_current_position:
- match: (?=.*{{error}})
set:
- error_line_meta
- main_pop_at_eol
- match: (?=.*{{warning}})
set:
- warning_line_meta
- main_pop_at_eol
- match: (?=.*{{info}})
set:
- info_line_meta
- main_pop_at_eol
- match: (?=.*{{debug}})
set:
- debug_line_meta
- main_pop_at_eol
log_levels:
- match: '{{error}}'
scope: markup.error.log
+11 -1
View File
@@ -50,6 +50,17 @@ contexts:
- match: (?=\])
pop: true
text:
- include: text_special
- match: ''
set: classify_log_text
classify_log_text:
- include: scope:text.log#log_level_lines_at_current_position
- match: ''
set: text_body
text_body:
- include: text_special
- include: scope:text.log
text_special:
- match: $
pop: true
- match: '<\w+>'
@@ -62,4 +73,3 @@ contexts:
escape: \)$
escape_captures:
0: punctuation.section.block.end.syslog
- include: scope:text.log
+1 -1
View File
@@ -97,7 +97,7 @@ The following files have been manually modified after converting from a `.tmLang
as it is not kept in a standalone repository. The file is generated from
https://github.com/open-policy-agent/opa/blob/master/misc/syntax/textmate/Rego.tmLanguage
* `SML.sublime_syntax` has been added manually from
https://github.com/seanjames777/SML-Language-Definitiona as it is not
https://github.com/seanjames777/SML-Language-Definition as it is not
kept in a standalone repository. The file generated is from
https://github.com/seanjames777/SML-Language-Definition/blob/master/sml.tmLanguage
* `Cabal.sublime_syntax` has been added manually from
+18
View File
@@ -83,6 +83,16 @@ Options:
-n, --number
Only show line numbers, no other decorations. This is an alias for '--style=numbers'
-b, --number-nonblank
Only show line numbers for non-blank lines, no other decorations. This is an alias for
'--style=numbers'. Non-blank lines are lines that contain any character before the line
ending, including spaces and tabs. When used together with --number (-n),
--number-nonblank (-b) takes precedence.
Example:
printf 'alpha\n\nbeta\n' | bat -b
numbers 'alpha' and 'beta', but skips the empty line.
--color <when>
Specify when to use colored output. The automatic mode only enables colors if an
interactive terminal is detected - colors are automatically disabled if the output goes to
@@ -164,6 +174,14 @@ Options:
escape sequences unless the syntax highlighting language is plain text. Possible values:
auto, always, *never*.
--sanitize <when>
Specify when to sanitize input bytes for safe terminal display. Implies --strip-ansi to
the same value, and additionally substitutes terminal-active control bytes (cursor moves,
charset switches, beep, etc.) and Unicode bidi / zero-width formatting characters with the
Unicode replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful for
displaying untrusted file content (e.g. file-manager preview panes). Possible values:
auto, always, *never*.
--style <components>
Configure which elements (line numbers, file headers, grid borders, Git modifications, ..)
to display in addition to the file contents. The argument is a comma-separated list of
+2
View File
@@ -33,6 +33,8 @@ Options:
Truncate all lines longer than screen width. Alias for '--wrap=never'.
-n, --number
Show line numbers (alias for '--style=numbers').
-b, --number-nonblank
Show line numbers for non-blank lines only (alias for '--style=numbers').
--color <when>
When to use colors (*auto*, never, always).
--italic-text <when>
+52 -10
View File
@@ -347,15 +347,15 @@ impl HighlightingAssets {
file_name: &OsStr,
ignored_suffixes: &IgnoredSuffixes,
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
let mut syntax = self.find_syntax_by_extension(Path::new(file_name).extension())?;
if syntax.is_none() {
syntax =
ignored_suffixes.try_with_stripped_suffix(file_name, |stripped_file_name| {
// Note: recursion
self.get_syntax_for_file_extension(stripped_file_name, ignored_suffixes)
})?;
let stripped =
ignored_suffixes.try_with_stripped_suffix(file_name, |stripped_file_name| {
self.get_syntax_for_file_extension(stripped_file_name, ignored_suffixes)
.map(Some)
})?;
match stripped {
Some(syntax) => Ok(syntax),
None => self.find_syntax_by_extension(Path::new(file_name).extension()),
}
Ok(syntax)
}
fn get_first_line_syntax(
@@ -363,11 +363,11 @@ impl HighlightingAssets {
reader: &mut InputReader,
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
let syntax_set = self.get_syntax_set()?;
Ok(String::from_utf8(reader.first_line.clone())
Ok(std::str::from_utf8(&reader.first_line)
.ok()
.and_then(|l| {
// Strip UTF-8 BOM if present
let line = l.strip_prefix('\u{feff}').unwrap_or(&l);
let line = l.strip_prefix('\u{feff}').unwrap_or(l);
syntax_set.find_syntax_by_first_line(line)
})
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
@@ -686,6 +686,48 @@ mod tests {
);
}
#[test]
fn syntax_detection_ignored_suffix_falls_back_to_first_line() {
let mut test = SyntaxDetectionTest::new();
// By default a `.txt` file uses Plain Text, even with a shebang: the
// `.txt` extension wins and first-line detection is not reached.
assert_eq!(
test.syntax_for_file_with_content("test.txt", "#!/usr/bin/env bash"),
"Plain Text"
);
// Once `.txt` is an ignored suffix it is stripped before detection. The
// stripped name (`test`) has no extension, so detection falls back to the
// first line -- even though `.txt` is itself a registered extension that
// would otherwise match as Plain Text. See #2745.
test.syntax_mapping.insert_ignored_suffix(".txt");
assert_eq!(
test.syntax_for_file_with_content("test.txt", "#!/usr/bin/env bash"),
"Bourne Again Shell (bash)"
);
assert_eq!(
test.syntax_for_file_with_content("test.txt", "<?php"),
"PHP"
);
// A `.txt` file without a recognizable first line keeps no syntax (the
// caller renders it as plain text); we must not resurrect the shadowed
// `.txt` -> Plain Text match here.
assert_eq!(
test.syntax_for_file_with_content("notes.txt", "just some prose"),
"!no syntax!"
);
// Stripping that exposes a real extension still works: `.dev` is ignored,
// and the remaining `.json` extension is detected as usual.
test.syntax_mapping.insert_ignored_suffix(".dev");
assert_eq!(
test.syntax_for_file_with_content("config.json.dev", ""),
"JSON"
);
}
#[test]
fn syntax_detection_is_case_insensitive() {
let mut test = SyntaxDetectionTest::new();
+25 -15
View File
@@ -1,4 +1,5 @@
use std::convert::TryInto;
use std::io::{self, Write};
use std::path::Path;
use syntect::highlighting::ThemeSet;
@@ -24,7 +25,7 @@ pub fn build(
let acknowledgements = build_acknowledgements(source_dir, include_acknowledgements)?;
print_unlinked_contexts(&syntax_set);
print_unlinked_contexts(&syntax_set)?;
write_assets(
&theme_set,
@@ -46,16 +47,18 @@ fn build_theme_set(source_dir: &Path, include_integrated_assets: bool) -> Result
if theme_dir.exists() {
let res = theme_set.add_from_folder(&theme_dir);
if let Err(err) = res {
println!(
writeln!(
io::stdout(),
"Failed to load one or more themes from '{}' (reason: '{err}')",
theme_dir.to_string_lossy(),
);
)?;
}
} else {
println!(
writeln!(
io::stdout(),
"No themes were found in '{}', using the default set",
theme_dir.to_string_lossy()
);
)?;
}
theme_set.try_into()
@@ -78,23 +81,25 @@ fn build_syntax_set_builder(
if syntax_dir.exists() {
syntax_set_builder.add_from_folder(syntax_dir, true)?;
} else {
println!(
writeln!(
io::stdout(),
"No syntaxes were found in '{}', using the default set.",
syntax_dir.to_string_lossy()
);
)?;
}
Ok(syntax_set_builder)
}
fn print_unlinked_contexts(syntax_set: &SyntaxSet) {
fn print_unlinked_contexts(syntax_set: &SyntaxSet) -> Result<()> {
let missing_contexts = syntax_set.find_unlinked_contexts();
if !missing_contexts.is_empty() {
println!("Some referenced contexts could not be found!");
writeln!(io::stdout(), "Some referenced contexts could not be found!")?;
for context in missing_contexts {
println!("- {context}");
writeln!(io::stdout(), "- {context}")?;
}
}
Ok(())
}
fn write_assets(
@@ -127,12 +132,13 @@ fn write_assets(
)?;
}
print!(
write!(
io::stdout(),
"Writing metadata to folder {} ... ",
target_dir.to_string_lossy()
);
)?;
crate::assets_metadata::AssetsMetadata::new(current_version).save_to_folder(target_dir)?;
println!("okay");
writeln!(io::stdout(), "okay")?;
Ok(())
}
@@ -161,10 +167,14 @@ fn asset_to_cache<T: serde::Serialize>(
description: &str,
compressed: bool,
) -> Result<()> {
print!("Writing {description} to {} ... ", path.to_string_lossy());
write!(
io::stdout(),
"Writing {description} to {} ... ",
path.to_string_lossy()
)?;
let contents = asset_to_contents(asset, description, compressed)?;
std::fs::write(path, &contents[..])
.map_err(|_| format!("Could not save {description} to {}", path.to_string_lossy()))?;
println!("okay");
writeln!(io::stdout(), "okay")?;
Ok(())
}
+62 -41
View File
@@ -38,6 +38,15 @@ pub fn env_no_color() -> bool {
env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty())
}
fn parse_strip_ansi_value(raw: Option<&str>, flag_name: &str) -> StripAnsiMode {
match raw {
Some("never") | None => StripAnsiMode::Never,
Some("always") => StripAnsiMode::Always,
Some("auto") => StripAnsiMode::Auto,
_ => unreachable!("other values for {flag_name} are not allowed"),
}
}
enum HelpType {
Short,
Long,
@@ -50,6 +59,10 @@ pub struct App {
/// (not from config file or environment variables).
/// This is used to honor the flag when piping output, similar to `cat -n`.
number_from_cli: bool,
/// True if -b / --number-nonblank was passed on the command line
/// (not from config file or environment variables).
/// This is used to honor the flag when piping output, similar to `cat -b`.
number_nonblank_from_cli: bool,
}
impl App {
@@ -59,36 +72,12 @@ impl App {
let interactive_output = std::io::stdout().is_terminal();
// Check if the -n / --number option was passed on the command line
// (before merging with config file and environment variables).
// This is needed to honor the -n flag when piping output, similar to `cat -n`.
// We need to handle both standalone (-n, --number) and combined short flags (-pn, -An, etc.)
// Note: We only check if -n appears and is not overridden by -p in the same combined flag.
// For combined flags like -np, -p comes after -n and overrides it, so we don't count it.
// For combined flags like -pn, -n comes after -p and takes effect.
let number_from_cli = wild::args_os().any(|arg| {
let arg_str = arg.to_string_lossy();
if arg_str == "-n" || arg_str == "--number" {
return true;
}
// Handle combined short flags
// Only count -n if it's the LAST flag in the combined form (so -p doesn't override it)
// or if -p is not present in the combined form
if arg_str.starts_with('-') && !arg_str.starts_with("--") && arg_str.len() > 2 {
let chars: Vec<char> = arg_str.chars().skip(1).collect();
let n_pos = chars.iter().position(|&c| c == 'n');
let p_pos = chars.iter().position(|&c| c == 'p');
// -n is in the combined flag and either:
// - -p is not present, OR
// - -n comes after -p (so -n takes effect)
if let Some(n) = n_pos {
if p_pos.is_none() || n > p_pos.unwrap() {
return true;
}
}
}
false
});
// Parse the original command line separately from config and environment arguments.
// Using clap here keeps CLI-only detection consistent with the final parser, including
// option terminators, combined short flags, and options that take values.
let cli_matches = Self::cli_matches(interactive_output);
let number_from_cli = cli_matches.get_flag("number");
let number_nonblank_from_cli = cli_matches.get_flag("number-nonblank");
let matches = Self::matches(interactive_output)?;
@@ -130,6 +119,7 @@ impl App {
matches,
interactive_output,
number_from_cli,
number_nonblank_from_cli,
})
}
@@ -179,7 +169,8 @@ impl App {
};
let cache_dir = PROJECT_DIRS.cache_dir();
let assets = assets_from_cache_or_binary(use_custom_assets, cache_dir)?;
let config_dir = PROJECT_DIRS.config_dir();
let assets = assets_from_cache_or_binary(use_custom_assets, config_dir, cache_dir)?;
Controller::new(&help_config, &assets)
.run(inputs, None)
.ok();
@@ -201,6 +192,10 @@ impl App {
args
}
fn cli_matches(interactive_output: bool) -> ArgMatches {
clap_app::build_app(interactive_output).get_matches_from(wild::args_os())
}
fn matches(interactive_output: bool) -> Result<ArgMatches> {
// Check if we should skip config file processing for special arguments
// that don't require full application setup (version, diagnostic)
@@ -445,7 +440,8 @@ impl App {
.map(|s| s.as_str())
== Some("always")
|| self.matches.get_flag("force-colorization")
|| self.number_from_cli),
|| self.number_from_cli
|| self.number_nonblank_from_cli),
tab_width: self
.matches
.get_one::<String>("tabs")
@@ -458,18 +454,36 @@ impl App {
4
},
),
strip_ansi: match self
.matches
.get_one::<String>("strip-ansi")
.map(|s| s.as_str())
{
Some("never") => StripAnsiMode::Never,
Some("always") => StripAnsiMode::Always,
Some("auto") => StripAnsiMode::Auto,
_ => unreachable!("other values for --strip-ansi are not allowed"),
strip_ansi: {
let sanitize = parse_strip_ansi_value(
self.matches
.get_one::<String>("sanitize")
.map(|s| s.as_str()),
"--sanitize",
);
let strip_ansi = parse_strip_ansi_value(
self.matches
.get_one::<String>("strip-ansi")
.map(|s| s.as_str()),
"--strip-ansi",
);
// --sanitize implies --strip-ansi to the same value.
if sanitize != StripAnsiMode::Never {
sanitize
} else {
strip_ansi
}
},
sanitize: parse_strip_ansi_value(
self.matches
.get_one::<String>("sanitize")
.map(|s| s.as_str()),
"--sanitize",
),
quiet_empty: self.matches.get_flag("quiet-empty"),
unbuffered: self.matches.get_flag("unbuffered"),
number_nonblank: self.matches.get_flag("number-nonblank")
|| self.number_nonblank_from_cli,
theme: theme(self.theme_options()).to_string(),
visible_lines: match self.matches.try_contains_id("diff").unwrap_or_default()
&& self.matches.get_flag("diff")
@@ -588,6 +602,13 @@ impl App {
])));
}
// Only line numbers for non-blank lines if `--number-nonblank`.
if self.matches.get_flag("number-nonblank") || self.number_nonblank_from_cli {
return Some(StyleComponents(HashSet::from([
StyleComponent::LineNumbers,
])));
}
// Plain if `--plain` is specified at least once.
if self.matches.get_count("plain") > 0 {
let mut components = HashSet::from([StyleComponent::Plain]);
+61 -10
View File
@@ -1,5 +1,5 @@
use std::fs;
use std::io;
use std::io::{self, Write};
use std::path::Path;
use std::path::PathBuf;
@@ -9,16 +9,22 @@ use bat::assets::HighlightingAssets;
use bat::assets_metadata::AssetsMetadata;
use bat::error::*;
pub fn clear_assets(cache_dir: &Path) {
clear_asset(cache_dir.join("themes.bin"), "theme set cache");
clear_asset(cache_dir.join("syntaxes.bin"), "syntax set cache");
clear_asset(cache_dir.join("metadata.yaml"), "metadata file");
pub fn clear_assets(cache_dir: &Path) -> Result<()> {
clear_asset(cache_dir.join("themes.bin"), "theme set cache")?;
clear_asset(cache_dir.join("syntaxes.bin"), "syntax set cache")?;
clear_asset(cache_dir.join("metadata.yaml"), "metadata file")?;
Ok(())
}
pub fn assets_from_cache_or_binary(
use_custom_assets: bool,
config_dir: &Path,
cache_dir: &Path,
) -> Result<HighlightingAssets> {
if use_custom_assets {
ensure_cache_built(config_dir, cache_dir);
}
if let Some(metadata) = AssetsMetadata::load_from_folder(cache_dir)? {
if !metadata.is_compatible_with(crate_version!()) {
return Err(format!(
@@ -43,15 +49,60 @@ pub fn assets_from_cache_or_binary(
Ok(custom_assets.unwrap_or_else(HighlightingAssets::from_binary))
}
fn clear_asset(path: PathBuf, description: &str) {
print!("Clearing {description} ... ");
fn clear_asset(path: PathBuf, description: &str) -> Result<()> {
write!(io::stdout(), "Clearing {description} ... ")?;
match fs::remove_file(&path) {
Err(err) if err.kind() == io::ErrorKind::NotFound => {
println!("skipped (not present)");
writeln!(io::stdout(), "skipped (not present)")?;
}
Err(err) => {
println!("could not remove the cache file {path:?}: {err}");
writeln!(
io::stdout(),
"could not remove the cache file {path:?}: {err}"
)?;
}
Ok(_) => println!("okay"),
Ok(_) => writeln!(io::stdout(), "okay")?,
}
Ok(())
}
/// Build the cache on first run when custom themes or syntaxes are present
/// but no cache exists yet (see #4017). A failed build is not fatal: warn
/// and fall back to the integrated assets.
#[cfg(feature = "build-assets")]
fn ensure_cache_built(config_dir: &Path, cache_dir: &Path) {
if cache_exists(cache_dir) || !custom_sources_exist(config_dir) {
return;
}
eprintln!("bat: custom themes or syntaxes found, building cache ...");
if let Err(err) = bat::assets::build(config_dir, true, false, cache_dir, crate_version!()) {
eprintln!("bat: automatic cache build failed ({err}), falling back to integrated assets.");
}
}
#[cfg(not(feature = "build-assets"))]
fn ensure_cache_built(_config_dir: &Path, _cache_dir: &Path) {}
#[cfg(feature = "build-assets")]
fn cache_exists(cache_dir: &Path) -> bool {
cache_dir.join("themes.bin").is_file()
&& cache_dir.join("syntaxes.bin").is_file()
&& cache_dir.join("metadata.yaml").is_file()
}
#[cfg(feature = "build-assets")]
fn custom_sources_exist(config_dir: &Path) -> bool {
dir_has_files(&config_dir.join("themes")) || dir_has_files(&config_dir.join("syntaxes"))
}
#[cfg(feature = "build-assets")]
fn dir_has_files(dir: &Path) -> bool {
fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(|entry| entry.ok())
.any(|entry| entry.path().is_file())
})
.unwrap_or(false)
}
+49
View File
@@ -52,6 +52,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.long("show-all")
.alias("show-nonprintable")
.short('A')
.overrides_with("show-all")
.action(ArgAction::SetTrue)
.conflicts_with("language")
.help("Show non-printable characters (space, tab, newline, ..).")
@@ -64,6 +65,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("nonprintable-notation")
.long("nonprintable-notation")
.overrides_with("nonprintable-notation")
.action(ArgAction::Set)
.default_value("unicode")
.value_parser(["unicode", "caret"])
@@ -80,6 +82,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("binary")
.long("binary")
.overrides_with("binary")
.action(ArgAction::Set)
.default_value("no-printing")
.value_parser(["no-printing", "as-text"])
@@ -97,6 +100,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("plain")
.overrides_with("plain")
.overrides_with("number")
.overrides_with("number-nonblank")
.short('p')
.long("plain")
.action(ArgAction::Count)
@@ -123,6 +127,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("fallback-syntax")
.long("fallback-syntax")
.overrides_with("fallback-syntax")
.visible_alias("fallback-language")
.help("Set a fallback language for undetected syntaxes.")
.long_help(
@@ -170,6 +175,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("diff")
.long("diff")
.short('d')
.overrides_with("diff")
.action(ArgAction::SetTrue)
.conflicts_with("line-range")
.help("Only show lines that have been added/removed/modified.")
@@ -234,6 +240,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("chop-long-lines")
.long("chop-long-lines")
.short('S')
.overrides_with("chop-long-lines")
.action(ArgAction::SetTrue)
.help("Truncate all lines longer than screen width. Alias for '--wrap=never'."),
)
@@ -277,6 +284,23 @@ pub fn build_app(interactive_output: bool) -> Command {
'--style=numbers'",
),
)
.arg(
Arg::new("number-nonblank")
.long("number-nonblank")
.overrides_with("number-nonblank")
.short('b')
.action(ArgAction::SetTrue)
.help("Show line numbers for non-blank lines only (alias for '--style=numbers').")
.long_help(
"Only show line numbers for non-blank lines, no other decorations. This is an \
alias for '--style=numbers'. Non-blank lines are lines that contain any \
character before the line ending, including spaces and tabs. When used \
together with --number (-n), --number-nonblank (-b) takes precedence.\n\n\
Example:\n \
printf 'alpha\\n\\nbeta\\n' | bat -b\n \
numbers 'alpha' and 'beta', but skips the empty line.",
),
)
.arg(
Arg::new("color")
.long("color")
@@ -296,6 +320,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("italic-text")
.long("italic-text")
.overrides_with("italic-text")
.value_name("when")
.value_parser(["always", "never"])
.default_value("never")
@@ -455,6 +480,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("squeeze-blank")
.long("squeeze-blank")
.short('s')
.overrides_with("squeeze-blank")
.action(ArgAction::SetTrue)
.help("Squeeze consecutive empty lines.")
.long_help("Squeeze consecutive empty lines into a single empty line.")
@@ -462,6 +488,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("squeeze-limit")
.long("squeeze-limit")
.overrides_with("squeeze-limit")
.value_parser(|s: &str| s.parse::<usize>().map_err(|_| "Requires a non-negative number".to_owned()))
.long_help("Set the maximum number of consecutive empty lines to be printed.")
.hide_short_help(true)
@@ -480,6 +507,24 @@ pub fn build_app(interactive_output: bool) -> Command {
language is plain text. Possible values: auto, always, *never*.")
.hide_short_help(true)
)
.arg(
Arg::new("sanitize")
.long("sanitize")
.overrides_with("sanitize")
.value_name("when")
.value_parser(["auto", "always", "never"])
.default_value("never")
.hide_default_value(true)
.help("Sanitize untrusted input for safe display (auto, always, *never*)")
.long_help("Specify when to sanitize input bytes for safe terminal display. \
Implies --strip-ansi to the same value, and additionally substitutes \
terminal-active control bytes (cursor moves, charset switches, beep, etc.) \
and Unicode bidi / zero-width formatting characters with the Unicode \
replacement character (U+FFFD). Tab, LF, FF, and CRLF pass through. Useful \
for displaying untrusted file content (e.g. file-manager preview panes). \
Possible values: auto, always, *never*.")
.hide_short_help(true)
)
.arg(
Arg::new("style")
.long("style")
@@ -562,6 +607,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("unbuffered")
.short('u')
.long("unbuffered")
.overrides_with("unbuffered")
.action(ArgAction::SetTrue)
.help("Enable unbuffered input reading for streaming use cases.")
.long_help(
@@ -583,6 +629,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("no-custom-assets")
.long("no-custom-assets")
.overrides_with("no-custom-assets")
.action(ArgAction::SetTrue)
.hide(true)
.help("Do not load custom assets"),
@@ -663,6 +710,7 @@ pub fn build_app(interactive_output: bool) -> Command {
Arg::new("quiet-empty")
.long("quiet-empty")
.short('E')
.overrides_with("quiet-empty")
.action(ArgAction::SetTrue)
.help("Produce no output when the input is empty.")
.long_help(
@@ -681,6 +729,7 @@ pub fn build_app(interactive_output: bool) -> Command {
.arg(
Arg::new("set-terminal-title")
.long("set-terminal-title")
.overrides_with("set-terminal-title")
.action(ArgAction::SetTrue)
.hide_short_help(true)
.help("Sets terminal title to filenames when using a pager."),
+15 -7
View File
@@ -32,12 +32,13 @@ pub fn config_file() -> PathBuf {
pub fn generate_config_file() -> bat::error::Result<()> {
let config_file = config_file();
if config_file.is_file() {
println!(
writeln!(
io::stdout(),
"A config file already exists at: {}",
config_file.to_string_lossy()
);
)?;
print!("Overwrite? (y/N): ");
write!(io::stdout(), "Overwrite? (y/N): ")?;
io::stdout().flush()?;
let mut decision = String::new();
io::stdin().read_line(&mut decision)?;
@@ -93,10 +94,11 @@ pub fn generate_config_file() -> bat::error::Result<()> {
)
})?;
println!(
writeln!(
io::stdout(),
"Success! Config file written to {}",
config_file.to_string_lossy()
);
)?;
Ok(())
}
@@ -108,7 +110,7 @@ pub fn get_args_from_config_file() -> Result<Vec<OsString>, shell_words::ParseEr
let user_config = config_file();
if let Ok(c) = fs::read_to_string(&system_config) {
config.push_str(&c);
config.push_str(strip_bom(&c));
config.push('\n');
}
@@ -116,7 +118,7 @@ pub fn get_args_from_config_file() -> Result<Vec<OsString>, shell_words::ParseEr
// which can happen when BAT_CONFIG_DIR is set to e.g. "/etc/bat". See #3589.
if !same_file(&system_config, &user_config) {
if let Ok(c) = fs::read_to_string(&user_config) {
config.push_str(&c);
config.push_str(strip_bom(&c));
}
}
@@ -134,6 +136,12 @@ pub fn get_args_from_env_opts_var() -> Option<Result<Vec<OsString>, shell_words:
env::var("BAT_OPTS").ok().map(|s| get_args_from_str(&s))
}
/// Editors on Windows (e.g. `Set-Content -Encoding UTF8` in Windows PowerShell)
/// may prepend a UTF-8 byte order mark, which would otherwise end up in the first argument.
fn strip_bom(content: &str) -> &str {
content.strip_prefix('\u{feff}').unwrap_or(content)
}
fn get_args_from_str(content: &str) -> Result<Vec<OsString>, shell_words::ParseError> {
let args_per_line = content
.split('\n')
+49 -32
View File
@@ -74,9 +74,9 @@ fn run_cache_subcommand(
#[cfg(feature = "build-assets")]
build_assets(matches, config_dir, cache_dir)?;
#[cfg(not(feature = "build-assets"))]
println!("bat has been built without the 'build-assets' feature. The 'cache --build' option is not available.");
writeln!(io::stdout(), "bat has been built without the 'build-assets' feature. The 'cache --build' option is not available.")?;
} else if matches.get_flag("clear") {
clear_assets(cache_dir);
clear_assets(cache_dir)?;
}
Ok(())
@@ -97,10 +97,10 @@ where
map
}
pub fn get_languages(config: &Config, cache_dir: &Path) -> Result<String> {
pub fn get_languages(config: &Config, config_dir: &Path, cache_dir: &Path) -> Result<String> {
let mut result: String = String::new();
let assets = assets_from_cache_or_binary(config.use_custom_assets, cache_dir)?;
let assets = assets_from_cache_or_binary(config.use_custom_assets, config_dir, cache_dir)?;
let mut languages = assets
.get_syntaxes()?
.iter()
@@ -152,7 +152,14 @@ pub fn get_languages(config: &Config, cache_dir: &Path) -> Result<String> {
let comma_separator = ", ";
let separator = " ";
// Line-wrapping for the possible file extension overflow.
let desired_width = config.term_width - longest - separator.len();
// Clamp instead of subtracting: a tiny `--terminal-width` (smaller than the
// longest language name) would otherwise underflow `usize` and wrap to a huge
// value, silently disabling wrapping (and panicking in debug/overflow-checked
// builds). Mirrors the `saturating_sub` fix applied to `print_snip` in #3804.
let desired_width = config
.term_width
.saturating_sub(longest)
.saturating_sub(separator.len());
let style = if config.colored_output {
Green.normal()
@@ -198,7 +205,7 @@ pub fn list_themes(
cache_dir: &Path,
theme_options: ThemeOptions,
) -> Result<()> {
let assets = assets_from_cache_or_binary(cfg.use_custom_assets, cache_dir)?;
let assets = assets_from_cache_or_binary(cfg.use_custom_assets, config_dir, cache_dir)?;
let mut config = cfg.clone();
let mut style = HashSet::new();
style.insert(StyleComponent::Plain);
@@ -255,13 +262,17 @@ pub fn list_themes(
Ok(())
}
fn set_terminal_title_to(new_terminal_title: String) {
fn set_terminal_title_to(new_terminal_title: String) -> Result<()> {
let osc_command_for_setting_terminal_title = "\x1b]0;";
let osc_end_command = "\x07";
// Prevent BEL/ESC/C1 bytes in the title from terminating or nesting the OSC.
let safe_title = bat::sanitize_for_terminal(&new_terminal_title);
print!("{osc_command_for_setting_terminal_title}{safe_title}{osc_end_command}");
io::stdout().flush().unwrap();
write!(
io::stdout(),
"{osc_command_for_setting_terminal_title}{safe_title}{osc_end_command}"
)?;
io::stdout().flush()?;
Ok(())
}
fn get_new_terminal_title(inputs: &Vec<Input>) -> String {
@@ -275,17 +286,22 @@ fn get_new_terminal_title(inputs: &Vec<Input>) -> String {
new_terminal_title
}
fn run_controller(inputs: Vec<Input>, config: &Config, cache_dir: &Path) -> Result<bool> {
let assets = assets_from_cache_or_binary(config.use_custom_assets, cache_dir)?;
fn run_controller(
inputs: Vec<Input>,
config: &Config,
config_dir: &Path,
cache_dir: &Path,
) -> Result<bool> {
let assets = assets_from_cache_or_binary(config.use_custom_assets, config_dir, cache_dir)?;
let controller = Controller::new(config, &assets);
if config.paging_mode != PagingMode::Never && config.set_terminal_title {
set_terminal_title_to(get_new_terminal_title(&inputs));
set_terminal_title_to(get_new_terminal_title(&inputs))?;
}
controller.run(inputs, None)
}
#[cfg(feature = "bugreport")]
fn invoke_bugreport(app: &App, cache_dir: &Path) {
fn invoke_bugreport(app: &App, cache_dir: &Path) -> Result<()> {
use bugreport::{bugreport, collector::*, format::Markdown, report::ReportEntry};
struct ColorSchemeCollector;
@@ -322,6 +338,7 @@ fn invoke_bugreport(app: &App, cache_dir: &Path) {
.info(CommandLine::default())
.info(EnvironmentVariables::list(&[
"BAT_CACHE_PATH",
"BAT_CONFIG_DIR",
"BAT_CONFIG_PATH",
"BAT_OPTS",
"BAT_PAGER",
@@ -363,7 +380,8 @@ fn invoke_bugreport(app: &App, cache_dir: &Path) {
))
};
report.print::<Markdown>();
writeln!(io::stdout(), "{}", report.format::<Markdown>())?;
Ok(())
}
/// Returns `Err(..)` upon fatal errors. Otherwise, returns `Ok(true)` on full success and
@@ -375,21 +393,22 @@ fn run() -> Result<bool> {
if app.matches.get_flag("diagnostic") {
#[cfg(feature = "bugreport")]
invoke_bugreport(&app, cache_dir);
invoke_bugreport(&app, cache_dir)?;
#[cfg(not(feature = "bugreport"))]
println!("bat has been built without the 'bugreport' feature. The '--diagnostic' option is not available.");
writeln!(io::stdout(), "bat has been built without the 'bugreport' feature. The '--diagnostic' option is not available.")?;
return Ok(true);
}
#[cfg(feature = "application")]
if let Some(shell) = app.matches.get_one::<String>("completion") {
match shell.as_str() {
"bash" => println!("{}", completions::BASH_COMPLETION),
"fish" => println!("{}", completions::FISH_COMPLETION),
"ps1" => println!("{}", completions::PS1_COMPLETION),
"zsh" => println!("{}", completions::ZSH_COMPLETION),
let completion = match shell.as_str() {
"bash" => completions::BASH_COMPLETION,
"fish" => completions::FISH_COMPLETION,
"ps1" => completions::PS1_COMPLETION,
"zsh" => completions::ZSH_COMPLETION,
_ => unreachable!("No completion for shell '{shell}' available."),
}
};
writeln!(io::stdout(), "{completion}")?;
return Ok(true);
}
@@ -410,7 +429,7 @@ fn run() -> Result<bool> {
let inputs = vec![Input::ordinary_file("cache")];
let config = app.config(&inputs)?;
run_controller(inputs, &config, cache_dir)
run_controller(inputs, &config, config_dir, cache_dir)
}
}
_ => {
@@ -418,19 +437,17 @@ fn run() -> Result<bool> {
let config = app.config(&inputs)?;
if app.matches.get_flag("list-languages") {
let languages: String = get_languages(&config, cache_dir)?;
let languages: String = get_languages(&config, config_dir, cache_dir)?;
let inputs: Vec<Input> = vec![Input::from_reader(Box::new(languages.as_bytes()))];
let plain_config = Config {
style_components: StyleComponents::new(StyleComponent::Plain.components(false)),
paging_mode: PagingMode::QuitIfOneScreen,
..Default::default()
};
run_controller(inputs, &plain_config, cache_dir)
let mut plain_config = config.clone();
plain_config.style_components =
StyleComponents::new(StyleComponent::Plain.components(false));
run_controller(inputs, &plain_config, config_dir, cache_dir)
} else if app.matches.get_flag("list-themes") {
list_themes(&config, config_dir, cache_dir, app.theme_options())?;
Ok(true)
} else if app.matches.get_flag("config-file") {
println!("{}", config_file().to_string_lossy());
writeln!(io::stdout(), "{}", config_file().to_string_lossy())?;
Ok(true)
} else if app.matches.get_flag("generate-config-file") {
generate_config_file()?;
@@ -445,7 +462,7 @@ fn run() -> Result<bool> {
writeln!(io::stdout(), "{}", bat::assets::get_acknowledgements())?;
Ok(true)
} else {
run_controller(inputs, &config, cache_dir)
run_controller(inputs, &config, config_dir, cache_dir)
}
}
}
+10 -3
View File
@@ -98,24 +98,31 @@ pub struct Config<'a> {
/// cached assets) are not available, assets from the binary will be used instead.
pub use_custom_assets: bool,
// Whether or not to use $LESSOPEN if set
/// Whether or not to use $LESSOPEN if set
#[cfg(feature = "lessopen")]
pub use_lessopen: bool,
// Whether or not to set terminal title when using a pager
/// Whether or not to set terminal title when using a pager
pub set_terminal_title: bool,
/// The maximum number of consecutive empty lines to display
pub squeeze_lines: Option<usize>,
// Whether or not to strip ANSI escape codes from the input
/// Whether or not to strip ANSI escape codes from the input
pub strip_ansi: StripAnsiMode,
/// Substitute terminal-active and spoofing-relevant bytes; implies strip_ansi.
pub sanitize: StripAnsiMode,
/// Whether or not to produce no output when input is empty
pub quiet_empty: bool,
/// Whether or not to use unbuffered input reading for streaming use cases
pub unbuffered: bool,
/// Only number non-blank lines (like `cat -b`). Has no effect if `style_components` doesn't
/// include `LineNumbers`.
pub number_nonblank: bool,
}
#[cfg(all(feature = "minimal-application", feature = "paging"))]
+15 -4
View File
@@ -276,10 +276,21 @@ impl Controller<'_> {
}
if !reached_eof {
if reader.read_line(&mut current_line_buffer)? {
// Fill the buffer
buffered_lines
.push_back((mem::take(&mut current_line_buffer), current_line_number));
current_line_number += 1;
// Fill the buffer. In number-nonblank mode, don't advance the
// line counter for empty lines (content-free lines that contain
// only the line terminator).
if self.config.number_nonblank
&& current_line_buffer
.iter()
.all(|&b| b == b'\r' || b == b'\n')
{
buffered_lines
.push_back((mem::take(&mut current_line_buffer), current_line_number));
} else {
buffered_lines
.push_back((mem::take(&mut current_line_buffer), current_line_number));
current_line_number += 1;
}
} else {
// No more data to read
reached_eof = true;
+8
View File
@@ -45,6 +45,14 @@ impl Decoration for LineNumberDecoration {
continuation: bool,
_printer: &InteractivePrinter,
) -> DecorationText {
if line_number == 0 {
// Blank line in number-nonblank mode: show empty space instead of a number.
return DecorationText {
text: self.color.paint(" ".repeat(self.width())).to_string(),
width: self.width(),
};
}
if continuation {
if line_number >= self.cached_wrap_invalid_at {
let new_width = self.cached_wrap.width + 1;
+176 -62
View File
@@ -1,12 +1,13 @@
#![cfg(feature = "git")]
use gix::diff::blob::pipeline::{Mode, WorktreeRoots};
use gix::diff::blob::{Algorithm, HunkIter, ResourceKind};
use gix::object::tree::EntryKind;
use path_abs::PathInfo;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use git2::{DiffOptions, IntoCString, Repository};
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LineChange {
Added,
RemovedAbove,
@@ -16,68 +17,181 @@ pub enum LineChange {
pub type LineChanges = HashMap<u32, LineChange>;
pub fn get_git_diff(filename: &Path) -> Option<LineChanges> {
let repo = Repository::discover(filename).ok()?;
let repo_path_absolute = fs::canonicalize(repo.workdir()?).ok()?;
let filepath_absolute = fs::canonicalize(filename).ok()?;
let filepath_relative_to_repo = filepath_absolute.strip_prefix(&repo_path_absolute).ok()?;
let mut diff_options = DiffOptions::new();
let pathspec = filepath_relative_to_repo.into_c_string().ok()?;
diff_options.pathspec(pathspec);
diff_options.context_lines(0);
let diff = repo
.diff_index_to_workdir(None, Some(&mut diff_options))
.ok()?;
let mut line_changes: LineChanges = HashMap::new();
let mark_section =
|line_changes: &mut LineChanges, start: u32, end: i32, change: LineChange| {
for line in start..=end as u32 {
line_changes.insert(line, change);
fn collect_changes_from_hunks(hunks: HunkIter) -> Option<LineChanges> {
let mut changes: LineChanges = HashMap::new();
for hunk in hunks {
if hunk.before.is_empty() && !hunk.after.is_empty() {
for line in hunk.after {
changes.insert(line + 1, LineChange::Added);
}
};
let _ = diff.foreach(
&mut |_, _| true,
None,
Some(&mut |delta, hunk| {
let path = delta.new_file().path().unwrap_or_else(|| Path::new(""));
if filepath_relative_to_repo != path {
return false;
}
let old_lines = hunk.old_lines();
let new_start = hunk.new_start();
let new_lines = hunk.new_lines();
let new_end = (new_start + new_lines) as i32 - 1;
if old_lines == 0 && new_lines > 0 {
mark_section(&mut line_changes, new_start, new_end, LineChange::Added);
} else if new_lines == 0 && old_lines > 0 {
if new_start == 0 {
mark_section(&mut line_changes, 1, 1, LineChange::RemovedAbove);
} else {
mark_section(
&mut line_changes,
new_start,
new_start as i32,
LineChange::RemovedBelow,
);
}
} else if hunk.after.is_empty() && !hunk.before.is_empty() {
if hunk.after.start == 0 {
changes.insert(1, LineChange::RemovedAbove);
} else {
mark_section(&mut line_changes, new_start, new_end, LineChange::Modified);
changes.insert(hunk.after.start, LineChange::RemovedBelow);
}
} else {
for line in hunk.after {
changes.insert(line + 1, LineChange::Modified);
}
}
}
true
}),
None,
Some(changes)
}
pub fn get_git_diff(filename: &Path) -> Option<LineChanges> {
let filepath_absolute = filename.canonicalize().ok()?;
let repository = gix::discover(filepath_absolute.parent().ok()?).ok()?;
let repo_path_absolute = repository.workdir()?.canonicalize().ok()?;
let filepath_relative_to_repo = gix::path::to_unix_separators_on_windows(gix::path::into_bstr(
filepath_absolute.strip_prefix(&repo_path_absolute).ok()?,
));
let index = repository.index_or_load_from_head_or_empty().ok()?;
let index_entry = index.entry_by_path(filepath_relative_to_repo.as_ref())?;
let mut cache = repository
.diff_resource_cache(
Mode::ToGit,
WorktreeRoots {
old_root: None,
new_root: repository.workdir().map(Path::to_path_buf),
},
)
.ok()?;
cache
.set_resource(
index_entry.id,
index_entry.mode.to_tree_entry_mode()?.kind(),
filepath_relative_to_repo.as_ref(),
ResourceKind::OldOrSource,
&repository,
)
.ok()?;
cache
.set_resource(
repository.object_hash().null(),
EntryKind::Blob,
filepath_relative_to_repo.as_ref(),
ResourceKind::NewOrDestination,
&repository,
)
.ok()?;
let diff = gix::diff::blob::diff_with_slider_heuristics(
Algorithm::Histogram,
&cache.prepare_diff().ok()?.interned_input(),
);
Some(line_changes)
collect_changes_from_hunks(diff.hunks())
}
#[cfg(test)]
mod tests {
use super::{get_git_diff, LineChange};
use std::path::{Path, PathBuf};
use std::process::Command;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git command can run");
assert!(
output.status.success(),
"git {args:?} failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn setup_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("can create temporary directory");
let repo = dir.path();
git(repo, &["init"]);
git(repo, &["config", "user.email", "test@test.test"]);
git(repo, &["config", "user.name", "Test"]);
dir
}
fn create_and_track_file(repo: &tempfile::TempDir, filename: &str) -> PathBuf {
let filepath = repo.path().join(filename);
std::fs::write(&filepath, "file\n").expect("can write file");
git(repo.path(), &["add", filename]);
git(repo.path(), &["commit", "-m", "initial"]);
filepath.to_owned()
}
#[test]
fn not_a_git_repository() {
let dir = tempfile::tempdir().expect("can create temporary directory");
let file = dir.path().join("file.txt");
std::fs::write(&file, "line 1\n").expect("can write file");
assert_eq!(get_git_diff(&file), None);
}
#[test]
fn diff_is_calculated_against_index() {
let repo = setup_repo();
let file = create_and_track_file(&repo, "file.txt");
std::fs::write(&file, "line 1\nline 2 modified\n").expect("can write file");
// Add modified file to index -> should find 0 changes but not none
git(repo.path(), &["add", "file.txt"]);
assert_eq!(get_git_diff(&file).expect("empty map").len(), 0);
}
#[test]
fn diff_is_calculated_against_index_2() {
let repo = setup_repo();
let file = create_and_track_file(&repo, "file.txt");
std::fs::write(&file, "line 1\nline 2 modified\n").expect("can write file");
git(repo.path(), &["add", "file.txt"]);
// modify the second line again which should show a single change
std::fs::write(&file, "line 1\nline 2 modified again\n").expect("can write file");
assert_eq!(get_git_diff(&file).expect("one change").len(), 1);
}
#[test]
fn diff_is_calculated_correctly() {
let repo = setup_repo();
create_and_track_file(&repo, "committed.txt");
let filename = "committed2.txt";
let file = create_and_track_file(&repo, filename);
std::fs::write(&file, "file\nline 2 added\n").expect("can write file");
let mut changes = get_git_diff(&file).expect("multiple changes");
assert_eq!(changes.get(&2), Some(&LineChange::Added));
git(repo.path(), &["add", filename]);
std::fs::write(&file, "file\nline 2 modified\n").expect("can write file");
changes = get_git_diff(&file).expect("multiple changes");
assert_eq!(changes.get(&2), Some(&LineChange::Modified));
git(repo.path(), &["add", filename]);
std::fs::write(&file, "line 2 modified\nline 3 added").expect("can write file");
changes = get_git_diff(&file).expect("multiple changes");
assert_eq!(changes.get(&1), Some(&LineChange::RemovedAbove));
assert_eq!(changes.get(&2), Some(&LineChange::Added));
}
#[test]
fn faulty_git_version_does_not_panic() {
let repo = setup_repo();
let file = create_and_track_file(&repo, "file.txt");
std::fs::write(&file, "line 1\nline 2 modified\n").expect("can write file");
// changes are detected
assert_eq!(get_git_diff(&file).expect("one change").len(), 2);
// write invalid repositoryformatversion
git(
repo.path(),
&["config", "core.repositoryformatversion", "one"],
);
// changes are no longer detected
assert_eq!(get_git_diff(&file), None);
}
}
+13 -2
View File
@@ -53,6 +53,17 @@ impl From<String> for Error {
pub type Result<T> = std::result::Result<T, Error>;
/// Paint a `[bat error]`/`[bat warning]` style prefix, unless the user asked
/// for no colors by setting the `NO_COLOR` environment variable.
#[doc(hidden)]
pub fn paint_prefix(color: nu_ansi_term::Color, prefix: &str) -> String {
if std::env::var_os("NO_COLOR").is_some_and(|x| !x.is_empty()) {
prefix.to_owned()
} else {
color.paint(prefix).to_string()
}
}
pub fn default_error_handler(error: &Error, output: &mut dyn Write) {
use nu_ansi_term::Color::Red;
@@ -64,7 +75,7 @@ pub fn default_error_handler(error: &Error, output: &mut dyn Write) {
writeln!(
output,
"{}: Error while parsing metadata.yaml file: {error}",
Red.paint("[bat error]"),
paint_prefix(Red, "[bat error]"),
)
.ok();
}
@@ -72,7 +83,7 @@ pub fn default_error_handler(error: &Error, output: &mut dyn Write) {
writeln!(
&mut std::io::stderr().lock(),
"{}: {error}",
Red.paint("[bat error]"),
paint_prefix(Red, "[bat error]"),
)
.ok();
}
+77 -4
View File
@@ -9,6 +9,8 @@ use content_inspector::{self, ContentType};
use crate::error::*;
const CONTENT_INSPECTION_LIMIT: usize = 1024;
/// A description of an Input source.
/// This tells bat how to refer to the input.
#[derive(Clone)]
@@ -263,14 +265,38 @@ impl<'a> InputReader<'a> {
}
pub(crate) fn try_new<R: BufRead + 'a>(mut reader: R) -> io::Result<InputReader<'a>> {
let mut first_line = vec![];
reader.read_until(b'\n', &mut first_line)?;
// content_inspector scans at most 1024 bytes. Capture the already-buffered
// prefix before splitting out the first line so an early newline in binary
// data does not shorten the inspected content. This does not consume input
// or perform an additional read beyond the one read_until needs anyway.
let mut inspection_prefix = {
let buffered = reader.fill_buf()?;
buffered[..buffered.len().min(CONTENT_INSPECTION_LIMIT)].to_vec()
};
let content_type = inspect_content_type(&first_line);
let mut first_line = vec![];
if !inspection_prefix.is_empty() {
reader.read_until(b'\n', &mut first_line)?;
}
// A custom BufRead implementation may expose less than 1024 bytes at a
// time. Keep the old behavior for long first lines in that case.
let first_line_prefix_len = first_line.len().min(CONTENT_INSPECTION_LIMIT);
if first_line_prefix_len > inspection_prefix.len() {
inspection_prefix.clear();
inspection_prefix.extend_from_slice(&first_line[..first_line_prefix_len]);
}
let content_type = inspect_content_type(&inspection_prefix);
if content_type == Some(ContentType::UTF_16LE) {
read_utf16_line(&mut reader, &mut first_line, 0x00, 0x0A)?;
} else if content_type == Some(ContentType::UTF_16BE) {
} else if content_type == Some(ContentType::UTF_16BE)
// In UTF-16BE a newline is encoded as 0x00 0x0A, so `read_until(b'\n')` above
// may already have read a complete first line. Only keep reading if it did not
// (e.g. the 0x0A byte was part of another character such as U+4E0A).
&& !(first_line.len() % 2 == 0 && first_line.ends_with(&[0x00, 0x0A]))
{
read_utf16_line(&mut reader, &mut first_line, 0x0A, 0x00)?;
}
@@ -410,6 +436,53 @@ fn non_zip_pk_prefix_is_not_treated_as_binary() {
);
}
#[test]
fn binary_detection_scans_beyond_first_line_and_preserves_input() {
let mut content = vec![b'a'; CONTENT_INSPECTION_LIMIT + 1];
content[1] = b'\n';
content[CONTENT_INSPECTION_LIMIT - 1] = 0;
let mut reader = InputReader::new(&content[..]);
assert_eq!(Some(ContentType::BINARY), reader.content_type);
let mut replayed = Vec::new();
while reader.read_line(&mut replayed).unwrap() {}
assert_eq!(content, replayed);
drop(reader);
content[CONTENT_INSPECTION_LIMIT - 1] = b'a';
content[CONTENT_INSPECTION_LIMIT] = 0;
let reader = InputReader::new(&content[..]);
assert_eq!(Some(ContentType::UTF_8), reader.content_type);
}
#[test]
fn input_detection_does_not_read_twice() {
struct OneRead(Option<&'static [u8]>);
impl Read for OneRead {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self.0.take() {
None => Err(io::Error::new(
io::ErrorKind::WouldBlock,
"no more data is available yet",
)),
Some(content) => {
buf[..content.len()].copy_from_slice(content);
Ok(content.len())
}
}
}
}
for (content, expected) in [(&b"text\n"[..], Some(ContentType::UTF_8)), (&b""[..], None)] {
let input = InputReader::try_new(BufReader::new(OneRead(Some(content)))).unwrap();
assert_eq!(expected, input.content_type);
}
}
#[test]
fn input_open_returns_initial_read_errors() {
struct FailingRead;
+112 -4
View File
@@ -14,6 +14,53 @@ use crate::{
input::{Input, InputKind, InputReader, OpenedInput, OpenedInputKind},
};
/// Wrap `s` in POSIX single quotes. An embedded `'` is encoded as `'\''`
/// (close, escaped quote, reopen) since `'...'` has no escape character.
fn shell_quote(s: &str) -> String {
if !s.contains('\'') {
let mut quoted = String::with_capacity(s.len() + 2);
quoted.push('\'');
quoted.push_str(s);
quoted.push('\'');
return quoted;
}
let mut quoted = String::with_capacity(s.len() + 2);
quoted.push('\'');
for c in s.chars() {
if c == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(c);
}
}
quoted.push('\'');
quoted
}
/// Substitute the first `%s` in a $LESSOPEN/$LESSCLOSE template with a quoted value.
fn shell_substitute(template: &str, replacement: &str) -> String {
template.replacen("%s", &shell_quote(replacement), 1)
}
/// Substitute the first two `%s` occurrences positionally in one pass; chaining
/// `shell_substitute` twice would mis-target if `first` itself contains `%s`.
fn shell_substitute_two(template: &str, first: &str, second: &str) -> String {
let mut out = String::with_capacity(template.len() + first.len() + second.len() + 4);
let mut remaining = template;
let mut to_substitute = [Some(first), Some(second)];
for slot in &mut to_substitute {
if let Some(idx) = remaining.find("%s") {
out.push_str(&remaining[..idx]);
out.push_str(&shell_quote(slot.take().unwrap()));
remaining = &remaining[idx + 2..];
} else {
break;
}
}
out.push_str(remaining);
out
}
/// Preprocess files and/or stdin using $LESSOPEN and $LESSCLOSE
pub(crate) struct LessOpenPreprocessor {
lessopen: String,
@@ -87,7 +134,7 @@ impl LessOpenPreprocessor {
None => return input.open(stdin, stdout_identifier),
};
let mut lessopen_command = shell(self.lessopen.replacen("%s", path_str, 1));
let mut lessopen_command = shell(shell_substitute(&self.lessopen, path_str));
lessopen_command.stdout(Stdio::piped());
let lessopen_output = match lessopen_command.execute_output() {
@@ -122,7 +169,7 @@ impl LessOpenPreprocessor {
let mut stdin_buffer = Vec::new();
stdin.read_to_end(&mut stdin_buffer)?;
let mut lessopen_command = shell(self.lessopen.replacen("%s", "-", 1));
let mut lessopen_command = shell(shell_substitute(&self.lessopen, "-"));
lessopen_command.stdout(Stdio::piped());
let lessopen_output = match lessopen_command.execute_input_output(&stdin_buffer)
@@ -181,7 +228,7 @@ impl LessOpenPreprocessor {
lessclose: self
.lessclose
.as_ref()
.map(|s| s.replacen("%s", &path_str, 1).replacen("%s", &stdout, 1)),
.map(|s| shell_substitute_two(s, &path_str, &stdout)),
}
} else {
Preprocessed {
@@ -189,7 +236,7 @@ impl LessOpenPreprocessor {
lessclose: self
.lessclose
.as_ref()
.map(|s| s.replacen("%s", &path_str, 1).replacen("%s", "-", 1)),
.map(|s| shell_substitute_two(s, &path_str, "-")),
}
},
))?,
@@ -384,4 +431,65 @@ mod tests {
Ok(())
}
#[test]
fn shell_quote_plain_filename() {
assert_eq!(super::shell_quote("file.txt"), "'file.txt'");
assert_eq!(super::shell_quote("with space.txt"), "'with space.txt'");
assert_eq!(super::shell_quote(""), "''");
assert_eq!(super::shell_quote("-"), "'-'");
}
#[test]
fn shell_quote_metacharacters_neutralised() {
assert_eq!(super::shell_quote("; rm -rf ~ ;"), "'; rm -rf ~ ;'");
assert_eq!(super::shell_quote("$(payload)"), "'$(payload)'");
assert_eq!(super::shell_quote("`payload`"), "'`payload`'");
assert_eq!(super::shell_quote("a|b&c;d"), "'a|b&c;d'");
assert_eq!(super::shell_quote("..>/dev/null"), "'..>/dev/null'");
assert_eq!(super::shell_quote("\n\t"), "'\n\t'");
}
#[test]
fn shell_quote_embedded_single_quote() {
assert_eq!(super::shell_quote("it's"), "'it'\\''s'");
assert_eq!(super::shell_quote("'"), "''\\'''");
assert_eq!(super::shell_quote("a'b'c"), "'a'\\''b'\\''c'");
}
#[test]
fn shell_substitute_only_replaces_first_percent_s() {
assert_eq!(super::shell_substitute("echo %s", "x"), "echo 'x'");
assert_eq!(super::shell_substitute("echo %s %s", "x"), "echo 'x' %s");
}
#[test]
fn shell_substitute_protects_against_filename_injection() {
let template = "|preproc %s";
let attacker_filename = "; rm -rf ~ ;";
let result = super::shell_substitute(template, attacker_filename);
assert_eq!(result, "|preproc '; rm -rf ~ ;'");
}
#[test]
fn shell_substitute_two_replaces_first_two_placeholders() {
assert_eq!(
super::shell_substitute_two("echo %s and %s done", "a", "b"),
"echo 'a' and 'b' done"
);
// A third %s is left alone.
assert_eq!(
super::shell_substitute_two("echo %s %s %s", "a", "b"),
"echo 'a' 'b' %s"
);
}
#[test]
fn shell_substitute_two_handles_percent_s_in_first_argument() {
// The chained-substitute approach would replace the injected `%s` here.
assert_eq!(
super::shell_substitute_two("a %s b %s c", "%s", "second"),
"a '%s' b 'second' c"
);
}
}
+3 -3
View File
@@ -355,13 +355,13 @@ fn test_parse_context_fail() {
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RangeCheckResult {
// Within one of the given ranges
/// Within one of the given ranges
InRange,
// Before the first range or within two ranges
/// Before the first range or within two ranges
BeforeOrBetweenRanges,
// Line number is outside of all ranges and larger than the last range.
/// Line number is outside of all ranges and larger than the last range.
AfterLastRange,
}
+5 -2
View File
@@ -1,7 +1,10 @@
#[macro_export]
macro_rules! bat_warning {
($($arg:tt)*) => ({
use nu_ansi_term::Color::Yellow;
eprintln!("{}: {}", Yellow.paint("[bat warning]"), format!($($arg)*));
eprintln!(
"{}: {}",
$crate::error::paint_prefix(nu_ansi_term::Color::Yellow, "[bat warning]"),
format!($($arg)*)
);
})
}
+214 -2
View File
@@ -139,16 +139,111 @@ pub fn replace_nonprintable(
/// Strips ANSI escape sequences from the input.
pub fn strip_ansi(line: &str) -> String {
let mut buffer = String::with_capacity(line.len());
for seq in EscapeSequenceOffsetsIterator::new(line) {
if let EscapeSequenceOffsets::Text { .. } = seq {
buffer.push_str(&line[seq.index_of_start()..seq.index_past_end()]);
}
}
buffer
}
/// Strips ANSI escape sequences and substitutes terminal-active control bytes
/// and visual-spoofing Unicode codepoints (bidi, zero-width) with U+FFFD.
pub fn sanitize(line: &str) -> String {
let stripped = strip_ansi(line);
let mut buffer = String::with_capacity(stripped.len());
let bytes = stripped.as_bytes();
let mut start = 0;
let mut i = 0;
// Skip directly to the next trigger byte instead of testing each one.
while let Some(off) = bytes[i..].iter().position(|&b| is_sanitize_trigger(b)) {
i += off;
let len = sanitize_at(bytes, i, &stripped, &mut buffer, &mut start);
i += len;
}
buffer.push_str(&stripped[start..]);
buffer
}
#[inline]
fn is_sanitize_trigger(b: u8) -> bool {
// C0 controls minus \t \n \f; DEL; UTF-8 leads with dangerous codepoints.
matches!(b, 0x00..=0x08 | 0x0B | 0x0D..=0x1F | 0x7F | 0xC2 | 0xD8 | 0xE2 | 0xEF)
}
/// Substitutes the byte/sequence at `bytes[i]` (or passes it through on
/// false-alarm trigger), flushing the prefix from `start`. Returns bytes consumed.
fn sanitize_at(
bytes: &[u8],
i: usize,
full: &str,
buffer: &mut String,
start: &mut usize,
) -> usize {
buffer.push_str(&full[*start..i]);
let consumed = match bytes[i] {
b'\r' if bytes.get(i + 1) == Some(&b'\n') => {
buffer.push_str("\r\n");
2
}
// 0xC2 leads U+0080..U+00FF; filter the C1 range.
0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => {
buffer.push('\u{FFFD}');
2
}
0xE2 if is_dangerous_e2(bytes, i) => {
buffer.push('\u{FFFD}');
3
}
// 0xEF 0xBB 0xBF = U+FEFF (BOM / zero-width no-break space).
0xEF if bytes.get(i + 1) == Some(&0xBB) && bytes.get(i + 2) == Some(&0xBF) => {
buffer.push('\u{FFFD}');
3
}
// 0xD8 0x9C = U+061C (Arabic letter mark, a bidi control). The rest of
// the 0xD8 block is ordinary Arabic text.
0xD8 if bytes.get(i + 1) == Some(&0x9C) => {
buffer.push('\u{FFFD}');
2
}
// False-alarm trigger: pass the full UTF-8 sequence through.
lead @ (0xC2 | 0xD8 | 0xE2 | 0xEF) => {
let n = utf8_len_from_lead(lead);
buffer.push_str(&full[i..i + n]);
n
}
_ => {
buffer.push('\u{FFFD}');
1
}
};
*start = i + consumed;
consumed
}
#[inline]
fn is_dangerous_e2(bytes: &[u8], i: usize) -> bool {
// U+200B..D (zero-width), U+200E..F (LRM/RLM), U+202A..E (bidi embedding
// and override), U+2066..9 (bidi isolates).
matches!(
(bytes.get(i + 1), bytes.get(i + 2)),
(Some(0x80), Some(0x8B..=0x8F | 0xAA..=0xAE)) | (Some(0x81), Some(0xA6..=0xA9))
)
}
#[inline]
fn utf8_len_from_lead(lead: u8) -> usize {
if lead < 0x80 {
1
} else if lead < 0xE0 {
2
} else if lead < 0xF0 {
3
} else {
4
}
}
/// Escape C0, DEL, and C1 control characters so a string from an untrusted
/// filename or path can be safely written to the terminal.
pub fn sanitize_for_terminal(input: &str) -> String {
@@ -207,11 +302,21 @@ pub fn strip_overstrike(line: &str, first_backspace: usize) -> String {
output
}
/// Specify when to strip ANSI escape sequences from the input.
///
/// Used for both the `--strip-ansi` and the `--sanitize` option, the latter
/// implying the former with the same value.
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum StripAnsiMode {
/// Keep escape sequences in the output
#[default]
Never,
/// Strip escape sequences from the output
Always,
/// Strip escape sequences unless the syntax highlighting language is plain
/// text, in which case they may be part of what the input wants to display
Auto,
}
@@ -270,6 +375,113 @@ fn test_strip_ansi() {
);
}
#[test]
fn test_strip_ansi_8bit_c1_introducers() {
assert_eq!(strip_ansi("a\u{9B}31mRED\u{9B}0mb"), "aREDb");
assert_eq!(strip_ansi("a\x1bP1;0|payload\x1b\\b"), "ab");
assert_eq!(strip_ansi("a\u{90}body\u{9C}b"), "ab");
}
#[test]
fn test_strip_ansi_single_char_esc() {
// strip_ansi must consume both bytes of single-byte ESC sequences (RIS, DECSC, keypad, VT52).
assert_eq!(strip_ansi("a\x1bcb"), "ab");
assert_eq!(strip_ansi("a\x1b7b\x1b8c"), "abc");
assert_eq!(strip_ansi("a\x1b=b\x1b>c"), "abc");
assert_eq!(strip_ansi("a\x1bZb"), "ab");
}
#[test]
fn test_strip_ansi_preserves_control_bytes() {
// strip_ansi removes only ANSI escape sequences; control bytes pass through.
assert_eq!(strip_ansi("safe\rEVIL"), "safe\rEVIL");
assert_eq!(strip_ansi("a\x08b\x07c\x0E\x0Fd"), "a\x08b\x07c\x0E\x0Fd");
}
#[test]
fn test_sanitize_substitutes_dangerous_bytes() {
let r = '\u{FFFD}';
assert_eq!(sanitize("safe\rEVIL"), format!("safe{r}EVIL"));
assert_eq!(sanitize("a\x08b"), format!("a{r}b"));
assert_eq!(sanitize("a\x07b"), format!("a{r}b"));
assert_eq!(sanitize("a\x0Bb"), format!("a{r}b"));
assert_eq!(sanitize("a\x0Eb\x0Fc"), format!("a{r}b{r}c"));
assert_eq!(sanitize("a\u{8D}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{85}b"), format!("a{r}b"));
assert_eq!(sanitize("trailing\r"), format!("trailing{r}"));
assert_eq!(sanitize("a\x7Fb"), format!("a{r}b"));
}
#[test]
fn test_sanitize_substitutes_bidi_and_zero_width() {
let r = '\u{FFFD}';
// Trojan-Source bidi formatting: U+202A..U+202E
assert_eq!(sanitize("a\u{202A}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{202E}b"), format!("a{r}b"));
// Bidi isolates: U+2066..U+2069
assert_eq!(sanitize("a\u{2066}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{2069}b"), format!("a{r}b"));
// Zero-width: U+200B..U+200D
assert_eq!(sanitize("a\u{200B}b"), format!("a{r}b"));
assert_eq!(sanitize("a\u{200D}b"), format!("a{r}b"));
// BOM in middle of file: U+FEFF
assert_eq!(sanitize("a\u{FEFF}b"), format!("a{r}b"));
}
#[test]
fn test_sanitize_substitutes_every_bidi_control() {
// Unicode Bidi_Control is exactly these 12 codepoints. Covering only some
// of them leaves a reordering attack available through the rest.
let r = '\u{FFFD}';
for c in [
'\u{061C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
] {
assert_eq!(
sanitize(&format!("a{c}b")),
format!("a{r}b"),
"U+{:04X} was not substituted",
c as u32
);
}
}
#[test]
fn test_sanitize_preserves_arabic_sharing_the_alm_lead_byte() {
// U+061C is reached via lead byte 0xD8, which also leads ordinary Arabic.
assert_eq!(
sanitize("\u{0600}\u{061F}\u{06FF}"),
"\u{0600}\u{061F}\u{06FF}"
);
assert_eq!(sanitize("مرحبا بالعالم"), "مرحبا بالعالم");
}
#[test]
fn test_sanitize_preserves_legitimate_bytes() {
assert_eq!(sanitize("crlf\r\nline\r\n"), "crlf\r\nline\r\n");
assert_eq!(sanitize("a\tb\nc"), "a\tb\nc");
assert_eq!(sanitize("plain ascii"), "plain ascii");
assert_eq!(sanitize("üñíçödé"), "üñíçödé");
// FF (U+000C) passes through; section separator in C source / Emacs Lisp.
assert_eq!(sanitize("section1\x0Csection2"), "section1\x0Csection2");
// Common Unicode that shares a UTF-8 lead byte with dangerous codepoints
// must pass through unchanged.
assert_eq!(sanitize("snowman ☃ moon ☾"), "snowman ☃ moon ☾");
assert_eq!(sanitize("emoji 🎉 ☃"), "emoji 🎉 ☃");
assert_eq!(sanitize("0xC2 lead: ÿ ñ ç"), "0xC2 lead: ÿ ñ ç");
assert_eq!(sanitize("CJK 漢字 emoji 🦀"), "CJK 漢字 emoji 🦀");
}
#[test]
fn test_sanitize_strips_ansi() {
let r = '\u{FFFD}';
// sanitize is a strict superset of strip_ansi.
assert_eq!(sanitize("a\x1B[31mb\x1B[0mc"), "abc");
assert_eq!(sanitize("a\u{9B}31mb\u{9B}0mc"), "abc");
// ANSI then dangerous byte: ANSI gone, byte substituted.
assert_eq!(sanitize("\x1B[31mhello\rEVIL"), format!("hello{r}EVIL"));
}
#[test]
fn test_strip_overstrike() {
// Bold: X\x08X (same char repeated)
+9
View File
@@ -192,6 +192,15 @@ impl<'a> PrettyPrinter<'a> {
self
}
/// Whether to sanitize untrusted input for safe display (default: never)
///
/// Strips ANSI escape sequences and additionally substitutes terminal-active
/// control bytes and bidi / zero-width codepoints with U+FFFD.
pub fn sanitize(&mut self, mode: StripAnsiMode) -> &mut Self {
self.config.sanitize = mode;
self
}
/// Text wrapping mode (default: do not wrap)
pub fn wrapping_mode(&mut self, mode: WrappingMode) -> &mut Self {
self.config.wrapping_mode = mode;
+42 -8
View File
@@ -30,7 +30,8 @@ use crate::input::OpenedInput;
use crate::line_range::{MaxBufferedLineNumber, RangeCheckResult};
use crate::output::OutputHandle;
use crate::preprocessor::{
expand_tabs, replace_nonprintable, sanitize_for_terminal, strip_ansi, strip_overstrike,
expand_tabs, replace_nonprintable, sanitize, sanitize_for_terminal, strip_ansi,
strip_overstrike,
};
use crate::style::StyleComponent;
use crate::terminal::{as_terminal_escaped, to_ansi_color};
@@ -210,6 +211,7 @@ pub(crate) struct InteractivePrinter<'a> {
background_color_highlight: Option<Color>,
consecutive_empty_lines: usize,
strip_ansi: bool,
sanitize: bool,
strip_overstrike: bool,
}
@@ -273,7 +275,9 @@ impl<'a> InteractivePrinter<'a> {
let needs_to_match_syntax = (!is_printing_binary
|| matches!(config.binary, BinaryBehavior::AsText))
&& (config.colored_output || config.strip_ansi == StripAnsiMode::Auto);
&& (config.colored_output
|| config.strip_ansi == StripAnsiMode::Auto
|| config.sanitize == StripAnsiMode::Auto);
let (is_plain_text, strip_overstrike, highlighter_from_set) = if needs_to_match_syntax {
// Determine the type of syntax for highlighting
@@ -319,6 +323,14 @@ impl<'a> InteractivePrinter<'a> {
_ => false,
};
let sanitize = match config.sanitize {
_ if config.show_nonprintable => false,
StripAnsiMode::Always => true,
StripAnsiMode::Auto if is_plain_text => false,
StripAnsiMode::Auto => true,
_ => false,
};
Ok(InteractivePrinter {
panel_width,
colors,
@@ -332,6 +344,7 @@ impl<'a> InteractivePrinter<'a> {
background_color_highlight,
consecutive_empty_lines: 0,
strip_ansi,
sanitize,
strip_overstrike,
})
}
@@ -603,11 +616,23 @@ impl Printer for InteractivePrinter<'_> {
let title = "8<";
let title_count = title.chars().count();
let snip_left = "─ ".repeat((self.config.term_width - panel_count - (title_count / 2)) / 4);
let snip_left = "─ ".repeat(
self.config
.term_width
.saturating_sub(panel_count)
.saturating_sub(title_count / 2)
/ 4,
);
let snip_left_count = snip_left.chars().count(); // Can't use .len() with Unicode.
let snip_right =
" ─".repeat((self.config.term_width - panel_count - snip_left_count - title_count) / 2);
let snip_right = " ─".repeat(
self.config
.term_width
.saturating_sub(panel_count)
.saturating_sub(snip_left_count)
.saturating_sub(title_count)
/ 2,
);
writeln!(
handle,
@@ -663,8 +688,10 @@ impl Printer for InteractivePrinter<'_> {
}
}
// If ANSI escape sequences are supposed to be stripped, do it before syntax highlighting.
if self.strip_ansi {
// Sanitize is the strict superset; otherwise strip-ansi alone.
if self.sanitize {
line = sanitize(&line).into()
} else if self.strip_ansi {
line = strip_ansi(&line).into()
}
@@ -711,10 +738,17 @@ impl Printer for InteractivePrinter<'_> {
// Line decorations.
if self.panel_width > 0 {
let display_line_number =
if self.config.number_nonblank && line.trim_end_matches(['\r', '\n']).is_empty() {
0
} else {
line_number
};
let decorations = self
.decorations
.iter()
.map(|d| d.generate(line_number, false, self));
.map(|d| d.generate(display_line_number, false, self));
for deco in decorations {
write!(handle, "{} ", deco.text)?;
@@ -1,3 +1,3 @@
# JSON Lines is a simple variation of JSON #2535
[mappings]
"JSON" = ["*.jsonl", "*.jsonc", "*.jsonld", "*.geojson", "*.ndjson"]
"JSON" = ["*.jsonl", "*.jsonc", "*.jsonld", "*.geojson", "*.ndjson", "*.code-workspace"]
@@ -0,0 +1,2 @@
[mappings]
"Makefile" = ["justfile", "Justfile", ".justfile", "*.justfile"]
@@ -0,0 +1,4 @@
# kubectl's config file has no extension. The format is YAML.
# https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/
[mappings]
"YAML" = ["**/.kube/config"]
@@ -1,2 +1,2 @@
[mappings]
"SSH Config" = ["**/.ssh/config"]
"SSH Config" = ["**/.ssh/**/config"]
@@ -0,0 +1,2 @@
[mappings]
"INI" = ["/etc/dnf/dnf.conf", "/etc/yum.repos.d/*.repo"]
@@ -1,5 +1,6 @@
# see `man quadlet`
[mappings]
"INI" = [
"*.quadlets",
"**/containers/systemd/**/*.{container,volume,network,kube,image,build,pod,artifact}",
]
@@ -0,0 +1,2 @@
[mappings]
"INI" = ["**/ghostty/**/*.ghostty", "**/ghostty/themes/*"]
+232 -16
View File
@@ -173,8 +173,10 @@ impl Attributes {
2 => self.dim = "\x1B[2m".to_owned(),
3 => self.italic = "\x1B[3m".to_owned(),
4 => self.underline = "\x1B[4m".to_owned(),
9 => self.strike = "\x1B[9m".to_owned(),
23 => self.italic.clear(),
24 => self.underline.clear(),
29 => self.strike.clear(),
22 => {
self.bold.clear();
self.dim.clear();
@@ -386,12 +388,23 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
}
fn next_text(&mut self) -> Option<EscapeSequenceOffsets> {
self.chars_take_while(|c| c != '\x1B')
self.chars_take_while(|c| !is_sequence_introducer(c))
.map(|(start, end)| EscapeSequenceOffsets::Text { start, end })
}
fn next_sequence(&mut self) -> Option<EscapeSequenceOffsets> {
let (start_sequence, c) = self.chars.next().expect("to not be finished");
// Handle 8-bit C1 introducers as their 7-bit `ESC <x>` equivalents.
match c {
'\u{9B}' => return self.next_csi_body(start_sequence),
'\u{9D}' => return self.next_osc_body(start_sequence),
'\u{90}' | '\u{98}' | '\u{9E}' | '\u{9F}' => {
return self.next_string_terminated_body(start_sequence)
}
_ => {}
}
match self.chars.peek() {
None => Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
@@ -400,12 +413,24 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
Some((_, ']')) => self.next_osc(start_sequence),
Some((_, '[')) => self.next_csi(start_sequence),
Some((i, c)) => match c {
// 7-bit DCS/SOS/PM/APC: `ESC P/X/^/_` introduces a string body.
Some((_, 'P' | 'X' | '^' | '_')) => {
self.chars.next();
self.next_string_terminated_body(start_sequence)
}
Some(&(i, c)) => match c {
'\x20'..='\x2F' => self.next_nf(start_sequence),
c => Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
end: i + c.len_utf8(),
}),
c => {
// Single-byte ESC sequence (RIS, DECSC/DECRC, keypad, VT52 etc.).
let end = match self.chars.next() {
Some((j, fc)) => j + fc.len_utf8(),
None => i + c.len_utf8(),
};
Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
end,
})
}
},
}
}
@@ -413,12 +438,27 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
fn next_osc(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let (osc_open_index, osc_open_char) = self.chars.next().expect("to not be finished");
debug_assert_eq!(osc_open_char, ']');
let start_command = osc_open_index + osc_open_char.len_utf8();
Some(self.read_osc_body(start_sequence, start_command))
}
/// OSC body parser entered after the 8-bit introducer U+009D was consumed.
fn next_osc_body(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let start_command = start_sequence + '\u{9D}'.len_utf8();
Some(self.read_osc_body(start_sequence, start_command))
}
fn read_osc_body(
&mut self,
start_sequence: usize,
start_command: usize,
) -> EscapeSequenceOffsets {
let mut start_terminator: usize;
let mut end_sequence: usize;
loop {
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B')) {
// ST is BEL, ESC `\\`, or U+009C.
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) {
None => {
start_terminator = self.text.len();
end_sequence = start_terminator;
@@ -437,6 +477,11 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
break;
}
Some((ti, '\u{9C}')) => {
end_sequence = ti + '\u{9C}'.len_utf8();
break;
}
Some((ti, '\x1B')) => {
match self.chars.next() {
Some((i, '\\')) => {
@@ -466,20 +511,82 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
}
}
Some(EscapeSequenceOffsets::OSC {
EscapeSequenceOffsets::OSC {
start_sequence,
start_command: osc_open_index + osc_open_char.len_utf8(),
start_command,
start_terminator,
end: end_sequence,
}
}
/// DCS/SOS/PM/APC body parser. Emitted as `Unknown` so the body is stripped.
fn next_string_terminated_body(
&mut self,
start_sequence: usize,
) -> Option<EscapeSequenceOffsets> {
let mut end_sequence: usize;
loop {
match self.chars_take_while(|c| !matches!(c, '\x07' | '\x1B' | '\u{9C}')) {
None => {
end_sequence = self.text.len();
break;
}
Some((_, end)) => {
end_sequence = end;
}
}
match self.chars.next() {
Some((ti, '\x07')) => {
end_sequence = ti + '\x07'.len_utf8();
break;
}
Some((ti, '\u{9C}')) => {
end_sequence = ti + '\u{9C}'.len_utf8();
break;
}
Some((ti, '\x1B')) => match self.chars.next() {
Some((i, '\\')) => {
end_sequence = i + '\\'.len_utf8();
break;
}
None => {
end_sequence = ti + '\x1B'.len_utf8();
break;
}
_ => {}
},
None => break,
Some((_, tc)) => {
panic!("this should not be reached: char {tc:?}")
}
}
}
Some(EscapeSequenceOffsets::Unknown {
start: start_sequence,
end: end_sequence,
})
}
fn next_csi(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let (csi_open_index, csi_open_char) = self.chars.next().expect("to not be finished");
debug_assert_eq!(csi_open_char, '[');
Some(self.read_csi_body(start_sequence, csi_open_index + csi_open_char.len_utf8()))
}
let start_parameters: usize = csi_open_index + csi_open_char.len_utf8();
/// CSI body parser entered after the 8-bit introducer U+009B was consumed.
fn next_csi_body(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
let start_parameters = start_sequence + '\u{9B}'.len_utf8();
Some(self.read_csi_body(start_sequence, start_parameters))
}
fn read_csi_body(
&mut self,
start_sequence: usize,
start_parameters: usize,
) -> EscapeSequenceOffsets {
// Keep iterating while within the range of `0x30-0x3F`.
let mut start_intermediates: usize = start_parameters;
if let Some((_, end)) = self.chars_take_while(|c| matches!(c, '\x30'..='\x3F')) {
@@ -498,13 +605,13 @@ impl<'a> EscapeSequenceOffsetsIterator<'a> {
Some((i, c)) => i + c.len_utf8(),
};
Some(EscapeSequenceOffsets::CSI {
EscapeSequenceOffsets::CSI {
start_sequence,
start_parameters,
start_intermediates,
start_final_byte,
end: end_of_sequence,
})
}
}
fn next_nf(&mut self, start_sequence: usize) -> Option<EscapeSequenceOffsets> {
@@ -543,13 +650,25 @@ impl Iterator for EscapeSequenceOffsetsIterator<'_> {
type Item = EscapeSequenceOffsets;
fn next(&mut self) -> Option<Self::Item> {
match self.chars.peek() {
Some((_, '\x1B')) => self.next_sequence(),
Some((_, c)) if is_sequence_introducer(*c) => self.next_sequence(),
Some((_, _)) => self.next_text(),
None => None,
}
}
}
/// True for ESC and the 8-bit C1 sequence introducers (DCS/SOS/CSI/OSC/PM/APC).
#[inline]
fn is_sequence_introducer(c: char) -> bool {
if (c as u32) < 0x80 {
return c == '\x1B';
}
matches!(
c,
'\u{90}' | '\u{98}' | '\u{9B}' | '\u{9D}' | '\u{9E}' | '\u{9F}'
)
}
/// An iterator over ANSI/VT escape sequences within a string.
///
/// ## Example
@@ -717,6 +836,79 @@ mod tests {
);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_8bit_csi() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{9B}31m");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::CSI {
start_sequence: 0,
start_parameters: 2,
start_intermediates: 4,
start_final_byte: 4,
end: 5,
})
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_8bit_osc_with_8bit_st() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{9D}0;title\u{9C}");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::OSC {
start_sequence: 0,
start_command: 2,
start_terminator: 9,
end: 11,
})
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_7bit_dcs_consumes_body() {
let mut iter = EscapeSequenceOffsetsIterator::new("\x1BP1;0;|payload\x1B\\rest");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown { start: 0, end: 16 })
);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Text { start: 16, end: 20 })
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_8bit_dcs_consumes_body() {
let mut iter = EscapeSequenceOffsetsIterator::new("\u{90}body\u{9C}rest");
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown { start: 0, end: 8 })
);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Text { start: 8, end: 12 })
);
}
#[test]
fn test_escape_sequence_offsets_iterator_truncated_dcs_consumes_to_eof() {
// Unterminated DCS must still be consumed, not emitted as `Text`.
let input = "\x1BPno-terminator";
let mut iter = EscapeSequenceOffsetsIterator::new(input);
assert_eq!(
iter.next(),
Some(EscapeSequenceOffsets::Unknown {
start: 0,
end: input.len(),
})
);
assert_eq!(iter.next(), None);
}
#[test]
fn test_escape_sequence_offsets_iterator_parses_csi() {
let mut iter = EscapeSequenceOffsetsIterator::new("\x1B[m");
@@ -922,10 +1114,10 @@ mod tests {
fn test_sgr_attributes_do_not_leak_into_wrong_field() {
let mut attrs = crate::vscreen::Attributes::new();
// Bold, Dim, Italic, Underline, Foreground, Background
// Bold, Dim, Italic, Underline, Strike, Foreground, Background
attrs.update(EscapeSequence::CSI {
raw_sequence: "\x1B[1;2;3;4;31;41m",
parameters: "1;2;3;4;31;41",
raw_sequence: "\x1B[1;2;3;4;9;31;41m",
parameters: "1;2;3;4;9;31;41",
intermediates: "",
final_byte: "m",
});
@@ -934,6 +1126,7 @@ mod tests {
assert_eq!(attrs.dim, "\x1B[2m");
assert_eq!(attrs.italic, "\x1B[3m");
assert_eq!(attrs.underline, "\x1B[4m");
assert_eq!(attrs.strike, "\x1B[9m");
assert_eq!(attrs.foreground, "\x1B[31m");
assert_eq!(attrs.background, "\x1B[41m");
@@ -950,4 +1143,27 @@ mod tests {
assert_eq!(attrs.foreground, "\x1B[94m");
assert_eq!(attrs.background, "\x1B[103m");
}
#[test]
fn test_sgr_strike_is_tracked_and_cleared() {
let mut attrs = crate::vscreen::Attributes::new();
// Strikethrough on (SGR 9) must be tracked so it can be re-emitted.
attrs.update(EscapeSequence::CSI {
raw_sequence: "\x1B[9m",
parameters: "9",
intermediates: "",
final_byte: "m",
});
assert_eq!(attrs.strike, "\x1B[9m");
// Strikethrough off (SGR 29) must clear it, just like italic (23) and underline (24).
attrs.update(EscapeSequence::CSI {
raw_sequence: "\x1B[29m",
parameters: "29",
intermediates: "",
final_byte: "m",
});
assert_eq!(attrs.strike, "");
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
pub enum WrappingMode {
Character,
Word,
// The bool specifies whether wrapping has been explicitly disabled by the user via --wrap=never
/// The bool specifies whether wrapping has been explicitly disabled by the user via --wrap=never
NoWrapping(bool),
}
+1
View File
@@ -0,0 +1 @@
--show-all
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
NAME="${0##*/}"
for i in {1..3}; do
echo "hello $NAME $i"
done
+777 -8
View File
@@ -25,10 +25,8 @@ mod unix {
use unix::*;
mod utils;
use utils::command::{bat, bat_with_config};
use utils::command::{bat, bat_raw_command, bat_raw_command_with_config, bat_with_config};
#[cfg(unix)]
use utils::command::bat_raw_command;
use utils::mocked_pagers;
const EXAMPLES_DIR: &str = "tests/examples";
@@ -60,6 +58,67 @@ fn stdin() {
.stdout("foo\nbar\n");
}
/// Runs `bat` with `args` while its stdout is a pipe whose read end has
/// already been closed, so the very first write fails with `BrokenPipe`
/// (EPIPE on Unix, `ERROR_NO_DATA` on Windows).
fn assert_quiet_exit_when_stdout_is_closed(command: &mut std::process::Command, args: &[&str]) {
use std::process::Stdio;
let (pipe_reader, pipe_writer) = std::io::pipe().expect("could not create a pipe");
drop(pipe_reader);
let output = command
.args(args)
.stdin(Stdio::null())
.stdout(pipe_writer)
.stderr(Stdio::piped())
.spawn()
.and_then(|child| child.wait_with_output())
.expect("could not run bat");
assert!(
output.status.success(),
"bat {args:?} should exit quietly when stdout is closed, status: {}, stderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr),
);
assert!(
output.stderr.is_empty(),
"bat {args:?} should not print to stderr when stdout is closed, stderr: {}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn auxiliary_output_does_not_panic_on_closed_stdout() {
// Auxiliary outputs used to be written via `println!`, which panics when
// the reader of a piped stdout is already gone (e.g. `bat --config-file |
// head -n 0`). The main output path has exited quietly on `BrokenPipe`
// since #232; auxiliary outputs should behave the same way.
assert_quiet_exit_when_stdout_is_closed(&mut bat_raw_command(), &["--config-file"]);
assert_quiet_exit_when_stdout_is_closed(&mut bat_raw_command(), &["--diagnostic"]);
// The `cache` subcommand is only enabled when the working directory does
// not contain a file named `cache` (see `cache_clear`), and it conflicts
// with top-level flags like `--no-config`. Run it from a temporary
// directory that doubles as `BAT_CACHE_PATH`, leaving the real cache alone.
let temp_dir = tempdir().expect("could not create a temporary directory");
let mut command = bat_raw_command_with_config();
command
.current_dir(temp_dir.path())
.env("BAT_CONFIG_PATH", temp_dir.path().join("config"))
.env("BAT_CACHE_PATH", temp_dir.path());
assert_quiet_exit_when_stdout_is_closed(&mut command, &["cache", "--clear"]);
}
#[cfg(feature = "application")]
#[test]
fn completion_does_not_panic_on_closed_stdout() {
for shell in ["bash", "fish", "ps1", "zsh"] {
assert_quiet_exit_when_stdout_is_closed(&mut bat_raw_command(), &["--completion", shell]);
}
}
#[test]
fn concatenate() {
bat()
@@ -213,6 +272,133 @@ fn numbers_honored_from_cli_when_preceeded_by_plain_in_loop_through_mode() {
.stdout(" 1 line 1\n 2 line 2\n 3 line 3\n 4 line 4\n 5 line 5\n 6 line 6\n 7 line 7\n 8 line 8\n 9 line 9\n 10 line 10\n");
}
#[test]
fn repeated_combined_flags_use_last_number_or_plain_flag() {
bat()
.arg("multiline.txt")
.arg("-npn")
.arg("--decorations=auto")
.assert()
.success()
.stdout(" 1 line 1\n 2 line 2\n 3 line 3\n 4 line 4\n 5 line 5\n 6 line 6\n 7 line 7\n 8 line 8\n 9 line 9\n 10 line 10\n");
bat()
.arg("multiline.txt")
.arg("-pnp")
.arg("--decorations=auto")
.assert()
.success()
.stdout(
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
);
bat()
.arg("empty_lines.txt")
.arg("-pbp")
.arg("--decorations=auto")
.assert()
.success()
.stdout("line 1\n\n\n\nline 5\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nline 20\nline 21\n\n\nline 24\n\nline 26\n\n\n\nline 30\n");
bat()
.arg("empty_lines.txt")
.arg("-bpb")
.arg("--decorations=auto")
.assert()
.success()
.stdout(" 1 line 1\n \n \n \n 2 line 5\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 3 line 20\n 4 line 21\n \n \n 5 line 24\n \n 6 line 26\n \n \n \n 7 line 30\n");
}
#[test]
fn option_terminator_keeps_filename_from_number_flags() {
let tmp_dir = tempdir().expect("can create temporary directory");
std::fs::write(tmp_dir.path().join("-b"), "hello\nworld\n").expect("can write temporary file");
bat()
.current_dir(tmp_dir.path())
.args(["--", "-b"])
.assert()
.success()
.stdout("hello\nworld\n");
}
#[test]
fn attached_short_option_values_are_not_treated_as_flags() {
let tmp_dir = tempdir().expect("can create temporary directory");
std::fs::write(tmp_dir.path().join("input.txt"), "hello\nworld\n")
.expect("can write temporary file");
bat()
.current_dir(tmp_dir.path())
.args(["-lbn", "input.txt"])
.assert()
.success()
.stdout("hello\nworld\n");
}
#[test]
fn number_nonblank_style() {
bat()
.arg("empty_lines.txt")
.arg("-b")
.arg("--decorations=always")
.assert()
.success()
.stdout(" 1 line 1\n \n \n \n 2 line 5\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 3 line 20\n 4 line 21\n \n \n 5 line 24\n \n 6 line 26\n \n \n \n 7 line 30\n");
}
#[test]
fn number_nonblank_from_cli_in_loop_through_mode() {
bat()
.arg("empty_lines.txt")
.arg("-b")
.assert()
.success()
.stdout(" 1 line 1\n \n \n \n 2 line 5\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 3 line 20\n 4 line 21\n \n \n 5 line 24\n \n 6 line 26\n \n \n \n 7 line 30\n");
}
#[test]
fn number_nonblank_takes_precedence_over_number() {
// -bn should behave like -b
bat()
.arg("empty_lines.txt")
.arg("-bn")
.arg("--decorations=always")
.assert()
.success()
.stdout(" 1 line 1\n \n \n \n 2 line 5\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 3 line 20\n 4 line 21\n \n \n 5 line 24\n \n 6 line 26\n \n \n \n 7 line 30\n");
// -nb should also behave like -b
bat()
.arg("empty_lines.txt")
.arg("-nb")
.arg("--decorations=always")
.assert()
.success()
.stdout(" 1 line 1\n \n \n \n 2 line 5\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n 3 line 20\n 4 line 21\n \n \n 5 line 24\n \n 6 line 26\n \n \n \n 7 line 30\n");
}
#[test]
fn number_nonblank_ignored_when_followed_by_plain() {
bat()
.arg("empty_lines.txt")
.arg("-bp")
.arg("--decorations=auto")
.assert()
.success()
.stdout("line 1\n\n\n\nline 5\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nline 20\nline 21\n\n\nline 24\n\nline 26\n\n\n\nline 30\n");
}
#[test]
fn piped_output_with_number_nonblank_flag() {
bat()
.arg("-b")
.write_stdin("hello\n\nworld\n")
.assert()
.success()
.stdout(" 1 hello\n \n 2 world\n");
}
#[test]
fn line_range_2_3() {
bat()
@@ -305,6 +491,19 @@ fn line_range_multiple() {
.stdout("line 1\nline 2\nline 4\n");
}
#[test]
fn snip_at_terminal_width_one_does_not_panic() {
bat()
.arg("multiline.txt")
.arg("--style=snip")
.arg("--color=always")
.arg("--terminal-width=1")
.arg("--line-range=1:2")
.arg("--line-range=4:4")
.assert()
.success();
}
#[test]
fn line_range_multiple_with_context() {
bat()
@@ -506,6 +705,75 @@ fn piped_output_with_default_style_flag() {
);
}
#[test]
fn repeated_boolean_flag_is_accepted() {
// A flag in the config file is prepended to the command line, so a user
// who has `--show-all` in their config and also types `-A` ends up passing
// it twice. That must not be an error.
bat()
.arg("empty_lines.txt")
.arg("--show-all")
.arg("--show-all")
.assert()
.success();
}
#[test]
fn boolean_flag_in_config_and_on_command_line() {
bat_with_config()
.env("BAT_CONFIG_PATH", "bat-show-all.conf")
.arg("empty_lines.txt")
.arg("--show-all")
.arg("--paging=never")
.assert()
.success();
}
#[test]
fn repeated_valued_option_is_accepted() {
// Options that take a value must be repeatable (last value wins) just like
// boolean flags, otherwise a value from the config file cannot be
// overridden on the command line.
for options in [
["--binary=as-text", "--binary=no-printing"],
["--italic-text=always", "--italic-text=never"],
[
"--nonprintable-notation=unicode",
"--nonprintable-notation=caret",
],
["--fallback-syntax=bash", "--fallback-syntax=json"],
["--squeeze-limit=5", "--squeeze-limit=2"],
] {
bat()
.arg("test.txt")
.args(options)
.assert()
.success()
.stdout("hello world\n");
}
}
#[test]
fn valued_option_in_config_can_be_overridden_on_command_line() {
// The config file is prepended to the command line, so a value given in
// both places used to be rejected as "cannot be used multiple times".
let tmp_dir = tempdir().expect("can create temporary directory");
let tmp_config_path = tmp_dir.path().join("config");
std::fs::write(&tmp_config_path, "--binary=as-text").expect("can write config file");
bat_with_config()
.env("BAT_CONFIG_PATH", tmp_config_path.to_str().unwrap())
.arg("test.binary")
.arg("--binary=no-printing")
.arg("--color=always")
.arg("--decorations=never")
.arg("--paging=never")
.assert()
.success()
.stdout(predicate::str::contains("Binary content from"))
.stdout(predicate::str::contains("\0").not());
}
#[test]
fn squeeze_blank() {
bat()
@@ -613,12 +881,18 @@ fn list_themes_to_piped_output() {
}
#[test]
#[serial]
fn list_languages() {
bat()
.arg("--list-languages")
.assert()
.success()
.stdout(predicate::str::contains("Rust").normalize());
mocked_pagers::with_mocked_versions_of_more_and_most_in_path(|| {
bat()
.env("PAGER", mocked_pagers::from("echo pager-output"))
.arg("--list-languages")
.arg("--paging=never")
.assert()
.success()
.stdout(predicate::str::contains("Rust").normalize())
.stdout(predicate::str::contains("pager-output").not());
});
}
#[test]
@@ -766,6 +1040,32 @@ fn bat_error_to_stderr() {
.stderr(predicate::str::contains("[bat error]"));
}
#[cfg(unix)]
#[test]
fn bat_error_respects_no_color() {
bat()
.env("NO_COLOR", "1")
.arg("/tmp")
.assert()
.failure()
.stderr("[bat error]: '/tmp' is a directory.\n");
}
#[test]
fn bat_warning_respects_no_color() {
bat()
.env("NO_COLOR", "1")
.arg("--decorations=always")
.arg("--style=grid,rule")
.arg("--terminal-width=80")
.arg("single-line.txt")
.assert()
.success()
.stderr(
"[bat warning]: Style 'rule' is a subset of style 'grid', 'rule' will not be visible.\n",
);
}
#[cfg(unix)]
#[test]
fn no_args_doesnt_break() {
@@ -1526,6 +1826,7 @@ fn diagnostic_sanity_check() {
.success()
.stdout(predicate::str::contains("BAT_PAGER="))
.stdout(predicate::str::contains("BAT_WIDTH="))
.stdout(predicate::str::contains("BAT_CONFIG_DIR="))
.stderr("");
}
@@ -1556,6 +1857,28 @@ fn help_works_with_invalid_config() {
.stdout(predicate::str::contains("A cat(1) clone with wings"));
}
#[test]
fn config_file_with_utf8_bom() {
let tmp_dir = tempdir().expect("can create temporary directory");
let tmp_config_path = tmp_dir.path().join("config");
for config in [
"\u{feff}--style=numbers\r\n",
"\u{feff}# comment\r\n--style=numbers\r\n",
] {
std::fs::write(&tmp_config_path, config).expect("can write config file");
bat_with_config()
.env("BAT_CONFIG_PATH", tmp_config_path.to_str().unwrap())
.arg("test.txt")
.arg("--decorations=always")
.assert()
.success()
.stdout(" 1 hello world\n")
.stderr("");
}
}
#[test]
fn help_uses_valid_config() {
let tmp_dir = tempdir().expect("can create temporary directory");
@@ -1825,6 +2148,75 @@ fn cache_build() {
assert!(tmp_metadata_path.exists());
}
#[test]
fn cache_auto_build_on_first_run() {
// Custom themes/syntaxes are picked up automatically on first run (see #4017):
// if the config directory contains sources but no cache exists yet, bat
// builds the cache instead of silently using the integrated assets.
let config_dir = tempdir().expect("can create temporary config directory");
let cache_dir = tempdir().expect("can create temporary cache directory");
std::fs::create_dir_all(config_dir.path().join("themes")).expect("can create themes directory");
std::fs::copy(
Path::new(EXAMPLES_DIR).join("cache_source/themes/example.tmTheme"),
config_dir.path().join("themes/example.tmTheme"),
)
.expect("can copy example theme");
std::fs::create_dir_all(config_dir.path().join("syntaxes"))
.expect("can create syntaxes directory");
std::fs::copy(
Path::new(EXAMPLES_DIR).join("cache_source/syntaxes/c.sublime-syntax"),
config_dir.path().join("syntaxes/c.sublime-syntax"),
)
.expect("can copy example syntax");
bat_with_config()
.env("BAT_CONFIG_DIR", config_dir.path())
.env("BAT_CACHE_PATH", cache_dir.path())
.arg("--no-config")
.arg("--paging=never")
.arg("--color=never")
.arg("--list-themes")
.assert()
.success()
.stderr(predicate::str::contains("building cache"))
.stdout(predicate::str::contains("example"));
assert!(cache_dir.path().join("themes.bin").exists());
assert!(cache_dir.path().join("syntaxes.bin").exists());
assert!(cache_dir.path().join("metadata.yaml").exists());
}
#[test]
fn cache_no_auto_build_with_no_custom_assets() {
// `--no-custom-assets` opts out of the automatic cache build (see #4017).
let config_dir = tempdir().expect("can create temporary config directory");
let cache_dir = tempdir().expect("can create temporary cache directory");
std::fs::create_dir_all(config_dir.path().join("themes")).expect("can create themes directory");
std::fs::copy(
Path::new(EXAMPLES_DIR).join("cache_source/themes/example.tmTheme"),
config_dir.path().join("themes/example.tmTheme"),
)
.expect("can copy example theme");
bat_with_config()
.env("BAT_CONFIG_DIR", config_dir.path())
.env("BAT_CACHE_PATH", cache_dir.path())
.arg("--no-config")
.arg("--no-custom-assets")
.arg("--paging=never")
.arg("--color=never")
.arg("--list-themes")
.assert()
.success()
.stderr(predicate::str::contains("building cache").not())
.stdout(predicate::str::contains("example").not());
assert!(!cache_dir.path().join("themes.bin").exists());
assert!(!cache_dir.path().join("metadata.yaml").exists());
}
#[test]
fn utf16() {
// The output will be converted to UTF-8 with the leading UTF-16
@@ -1872,6 +2264,19 @@ fn utf16be() {
.stdout(" 1 上一伊刀\n 2 foo bar\n 3 hello world\n");
}
#[test]
fn utf16be_first_line_not_merged_with_second() {
// The first line of a UTF-16BE file must not swallow the second line
bat()
.arg("--decorations=always")
.arg("--style=numbers")
.arg("--color=never")
.arg("test_UTF-16BE.txt")
.assert()
.success()
.stdout(" 1 hello world\n 2 this is a test\n");
}
// Regression test for https://github.com/sharkdp/bat/issues/1922
#[test]
fn bom_not_stripped_in_loop_through_mode() {
@@ -2145,6 +2550,26 @@ fn header_binary() {
.stderr("");
}
// Regression test for https://github.com/sharkdp/bat/issues/3554
#[test]
fn header_binary_with_null_after_first_line() {
let tmp_dir = tempdir().expect("can create temporary directory");
let tmp_path = tmp_dir.path().join("encrypted.gpg");
std::fs::write(&tmp_path, b"packet-header\npayload\0bytes\n")
.expect("can write temporary file");
bat()
.arg(&tmp_path)
.arg("--decorations=always")
.arg("--style=header")
.arg("--line-range=0:0")
.arg("--file-name=encrypted.gpg")
.assert()
.success()
.stdout("File: encrypted.gpg <BINARY>\n")
.stderr("");
}
#[test]
fn header_zip_file_is_binary() {
let tmp_dir = tempdir().expect("can create temporary directory");
@@ -2540,6 +2965,145 @@ fn no_first_line_fallback_when_mapping_to_invalid_syntax() {
.stderr(predicate::str::contains("unknown syntax: 'InvalidSyntax'"));
}
#[test]
fn stdin_detects_bash_from_first_line() {
let content = "#!/bin/bash\necho hi\n";
let detected_output = bat()
.arg("--color=always")
.arg("--style=plain")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
let explicit_output = bat()
.arg("--color=always")
.arg("--style=plain")
.arg("--language=bash")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
from_utf8(&detected_output).expect("output is valid utf-8"),
from_utf8(&explicit_output).expect("output is valid utf-8")
);
}
#[test]
fn stdin_detects_diff_from_first_line() {
let content = "diff --git a/x b/y\n--- a/x\n+++ b/y\n@@ -1 +1 @@\n-old\n+new\n";
let detected_output = bat()
.arg("--color=always")
.arg("--style=plain")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
let explicit_output = bat()
.arg("--color=always")
.arg("--style=plain")
.arg("--language=diff")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
from_utf8(&detected_output).expect("output is valid utf-8"),
from_utf8(&explicit_output).expect("output is valid utf-8")
);
}
#[test]
fn empty_stdin_does_not_detect_syntax() {
bat()
.arg("--color=always")
.arg("--style=plain")
.write_stdin("")
.assert()
.success()
.stdout("")
.stderr("");
}
#[test]
fn binary_stdin_does_not_detect_syntax_from_invalid_utf8_first_line() {
let content = b"#!/bin/bash\xff\necho hi\n";
let detected_output = bat()
.arg("--binary=as-text")
.arg("--color=always")
.arg("--style=plain")
.write_stdin(content.as_slice())
.assert()
.success()
.get_output()
.stdout
.clone();
let plain_text_output = bat()
.arg("--binary=as-text")
.arg("--color=always")
.arg("--style=plain")
.arg("--language=txt")
.write_stdin(content.as_slice())
.assert()
.success()
.get_output()
.stdout
.clone();
assert_eq!(
from_utf8(&detected_output).expect("output is valid utf-8"),
from_utf8(&plain_text_output).expect("output is valid utf-8")
);
}
#[test]
fn explicit_language_overrides_stdin_first_line_detection() {
let content = "diff --git a/x b/y\n--- a/x\n+++ b/y\n@@ -1 +1 @@\n-old\n+new\n";
let detected_output = bat()
.arg("--color=always")
.arg("--style=plain")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
let explicit_output = bat()
.arg("--color=always")
.arg("--style=plain")
.arg("--language=json")
.arg("-")
.write_stdin(content)
.assert()
.success()
.get_output()
.stdout
.clone();
assert_ne!(
from_utf8(&detected_output).expect("output is valid utf-8"),
from_utf8(&explicit_output).expect("output is valid utf-8")
);
}
#[test]
fn fallback_syntax_is_used_when_no_syntax_is_detected() {
let content = "# comment\nfoo=bar\n";
@@ -3026,6 +3590,25 @@ fn ansi_sgr_joins_attributes_when_wrapped() {
.stderr("");
}
// Ensure that strikethrough (SGR 9) is tracked like the other attributes, so it is
// re-emitted (and therefore preserved) across a wrap boundary instead of being dropped.
#[test]
fn ansi_sgr_strike_joins_attributes_when_wrapped() {
bat()
.arg("--paging=never")
.arg("--color=never")
.arg("--terminal-width=20")
.arg("--wrap=character")
.arg("--decorations=always")
.arg("--style=plain")
.write_stdin("\x1B[33mColor. \x1B[9mStrike.......Struck+color.\n")
.assert()
.success()
.stdout("\x1B[33m\x1B[33mColor. \x1B[9m\x1B[33m\x1B[9mStrike.......\n\x1B[33m\x1B[9mStruck+color.\n")
// FIXME: ~~~~~~~~ ~~~~~~~~~~~~~~~ should not be emitted twice.
.stderr("");
}
#[test]
fn ignored_suffix_arg() {
bat()
@@ -3831,6 +4414,158 @@ fn strip_ansi_auto_does_not_strip_ansi_when_plain_text_by_option() {
assert!(output.contains("\x1B[33mYellow"))
}
#[test]
fn sanitize_implies_strip_ansi() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("\x1B[33mYellow\x1B[m")
.assert()
.success()
.stdout("Yellow");
}
#[test]
fn sanitize_strips_osc_clipboard_hijack() {
// OSC 52 sets the system clipboard. A file containing this would silently
// overwrite the user's clipboard if displayed unfiltered.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("safe\x1B]52;c;cm0=\x07payload")
.assert()
.success()
.stdout("safepayload");
}
#[test]
fn sanitize_strips_osc_8_hyperlink_spoof() {
// OSC 8 hyperlinks let displayed text point to an arbitrary URL.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("\x1B]8;;https://evil.example\x07click here\x1B]8;;\x07")
.assert()
.success()
.stdout("click here");
}
#[test]
fn sanitize_strips_window_title_injection() {
// OSC 0/1/2 set the terminal window title.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("hello\x1B]0;evil-title\x07world")
.assert()
.success()
.stdout("helloworld");
}
#[test]
fn sanitize_strips_8bit_csi() {
// 8-bit CSI introducer (U+009B) is the single-codepoint equivalent of ESC [.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("a\u{9B}31mRED\u{9B}0mb")
.assert()
.success()
.stdout("aREDb");
}
#[test]
fn sanitize_substitutes_bare_cr() {
// Bare CR (not part of CRLF) is the line-overwrite forgery vector.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("safe\rEVIL")
.assert()
.success()
.stdout("safe\u{FFFD}EVIL");
}
#[test]
fn sanitize_preserves_crlf() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("line1\r\nline2\r\n")
.assert()
.success()
.stdout("line1\r\nline2\r\n");
}
#[test]
fn sanitize_substitutes_bidi_controls() {
// Trojan-Source attack (CVE-2021-42574): U+202E (RLO) reorders display.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("admin\u{202E}check")
.assert()
.success()
.stdout("admin\u{FFFD}check");
}
#[test]
fn sanitize_substitutes_zero_width() {
// Zero-width chars allow invisible content / identifier confusion.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("ad\u{200B}min")
.assert()
.success()
.stdout("ad\u{FFFD}min");
}
#[test]
fn sanitize_preserves_form_feed_in_source() {
// FF (U+000C) is used as a section separator in C source and Emacs Lisp.
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("section1\x0Csection2")
.assert()
.success()
.stdout("section1\x0Csection2");
}
#[test]
fn sanitize_preserves_unicode_text() {
bat()
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("--sanitize=always")
.write_stdin("snowman ☃ CJK 漢字 emoji 🦀")
.assert()
.success()
.stdout("snowman ☃ CJK 漢字 emoji 🦀");
}
// Tests that style components can be removed with `-component`.
#[test]
fn style_components_can_be_removed() {
@@ -4235,3 +4970,37 @@ fn tcl_shebang_detection_expect() {
.assert()
.success();
}
#[test]
fn ignored_suffix_enables_first_line_detection() {
// A shebang shell script saved with a `.txt` extension is Plain Text by
// default (the extension wins). With `--ignored-suffix .txt` the suffix is
// stripped before detection, so it falls back to the first line and is
// highlighted exactly as if its language were forced to bash. See #2745.
let fixture = "regression_tests/issue_2745.txt";
let common = ["--color=always", "--decorations=never", "--style=plain"];
let stdout = |args: &[&str]| -> Vec<u8> {
let assert = bat()
.args(common)
.args(args)
.arg(fixture)
.assert()
.success();
assert.get_output().stdout.clone()
};
let forced_bash = stdout(&["--language", "bash"]);
let with_ignored_suffix = stdout(&["--ignored-suffix", ".txt"]);
let default = stdout(&[]);
// The fixture really is being highlighted (forcing bash is not a no-op).
assert!(
forced_bash.windows(2).any(|w| w == b"\x1b["),
"forced-bash output should contain ANSI color codes"
);
// With the ignored suffix, detection matches forced bash highlighting...
assert_eq!(with_ignored_suffix, forced_bash);
// ...while the default (extension wins) stays plain and differs.
assert_ne!(default, forced_bash);
}
+25
View File
@@ -0,0 +1,25 @@
(logging) {
 log {
 output file /var/log/caddy.log
 }
}
# a comment
localhost:8080, example.com {
 root * /var/www/site
 file_server
 reverse_proxy /.well-known/matrix/* localhost:8008 {
 header_up Host {upstream_hostport}
 }
 import logging
}
www.example.com {
 redir https://example.com{uri} permanent
 import logging
}
status.example.com {
 reverse_proxy localhost:3002
 import logging
}
@@ -0,0 +1,5 @@
{a
 /* comment */
}
{a /* comment */}
+25
View File
@@ -0,0 +1,25 @@
(logging) {
log {
output file /var/log/caddy.log
}
}
# a comment
localhost:8080, example.com {
root * /var/www/site
file_server
reverse_proxy /.well-known/matrix/* localhost:8008 {
header_up Host {upstream_hostport}
}
import logging
}
www.example.com {
redir https://example.com{uri} permanent
import logging
}
status.example.com {
reverse_proxy localhost:3002
import logging
}
@@ -0,0 +1,5 @@
{a
/* comment */
}
{a /* comment */}
File renamed without changes.
+37 -28
View File
@@ -1,15 +1,17 @@
use gix::actor::SignatureRef;
use gix::bstr::BString;
use gix::bstr::ByteSlice;
use gix::date::time::Format;
use gix::date::Time;
use gix::objs::tree;
use std::env;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
use git2::build::CheckoutBuilder;
use git2::Repository;
use git2::Signature;
pub struct BatTester {
/// Temporary working directory
temp_dir: TempDir,
@@ -59,35 +61,42 @@ impl Default for BatTester {
}
}
fn create_sample_directory() -> Result<TempDir, git2::Error> {
fn create_sample_directory() -> Result<TempDir, Box<dyn std::error::Error>> {
// Create temp directory and initialize repository
let temp_dir = TempDir::new().expect("Temp directory");
let repo = Repository::init(&temp_dir)?;
let repo = gix::init(&temp_dir)?;
let mut tree = gix::objs::Tree::empty();
// Copy over `sample.rs`
let sample_path = temp_dir.path().join("sample.rs");
println!("{sample_path:?}");
fs::copy("tests/snapshots/sample.rs", &sample_path).expect("successful copy");
// Create sample.rs from snapshot file
let blob_id = repo.write_blob_stream(File::open("tests/snapshots/sample.rs")?)?;
let entry = tree::Entry {
mode: tree::EntryMode::from(tree::EntryKind::Blob),
oid: blob_id.object()?.id,
filename: BString::from("sample.rs"),
};
tree.entries.push(entry);
let tree_id = repo.write_object(tree)?;
// Commit
let mut index = repo.index()?;
index.add_path(Path::new("sample.rs"))?;
let oid = index.write_tree()?;
let signature = Signature::now("bat test runner", "bat@test.runner")?;
let tree = repo.find_tree(oid)?;
let _ = repo.commit(
Some("HEAD"), // point HEAD to our new commit
&signature, // author
&signature, // committer
let author = SignatureRef {
name: "test".as_bytes().as_bstr(),
email: "test@test.test".as_bytes().as_bstr(),
time: &Time::now_local_or_utc().format_or_unix(Format::Raw),
};
let commit_id = repo.commit_as(
author,
author,
"HEAD",
"initial commit",
&tree,
&[],
);
let mut opts = CheckoutBuilder::new();
repo.checkout_head(Some(opts.force()))?;
tree_id,
gix::commit::NO_PARENT_IDS,
)?;
assert_eq!(commit_id, repo.head_id()?);
fs::copy("tests/snapshots/sample.modified.rs", &sample_path).expect("successful copy");
fs::copy(
"tests/snapshots/sample.modified.rs",
temp_dir.path().join("sample.rs"),
)
.expect("successful copy");
Ok(temp_dir)
}