Merge branch 'master' into fix-binary-detection-across-lines-3554

This commit is contained in:
Keith Hall
2026-09-03 21:54:06 +03:00
committed by GitHub
17 changed files with 398 additions and 28 deletions
+4
View File
@@ -9,6 +9,7 @@
## Features
- 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)
- Preserve `--diff` change markers and snip separators when `--plain` is set. Closes #3630, see #3643 (@mvanhorn)
@@ -25,6 +26,9 @@
## Bugfixes
- 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)
Generated
+10 -10
View File
@@ -281,9 +281,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bytesize"
version = "2.4.2"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e"
checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b"
[[package]]
name = "cc"
@@ -362,9 +362,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "console"
version = "0.16.3"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
@@ -1604,9 +1604,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
dependencies = [
"aho-corasick",
"bstr",
@@ -1705,9 +1705,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.14.0"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
@@ -2196,9 +2196,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.16"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
+1 -1
View File
@@ -902,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.
+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
+10
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
+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>
+2 -2
View File
@@ -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 }))
+43 -5
View File
@@ -59,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 {
@@ -81,12 +85,12 @@ impl App {
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
// Only count -n if its last occurrence comes after the last -p,
// 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');
let n_pos = chars.iter().rposition(|&c| c == 'n');
let p_pos = chars.iter().rposition(|&c| c == 'p');
// -n is in the combined flag and either:
// - -p is not present, OR
// - -n comes after -p (so -n takes effect)
@@ -99,6 +103,29 @@ impl App {
false
});
// Check if the -b / --number-nonblank option was passed on the command line
// (before merging with config file and environment variables).
// This is needed to honor the -b flag when piping output, similar to `cat -b`.
// The same combined-flag logic applies as for -n above.
let number_nonblank_from_cli = wild::args_os().any(|arg| {
let arg_str = arg.to_string_lossy();
if arg_str == "-b" || arg_str == "--number-nonblank" {
return true;
}
// Handle combined short flags by comparing the last -b and -p occurrences.
if arg_str.starts_with('-') && !arg_str.starts_with("--") && arg_str.len() > 2 {
let chars: Vec<char> = arg_str.chars().skip(1).collect();
let b_pos = chars.iter().rposition(|&c| c == 'b');
let p_pos = chars.iter().rposition(|&c| c == 'p');
if let Some(b) = b_pos {
if p_pos.is_none() || b > p_pos.unwrap() {
return true;
}
}
}
false
});
let matches = Self::matches(interactive_output)?;
if matches.get_flag("help") {
@@ -139,6 +166,7 @@ impl App {
matches,
interactive_output,
number_from_cli,
number_nonblank_from_cli,
})
}
@@ -454,7 +482,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")
@@ -495,6 +524,8 @@ impl App {
),
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")
@@ -613,6 +644,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]);
+18
View File
@@ -97,6 +97,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)
@@ -277,6 +278,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")
+4
View File
@@ -119,6 +119,10 @@ pub struct Config<'a> {
/// 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;
+8 -1
View File
@@ -738,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)?;
+239
View File
@@ -213,6 +213,106 @@ 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 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()
@@ -2579,6 +2679,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";