From 530e83a45506a4792e788944a75a2e89b82518ab Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 16 May 2026 13:51:00 -0700 Subject: [PATCH 01/15] 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, Result>> { 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 })) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index cfbad253..9c7cc285 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2470,6 +2470,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"; From db69aab106ed01d78c788f5961daed1888e91a24 Mon Sep 17 00:00:00 2001 From: Matei6942 Date: Sun, 2 Aug 2026 10:52:05 +0300 Subject: [PATCH 02/15] fix(syntax): avoid repeated log level scans --- assets/syntaxes/02_Extra/log.sublime-syntax | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/assets/syntaxes/02_Extra/log.sublime-syntax b/assets/syntaxes/02_Extra/log.sublime-syntax index 0ce01f10..acd14216 100644 --- a/assets/syntaxes/02_Extra/log.sublime-syntax +++ b/assets/syntaxes/02_Extra/log.sublime-syntax @@ -38,19 +38,20 @@ contexts: scope: markup.underline.link.scheme.log push: url-host log_level_lines: - - match: (?=.*{{error}}) + # Prevent retrying each lookahead at every position. + - match: \G(?=.*{{error}}) push: - error_line_meta - main_pop_at_eol - - match: (?=.*{{warning}}) + - match: \G(?=.*{{warning}}) push: - warning_line_meta - main_pop_at_eol - - match: (?=.*{{info}}) + - match: \G(?=.*{{info}}) push: - info_line_meta - main_pop_at_eol - - match: (?=.*{{debug}}) + - match: \G(?=.*{{debug}}) push: - debug_line_meta - main_pop_at_eol From 531d54b110d9b2311ab852e728ad1448d808ae03 Mon Sep 17 00:00:00 2001 From: Matei6942 Date: Sun, 2 Aug 2026 10:52:39 +0300 Subject: [PATCH 03/15] docs: add changelog entry for #3876 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0abc18..a2819429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- 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) From a58f23724a84bab02dbacf4e573fe702d7f84fe8 Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:05:20 +1200 Subject: [PATCH 04/15] Fix doubled word in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9eb4c095..8826d4e0 100644 --- a/README.md +++ b/README.md @@ -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. From 3ca4a01be6bef5ba7c32c4cacc285e60bf66426e Mon Sep 17 00:00:00 2001 From: Matei6942 Date: Mon, 10 Aug 2026 20:00:44 +0300 Subject: [PATCH 05/15] fix(syntax): use start-of-line anchor for log levels --- assets/syntaxes/02_Extra/log.sublime-syntax | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/syntaxes/02_Extra/log.sublime-syntax b/assets/syntaxes/02_Extra/log.sublime-syntax index acd14216..5a4a0e06 100644 --- a/assets/syntaxes/02_Extra/log.sublime-syntax +++ b/assets/syntaxes/02_Extra/log.sublime-syntax @@ -39,19 +39,19 @@ contexts: push: url-host log_level_lines: # Prevent retrying each lookahead at every position. - - match: \G(?=.*{{error}}) + - match: ^(?=.*{{error}}) push: - error_line_meta - main_pop_at_eol - - match: \G(?=.*{{warning}}) + - match: ^(?=.*{{warning}}) push: - warning_line_meta - main_pop_at_eol - - match: \G(?=.*{{info}}) + - match: ^(?=.*{{info}}) push: - info_line_meta - main_pop_at_eol - - match: \G(?=.*{{debug}}) + - match: ^(?=.*{{debug}}) push: - debug_line_meta - main_pop_at_eol From cecbb80093a42690f8f432c37b5c279af2214615 Mon Sep 17 00:00:00 2001 From: Matei6942 Date: Mon, 10 Aug 2026 20:56:35 +0300 Subject: [PATCH 06/15] fix(syntax): preserve log-level scopes in Syslog --- assets/syntaxes/02_Extra/log.sublime-syntax | 18 ++++++++++++++++++ assets/syntaxes/02_Extra/syslog.sublime-syntax | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/assets/syntaxes/02_Extra/log.sublime-syntax b/assets/syntaxes/02_Extra/log.sublime-syntax index 5a4a0e06..329da39d 100644 --- a/assets/syntaxes/02_Extra/log.sublime-syntax +++ b/assets/syntaxes/02_Extra/log.sublime-syntax @@ -55,6 +55,24 @@ contexts: 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 diff --git a/assets/syntaxes/02_Extra/syslog.sublime-syntax b/assets/syntaxes/02_Extra/syslog.sublime-syntax index 01ed58db..7cc9580b 100644 --- a/assets/syntaxes/02_Extra/syslog.sublime-syntax +++ b/assets/syntaxes/02_Extra/syslog.sublime-syntax @@ -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 From 061fb216cb08a2f3755ca411fd14f0b28730afd0 Mon Sep 17 00:00:00 2001 From: MeGaurav4 Date: Fri, 24 Jul 2026 22:26:50 +0530 Subject: [PATCH 07/15] 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. --- CHANGELOG.md | 1 + doc/long-help.txt | 10 ++++++ doc/short-help.txt | 2 ++ src/bin/bat/app.rs | 40 +++++++++++++++++++++++- src/bin/bat/clap_app.rs | 18 +++++++++++ src/config.rs | 4 +++ src/controller.rs | 19 +++++++++--- src/decorations.rs | 8 +++++ src/printer.rs | 9 +++++- tests/integration_tests.rs | 63 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 168 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2fc216..f9ffec2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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=` 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) diff --git a/doc/long-help.txt b/doc/long-help.txt index 5b549e07..b4b07107 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -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 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 diff --git a/doc/short-help.txt b/doc/short-help.txt index e08bb604..ae433a8f 100644 --- a/doc/short-help.txt +++ b/doc/short-help.txt @@ -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 to use colors (*auto*, never, always). --italic-text diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index cc37075c..7b738c35 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -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 { @@ -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 + 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 + }); + 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::("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]); diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index decd0364..7f76f51b 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -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") diff --git a/src/config.rs b/src/config.rs index 121097d9..9f78244e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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"))] diff --git a/src/controller.rs b/src/controller.rs index 8f64e8c5..d9ed4b79 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -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; diff --git a/src/decorations.rs b/src/decorations.rs index 5b7846c3..cb0e5eaa 100644 --- a/src/decorations.rs +++ b/src/decorations.rs @@ -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; diff --git a/src/printer.rs b/src/printer.rs index 42405902..93a79e5f 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -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)?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 33de3fbf..2a100ef2 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -213,6 +213,69 @@ 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 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() From b8430080f071a8cc9853b12dd4ea6234ba8d8357 Mon Sep 17 00:00:00 2001 From: cuishuang Date: Fri, 28 Aug 2026 22:21:23 +0800 Subject: [PATCH 08/15] fix: use last occurrence in combined short flags Signed-off-by: cuishuang --- src/bin/bat/app.rs | 14 +++++++------- tests/integration_tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 7b738c35..642f8c08 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -85,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 = 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) @@ -112,11 +112,11 @@ impl App { if arg_str == "-b" || arg_str == "--number-nonblank" { return true; } - // Handle combined short flags + // 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 = arg_str.chars().skip(1).collect(); - let b_pos = chars.iter().position(|&c| c == 'b'); - let p_pos = chars.iter().position(|&c| c == 'p'); + 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; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index ceec7867..260097e9 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -213,6 +213,43 @@ 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() From 1cedc1b4c3fa3b9869152480d72f22f575d85850 Mon Sep 17 00:00:00 2001 From: cuishuang Date: Fri, 28 Aug 2026 22:23:39 +0800 Subject: [PATCH 09/15] fix: use last occurrence in combined short flags Signed-off-by: cuishuang --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac77a75a..2b903cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- Use the last occurrence of numbering and plain flags in combined short arguments, see #3897 (@cuishuang) - 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) From 400c5dc6f1b3c0e348e413cc5404dee41c0ec596 Mon Sep 17 00:00:00 2001 From: Abhishek Murthy <20264867+rootsec1@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:47:24 +0000 Subject: [PATCH 10/15] fix: avoid parsing syntax fixture as Cargo manifest --- CHANGELOG.md | 1 + tests/syntax-tests/highlighted/TOML/{Cargo.toml => example.toml} | 0 tests/syntax-tests/source/TOML/{Cargo.toml => example.toml} | 0 3 files changed, 1 insertion(+) rename tests/syntax-tests/highlighted/TOML/{Cargo.toml => example.toml} (100%) rename tests/syntax-tests/source/TOML/{Cargo.toml => example.toml} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac77a75a..52209527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes +- Avoid a spurious Cargo manifest error during installation from Git, see #PR_NUMBER (@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/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 From 7892a8754be3cc3fda6c583ad8fb68b21faa5b99 Mon Sep 17 00:00:00 2001 From: Abhishek Murthy <20264867+rootsec1@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:06:25 +0000 Subject: [PATCH 11/15] chore: add pull request number to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52209527..967fef0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ - Syntax highlighting for Python files using uv as script runner in shebang #3689 (@janlarres) ## Bugfixes -- Avoid a spurious Cargo manifest error during installation from Git, see #PR_NUMBER (@rootsec1) +- 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) From b3c9516a9bc5f9d620614cd707016e54fe8dc7aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:03:42 +0000 Subject: [PATCH 12/15] 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] --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f04a37ce..464bbf17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -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", From 2da852dc78e2decc66ec60cfb2cd36e10ad208b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:29:34 +0000 Subject: [PATCH 13/15] 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] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 464bbf17..020a2407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", From 7928b4be52cb9192b04fa95567882604df873d60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:54:28 +0000 Subject: [PATCH 14/15] 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] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 020a2407..d3258ad4 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" From b5c8e101f8d421fd3fe15cd1bed6299ea7001fb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:15:44 +0000 Subject: [PATCH 15/15] 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] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3258ad4..fa7800ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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",