diff --git a/assets/completions/_bat.ps1.in b/assets/completions/_bat.ps1.in index db10302a..80eb6654 100644 --- a/assets/completions/_bat.ps1.in +++ b/assets/completions/_bat.ps1.in @@ -173,7 +173,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script # [CompletionResult]::new('-L' , 'L' , [CompletionResultType]::ParameterName, 'Display all supported languages.') [CompletionResult]::new('--list-languages' , 'list-languages' , [CompletionResultType]::ParameterName, 'Display all supported languages.') # [CompletionResult]::new('-u' , 'u' , [CompletionResultType]::ParameterName, 'u') - [CompletionResult]::new('--unbuffered' , 'unbuffered' , [CompletionResultType]::ParameterName, 'unbuffered') + [CompletionResult]::new('--unbuffered' , 'unbuffered' , [CompletionResultType]::ParameterName, 'Enable unbuffered input reading for streaming use cases') [CompletionResult]::new('--completion' , 'completion' , [CompletionResultType]::ParameterName, 'Show shell completion for a certain shell. [possible values: bash, fish, zsh, ps1]') [CompletionResult]::new('--no-config' , 'no-config' , [CompletionResultType]::ParameterName, 'Do not use the configuration file') [CompletionResult]::new('--no-custom-assets' , 'no-custom-assets' , [CompletionResultType]::ParameterName, 'Do not load custom assets') diff --git a/assets/completions/bat.bash.in b/assets/completions/bat.bash.in index 7593df8f..2cc360ab 100644 --- a/assets/completions/bat.bash.in +++ b/assets/completions/bat.bash.in @@ -190,8 +190,8 @@ _bat() { $split && return 0 if [[ $cur == -* ]]; then - # --unbuffered excluded intentionally (no-op) COMPREPLY=($(compgen -W " + --unbuffered --show-all --nonprintable-notation --binary diff --git a/assets/completions/bat.fish.in b/assets/completions/bat.fish.in index 21c07adf..57f3cd7d 100644 --- a/assets/completions/bat.fish.in +++ b/assets/completions/bat.fish.in @@ -239,7 +239,7 @@ complete -c $bat -l theme-dark -x -a "(command $bat --list-themes | command cat) 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 -s u -l unbuffered -d "This option exists for POSIX-compliance reasons" -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 complete -c $bat -s V -l version -f -d "Show version information" -n __fish_is_first_arg diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index da9a66e6..536cd8df 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -58,7 +58,7 @@ _{{PROJECT_EXECUTABLE}}_main() { 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' '(* -)'{-L,--list-languages}'[display all supported languages]' - '(-u --unbuffered)'--unbuffered'[this option exists for POSIX-compliance reasons]' + '(-u --unbuffered)'--unbuffered'[enable unbuffered input reading for streaming use cases]' --completion='[show shell completion for a certain shell]:shell:(bash fish zsh ps1)' --set-terminal-title'[sets terminal title to filenames when using a pager]' --diagnostic'[show diagnostic information for bug reports]' diff --git a/assets/manual/bat.1.in b/assets/manual/bat.1.in index ff4cb76f..e35b10f9 100644 --- a/assets/manual/bat.1.in +++ b/assets/manual/bat.1.in @@ -263,8 +263,10 @@ Display a list of supported languages for syntax highlighting. .HP \fB\-u\fR, \fB\-\-unbuffered\fR .IP -This option exists for POSIX\-compliance reasons ('u' is for 'unbuffered'). The output is -always unbuffered \- this option is simply ignored. +Enable unbuffered input reading. When this flag is set, bat will display data as soon as it +is available, without waiting for a complete line. This is useful for streaming use cases like +\&'tail \-f logfile | bat \-u \-\-paging=never'. Note that line numbers are automatically disabled +in unbuffered mode, and syntax highlighting may be imperfect on partial lines. .HP \fB\-\-no\-custom\-assets\fR .IP diff --git a/doc/long-help.txt b/doc/long-help.txt index 994a3810..d00c84e9 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -203,8 +203,11 @@ Options: Display a list of supported languages for syntax highlighting. -u, --unbuffered - This option exists for POSIX-compliance reasons ('u' is for 'unbuffered'). The output is - always unbuffered - this option is simply ignored. + Enable unbuffered input reading. When this flag is set, bat will display data as soon as + it is available, without waiting for a complete line. This is useful for streaming use + cases like 'tail -f logfile | bat -u --paging=never'. Note that line numbers are + automatically disabled in unbuffered mode, and syntax highlighting may be imperfect on + partial lines. --completion Show shell completion for a certain shell. [possible values: bash, fish, zsh, ps1] diff --git a/doc/short-help.txt b/doc/short-help.txt index 41a0fdee..04ca57a6 100644 --- a/doc/short-help.txt +++ b/doc/short-help.txt @@ -58,6 +58,8 @@ Options: Only print the lines from N to M. -L, --list-languages Display all supported languages. + -u, --unbuffered + Enable unbuffered input reading for streaming use cases. --completion Show shell completion for a certain shell. [possible values: bash, fish, zsh, ps1] -E, --quiet-empty diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index f5f59ec7..521674f2 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -462,6 +462,7 @@ impl App { _ => unreachable!("other values for --strip-ansi are not allowed"), }, quiet_empty: self.matches.get_flag("quiet-empty"), + unbuffered: self.matches.get_flag("unbuffered"), theme: theme(self.theme_options()).to_string(), visible_lines: match self.matches.try_contains_id("diff").unwrap_or_default() && self.matches.get_flag("diff") @@ -619,6 +620,11 @@ impl App { bat_warning!("Style 'rule' is a subset of style 'grid', 'rule' will not be visible."); } + // Auto-disable line numbers in unbuffered mode to avoid confusion with partial lines + if self.matches.get_flag("unbuffered") { + styled_components.0.remove(&StyleComponent::LineNumbers); + } + Ok(styled_components) } diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index 8ee3604e..d286904a 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -548,11 +548,14 @@ pub fn build_app(interactive_output: bool) -> Command { .short('u') .long("unbuffered") .action(ArgAction::SetTrue) - .hide_short_help(true) + .help("Enable unbuffered input reading for streaming use cases.") .long_help( - "This option exists for POSIX-compliance reasons ('u' is for \ - 'unbuffered'). The output is always unbuffered - this option \ - is simply ignored.", + "Enable unbuffered input reading. When this flag is set, bat will \ + display data as soon as it is available, without waiting for a \ + complete line. This is useful for streaming use cases like \ + 'tail -f logfile | bat -u --paging=never'. Note that line numbers \ + are automatically disabled in unbuffered mode, and syntax \ + highlighting may be imperfect on partial lines.", ), ) .arg( diff --git a/src/config.rs b/src/config.rs index 7209c2fd..8ea0e275 100644 --- a/src/config.rs +++ b/src/config.rs @@ -110,6 +110,9 @@ pub struct Config<'a> { /// 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, } #[cfg(all(feature = "minimal-application", feature = "paging"))] diff --git a/src/controller.rs b/src/controller.rs index d50ffe54..8f64e8c5 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -158,6 +158,7 @@ impl Controller<'_> { #[cfg(not(feature = "lessopen"))] input.open(stdin, stdout_identifier)? }; + opened_input.reader.unbuffered = self.config.unbuffered; #[cfg(feature = "git")] let line_changes = if self.config.visible_lines.diff_mode() || (!self.config.loop_through && self.config.style_components.changes()) @@ -327,6 +328,9 @@ impl Controller<'_> { } printer.print_line(false, writer, line_nr, &line, max_buffered_line_number)?; + if self.config.unbuffered { + writer.flush()?; + } } RangeCheckResult::AfterLastRange => { break; diff --git a/src/input.rs b/src/input.rs index f807d125..29846abe 100644 --- a/src/input.rs +++ b/src/input.rs @@ -253,6 +253,7 @@ pub(crate) struct InputReader<'a> { inner: Box, pub(crate) first_line: Vec, pub(crate) content_type: Option, + pub(crate) unbuffered: bool, } impl<'a> InputReader<'a> { @@ -276,6 +277,7 @@ impl<'a> InputReader<'a> { inner: Box::new(reader), first_line, content_type, + unbuffered: false, } } @@ -292,9 +294,29 @@ impl<'a> InputReader<'a> { return read_utf16_line(&mut self.inner, buf, 0x0A, 0x00); } + if self.unbuffered { + return self.read_line_unbuffered(buf); + } + let res = self.inner.read_until(b'\n', buf).map(|size| size > 0)?; Ok(res) } + + fn read_line_unbuffered(&mut self, buf: &mut Vec) -> io::Result { + let available = self.inner.fill_buf()?; + if available.is_empty() { + return Ok(!buf.is_empty()); + } + if let Some(pos) = available.iter().position(|&b| b == b'\n') { + buf.extend_from_slice(&available[..=pos]); + self.inner.consume(pos + 1); + } else { + let len = available.len(); + buf.extend_from_slice(available); + self.inner.consume(len); + } + Ok(true) + } } fn read_utf16_line( @@ -381,6 +403,89 @@ fn utf16le() { assert!(buffer.is_empty()); } +#[test] +fn unbuffered_returns_partial_data() { + use std::io::Cursor; + + let content = b"first line\npartial"; + let mut reader = InputReader::new(Cursor::new(&content[..])); + reader.unbuffered = true; + + // First call returns first_line (buffered during new()) + let mut buffer = vec![]; + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(res.unwrap()); + assert_eq!(b"first line\n", &buffer[..]); + + // Subsequent calls use unbuffered reading + buffer.clear(); + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(res.unwrap()); + assert_eq!(b"partial", &buffer[..]); + + // EOF + buffer.clear(); + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(!res.unwrap()); + assert!(buffer.is_empty()); +} + +#[test] +fn unbuffered_returns_complete_lines() { + use std::io::Cursor; + + let content = b"line1\nline2\n"; + let mut reader = InputReader::new(Cursor::new(&content[..])); + reader.unbuffered = true; + + // First call returns first_line + let mut buffer = vec![]; + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(res.unwrap()); + assert_eq!(b"line1\n", &buffer[..]); + + // Second call returns line2 (complete line with newline) + buffer.clear(); + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(res.unwrap()); + assert_eq!(b"line2\n", &buffer[..]); + + // EOF + buffer.clear(); + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(!res.unwrap()); + assert!(buffer.is_empty()); +} + +#[test] +fn unbuffered_eof_handling() { + use std::io::Cursor; + + let content = b"only line\n"; + let mut reader = InputReader::new(Cursor::new(&content[..])); + reader.unbuffered = true; + + // First call returns first_line + let mut buffer = vec![]; + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(res.unwrap()); + assert_eq!(b"only line\n", &buffer[..]); + + // EOF - empty buffer returns false + buffer.clear(); + let res = reader.read_line(&mut buffer); + assert!(res.is_ok()); + assert!(!res.unwrap()); + assert!(buffer.is_empty()); +} + #[test] fn utf16le_issue3367() { let content = b"\xFF\xFE\x0A\x4E\x00\x4E\x0A\x4F\x00\x52\x0A\x00\ diff --git a/src/output.rs b/src/output.rs index 6c728c4f..e5c8a654 100644 --- a/src/output.rs +++ b/src/output.rs @@ -236,4 +236,11 @@ impl OutputHandle<'_> { Self::FmtWrite(handle) => handle.write_fmt(args).map_err(Into::into), } } + + pub fn flush(&mut self) -> Result<()> { + match self { + Self::IoWrite(handle) => handle.flush().map_err(Into::into), + Self::FmtWrite(_) => Ok(()), + } + } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 7d0f69cd..da8b21eb 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -3701,3 +3701,39 @@ fn cache_help_shows_help_message() { .stdout(predicate::str::contains("--build")) .stdout(predicate::str::contains("--clear")); } + +#[test] +fn unbuffered_flag_is_accepted() { + bat() + .arg("--unbuffered") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); +} + +#[test] +fn unbuffered_mode_disables_line_numbers() { + // When --unbuffered is used, line numbers should be auto-disabled even if requested + bat() + .arg("--unbuffered") + .arg("--style=numbers") + .arg("--decorations=always") + .arg("--color=never") + .arg("test.txt") + .assert() + .success() + .stdout(predicate::str::starts_with(" 1").not()); +} + +#[test] +fn unbuffered_mode_plain_output() { + bat() + .arg("--unbuffered") + .arg("--color=never") + .arg("--decorations=never") + .arg("test.txt") + .assert() + .success() + .stdout("hello world\n"); +}