1
0
mirror of https://github.com/sharkdp/bat synced 2026-06-09 10:03:18 +00:00

feat: implement --unbuffered mode for streaming input (#3555)

Repurpose the existing --unbuffered/-u flag (previously a POSIX no-op)
to enable unbuffered input reading using fill_buf()/consume() instead
of read_until(b'\n'). This allows partial lines to display immediately
when piping streaming input like `tail -f` into bat.

- Add unbuffered field to Config and InputReader
- Add read_line_unbuffered() using BufRead::fill_buf()/consume()
- Add flush() to OutputHandle, called after each line in unbuffered mode
- Auto-disable line numbers in unbuffered mode to avoid partial line confusion
- Update help text, man page, and shell completions
- Add unit tests and integration tests
This commit is contained in:
mainnebula
2026-02-11 23:13:20 -05:00
parent cb8b637574
commit 167dda63a8
14 changed files with 183 additions and 12 deletions
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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]'
+4 -2
View File
@@ -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
+5 -2
View File
@@ -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 <SHELL>
Show shell completion for a certain shell. [possible values: bash, fish, zsh, ps1]
+2
View File
@@ -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 <SHELL>
Show shell completion for a certain shell. [possible values: bash, fish, zsh, ps1]
-E, --quiet-empty
+6
View File
@@ -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)
}
+7 -4
View File
@@ -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(
+3
View File
@@ -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"))]
+4
View File
@@ -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;
+105
View File
@@ -253,6 +253,7 @@ pub(crate) struct InputReader<'a> {
inner: Box<dyn BufRead + 'a>,
pub(crate) first_line: Vec<u8>,
pub(crate) content_type: Option<ContentType>,
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<u8>) -> io::Result<bool> {
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<R: BufRead>(
@@ -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\
+7
View File
@@ -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(()),
}
}
}
+36
View File
@@ -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");
}