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

Add word wrapping mode (#3597)

* feat: add word wrapping mode for --wrap flag

* Run `cargo fmt` and add CHANGELOG entry

* Add word wrap tests, update manpage and shell completions

- Add integration tests for word wrapping: basic word boundary breaking,
  fallback to character wrapping for long words, line numbers, and
  short lines that fit without wrapping
- Update manpage to document the new 'word' wrapping mode
- Update bash, fish, zsh, and PowerShell completions with 'word' option
- Avoid unnecessary clone of `line_buf` when word wrap is disabled

* make clippy and cargo fmt happy

---------

Co-authored-by: Keith Hall <keith-hall@users.noreply.github.com>
This commit is contained in:
Varun Chawla
2026-03-02 19:18:49 -08:00
committed by GitHub
parent 1424f9d6bf
commit cc5f782d28
14 changed files with 130 additions and 15 deletions
+1
View File
@@ -4,6 +4,7 @@
## Features
- Add word wrapping mode via `--wrap=word`, see #3597 (@veeceey)
- Implement `--unbuffered` mode for streaming input, allowing partial lines to display immediately (e.g. `tail -f | bat -u`). Closes #3555, see #3583 (@mainnebula)
- Added an initial `flake.nix` for a ready made development environment; see #3578 (@vorburger)
- Add `--quiet-empty` (`-E`) flag to suppress output when input is empty. Closes #1936, see #3563 (@NORMAL-EX)
+2 -2
View File
@@ -9,7 +9,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
$ArrayCompletion = @('bash', 'fish', 'zsh', 'ps1')
$ArrayWhen = @('auto', 'never', 'always')
$ArrayYesNo = @('never', 'always')
$ArrayWrap = @('always', 'never', 'character')
$ArrayWrap = @('always', 'never', 'character', 'word')
$ArrayBinary = @('no-printing', 'as-text')
$ArrayPrint = @('unicode', 'caret')
@@ -135,7 +135,7 @@ Register-ArgumentCompleter -Native -CommandName '{{PROJECT_EXECUTABLE}}' -Script
[CompletionResult]::new('--file-name' , 'file-name' , [CompletionResultType]::ParameterName, 'Specify the name to display for a file.')
[CompletionResult]::new('--diff-context' , 'diff-context' , [CompletionResultType]::ParameterName, 'diff-context')
[CompletionResult]::new('--tabs' , 'tabs' , [CompletionResultType]::ParameterName, 'Set the tab width to T spaces.')
[CompletionResult]::new('--wrap' , 'wrap' , [CompletionResultType]::ParameterName, 'Specify the text-wrapping mode (*auto*, character).')
[CompletionResult]::new('--wrap' , 'wrap' , [CompletionResultType]::ParameterName, 'Specify the text-wrapping mode (*auto*, never, character, word).')
[CompletionResult]::new('--terminal-width' , 'terminal-width' , [CompletionResultType]::ParameterName, 'Explicitly set the width of the terminal instead of determining it automatically. If prefixed with ''+'' or ''-'', the value will be treated as an offset to the actual terminal width. See also: ''--wrap''.')
[CompletionResult]::new('--color' , 'color' , [CompletionResultType]::ParameterName, 'When to use colors (*auto*, never, always).')
[CompletionResult]::new('--italic-text' , 'italic-text' , [CompletionResultType]::ParameterName, 'Use italics in output (always, *never*)')
+1 -1
View File
@@ -117,7 +117,7 @@ _bat() {
return 0
;;
--wrap)
COMPREPLY=($(compgen -W "auto never character" -- "$cur"))
COMPREPLY=($(compgen -W "auto never character word" -- "$cur"))
return 0
;;
--binary)
+1
View File
@@ -118,6 +118,7 @@ set -l wrap_opts '
auto\tdefault
never\t
character\t
word\t
'
# While --tabs theoretically takes any number, most people should be OK with these.
+1 -1
View File
@@ -34,7 +34,7 @@ _{{PROJECT_EXECUTABLE}}_main() {
'(-d --diff)'--diff'[only show lines that have been added/removed/modified]'
--diff-context='[specify lines of context around added/removed/modified lines when using `--diff`]:lines'
--tabs='[set the tab width]:tab width [4]'
--wrap='[specify the text-wrapping mode]:mode [auto]:(auto never character)'
--wrap='[specify the text-wrapping mode]:mode [auto]:(auto never character word)'
'!(--wrap)'{-S,--chop-long-lines}
--terminal-width='[explicitly set the width of the terminal instead of determining it automatically]:width'
'(-n --number --diff --diff-context)'{-n,--number}'[show line numbers]'
+4 -2
View File
@@ -102,8 +102,10 @@ Set the tab width to T spaces. Use a width of 0 to pass tabs through directly
.HP
\fB\-\-wrap\fR <mode>
.IP
Specify the text\-wrapping mode (*auto*, never, character). The '\-\-terminal\-width' option
can be used in addition to control the output width.
Specify the text\-wrapping mode (*auto*, never, character, word). The '\-\-terminal\-width' option
can be used in addition to control the output width. In \fBword\fR mode, lines are broken at
whitespace boundaries. If a single word exceeds the terminal width, it falls back to
character wrapping.
.HP
\fB\-S\fR, \fB\-\-chop\-long\-lines\fR
.IP
+2 -2
View File
@@ -61,8 +61,8 @@ Options:
Set the tab width to T spaces. Use a width of 0 to pass tabs through directly
--wrap <mode>
Specify the text-wrapping mode (*auto*, never, character). The '--terminal-width' option
can be used in addition to control the output width.
Specify the text-wrapping mode (*auto*, never, character, word). The '--terminal-width'
option can be used in addition to control the output width.
-S, --chop-long-lines
Truncate all lines longer than screen width. Alias for '--wrap=never'.
+1 -1
View File
@@ -26,7 +26,7 @@ Options:
--tabs <T>
Set the tab width to T spaces.
--wrap <mode>
Specify the text-wrapping mode (*auto*, never, character).
Specify the text-wrapping mode (*auto*, never, character, word).
-S, --chop-long-lines
Truncate all lines longer than screen width. Alias for '--wrap=never'.
-n, --number
+1
View File
@@ -405,6 +405,7 @@ impl App {
} else {
match self.matches.get_one::<String>("wrap").map(|s| s.as_str()) {
Some("character") => WrappingMode::Character,
Some("word") => WrappingMode::Word,
Some("never") => WrappingMode::NoWrapping(true),
Some("auto") | None => {
if self.interactive_output || maybe_term_width.is_some() {
+3 -3
View File
@@ -211,11 +211,11 @@ pub fn build_app(interactive_output: bool) -> Command {
.long("wrap")
.overrides_with("wrap")
.value_name("mode")
.value_parser(["auto", "never", "character"])
.value_parser(["auto", "never", "character", "word"])
.default_value("auto")
.hide_default_value(true)
.help("Specify the text-wrapping mode (*auto*, never, character).")
.long_help("Specify the text-wrapping mode (*auto*, never, character). \
.help("Specify the text-wrapping mode (*auto*, never, character, word).")
.long_help("Specify the text-wrapping mode (*auto*, never, character, word). \
The '--terminal-width' option can be used in addition to \
control the output width."),
)
+48 -1
View File
@@ -781,11 +781,21 @@ impl Printer for InteractivePrinter<'_> {
// Displayed width of line_buf
let mut current_width = 0;
let word_wrap = matches!(self.config.wrapping_mode, WrappingMode::Word);
// For word wrapping, track last whitespace position.
let mut last_ws_idx: Option<usize> = None;
for c in text.chars() {
// calculate the displayed width for next character
let cw = c.width().unwrap_or(0);
current_width += cw;
// Track whitespace positions for word wrapping.
if word_wrap && c.is_whitespace() {
last_ws_idx = Some(line_buf.len());
}
// if next character cannot be printed on this line,
// flush the buffer.
if current_width > max_width {
@@ -807,13 +817,37 @@ impl Printer for InteractivePrinter<'_> {
}
}
// Determine the break point and remainder
// for word wrapping.
let (emit_end, rest_start) = if word_wrap {
if let Some(ws_idx) = last_ws_idx {
// Skip the whitespace character itself
// and carry the rest to the next line.
let rs = ws_idx
+ line_buf[ws_idx..]
.chars()
.next()
.map(|ch| ch.len_utf8())
.unwrap_or(0);
(ws_idx, Some(rs))
} else {
(line_buf.len(), None)
}
} else {
(line_buf.len(), None)
};
// It wraps.
write!(
handle,
"{}{}\n{}",
as_terminal_escaped(
style,
&format!("{}{line_buf}", self.ansi_style),
&format!(
"{}{}",
self.ansi_style,
&line_buf[..emit_end]
),
self.config.true_color,
self.config.colored_output,
self.config.use_italic_text,
@@ -826,9 +860,22 @@ impl Printer for InteractivePrinter<'_> {
cursor = 0;
max_width = cursor_max;
if let Some(rs) = rest_start {
// Word wrap: carry remainder to next line.
let remainder = line_buf[rs..].to_string();
let rem_width: usize = remainder
.chars()
.map(|ch| ch.width().unwrap_or(0))
.sum();
line_buf.clear();
line_buf.push_str(&remainder);
current_width = rem_width + cw;
} else {
line_buf.clear();
current_width = cw;
}
last_ws_idx = None;
}
line_buf.push(c);
}
+1
View File
@@ -1,6 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrappingMode {
Character,
Word,
// The bool specifies whether wrapping has been explicitly disabled by the user via --wrap=never
NoWrapping(bool),
}
+3
View File
@@ -0,0 +1,3 @@
The quick brown fox jumps over the lazy dog and then runs away
superlongwordthatdefinitelyexceedstheterminalwidthandshouldfallbacktocharacterwrapping
short words here
+59
View File
@@ -3791,3 +3791,62 @@ fn unbuffered_mode_plain_output() {
.success()
.stdout("hello world\n");
}
#[test]
fn word_wrap_breaks_at_word_boundaries() {
bat()
.arg("word-wrap.txt")
.arg("--wrap=word")
.arg("--terminal-width=40")
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.assert()
.success()
.stdout(
"\
The quick brown fox jumps over the lazy
dog and then runs away
superlongwordthatdefinitelyexceedstheter
minalwidthandshouldfallbacktocharacterwr
apping
short words here
",
);
}
#[test]
fn word_wrap_with_line_numbers() {
bat()
.arg("word-wrap.txt")
.arg("--wrap=word")
.arg("--terminal-width=40")
.arg("--style=numbers")
.arg("--decorations=always")
.arg("--color=never")
.assert()
.success()
.stdout(
" 1 The quick brown fox jumps over the
lazy dog and then runs away
2 superlongwordthatdefinitelyexceedst
heterminalwidthandshouldfallbacktoc
haracterwrapping
3 short words here
",
);
}
#[test]
fn word_wrap_short_line_no_wrap() {
bat()
.arg("--wrap=word")
.arg("--terminal-width=80")
.arg("--style=plain")
.arg("--decorations=always")
.arg("--color=never")
.arg("single-line.txt")
.assert()
.success()
.stdout("Single Line\n");
}