diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc0a086..9cce4d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,11 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- 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) +- 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) diff --git a/Cargo.lock b/Cargo.lock index f04a37ce..fa7800ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 7b738c35..feb2e11e 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -72,59 +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 = 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 - }); - - // 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 - if arg_str.starts_with('-') && !arg_str.starts_with("--") && arg_str.len() > 2 { - let chars: Vec = arg_str.chars().skip(1).collect(); - let b_pos = chars.iter().position(|&c| c == 'b'); - let p_pos = chars.iter().position(|&c| c == 'p'); - if let Some(b) = b_pos { - if p_pos.is_none() || b > 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)?; @@ -238,6 +191,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 { // Check if we should skip config file processing for special arguments // that don't require full application setup (version, diagnostic) diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index 7f76f51b..9d57fc92 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -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, ..).") @@ -171,6 +172,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.") @@ -235,6 +237,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'."), ) @@ -473,6 +476,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.") @@ -598,6 +602,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( @@ -619,6 +624,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"), @@ -699,6 +705,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( @@ -717,6 +724,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."), diff --git a/src/input.rs b/src/input.rs index facd64bf..d79c8198 100644 --- a/src/input.rs +++ b/src/input.rs @@ -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,10 +265,29 @@ impl<'a> InputReader<'a> { } pub(crate) fn try_new(mut reader: R) -> io::Result> { - 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)?; @@ -410,6 +431,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 { + 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; diff --git a/tests/examples/bat-show-all.conf b/tests/examples/bat-show-all.conf new file mode 100644 index 00000000..e5dd9b4e --- /dev/null +++ b/tests/examples/bat-show-all.conf @@ -0,0 +1 @@ +--show-all diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index ceec7867..3f15f281 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -213,6 +213,70 @@ 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() @@ -582,6 +646,30 @@ 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 squeeze_blank() { bat() @@ -2227,6 +2315,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 \n") + .stderr(""); +} + #[test] fn header_zip_file_is_binary() { let tmp_dir = tempdir().expect("can create temporary directory"); diff --git a/tests/syntax-tests/highlighted/TOML/Cargo.toml b/tests/syntax-tests/highlighted/TOML/example.toml similarity index 100% rename from tests/syntax-tests/highlighted/TOML/Cargo.toml rename to tests/syntax-tests/highlighted/TOML/example.toml diff --git a/tests/syntax-tests/source/TOML/Cargo.toml b/tests/syntax-tests/source/TOML/example.toml similarity index 100% rename from tests/syntax-tests/source/TOML/Cargo.toml rename to tests/syntax-tests/source/TOML/example.toml