From cc5f782d28a8e6156b8ebd3346b0a7f7c49256e2 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:18:49 -0800 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + assets/completions/_bat.ps1.in | 4 +-- assets/completions/bat.bash.in | 2 +- assets/completions/bat.fish.in | 1 + assets/completions/bat.zsh.in | 2 +- assets/manual/bat.1.in | 6 ++-- doc/long-help.txt | 4 +-- doc/short-help.txt | 2 +- src/bin/bat/app.rs | 1 + src/bin/bat/clap_app.rs | 6 ++-- src/printer.rs | 53 ++++++++++++++++++++++++++++-- src/wrapping.rs | 1 + tests/examples/word-wrap.txt | 3 ++ tests/integration_tests.rs | 59 ++++++++++++++++++++++++++++++++++ 14 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 tests/examples/word-wrap.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 278bfc74..04bb52ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/assets/completions/_bat.ps1.in b/assets/completions/_bat.ps1.in index 80eb6654..97f76932 100644 --- a/assets/completions/_bat.ps1.in +++ b/assets/completions/_bat.ps1.in @@ -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*)') diff --git a/assets/completions/bat.bash.in b/assets/completions/bat.bash.in index 2cc360ab..6e45bd19 100644 --- a/assets/completions/bat.bash.in +++ b/assets/completions/bat.bash.in @@ -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) diff --git a/assets/completions/bat.fish.in b/assets/completions/bat.fish.in index 57f3cd7d..2100338c 100644 --- a/assets/completions/bat.fish.in +++ b/assets/completions/bat.fish.in @@ -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. diff --git a/assets/completions/bat.zsh.in b/assets/completions/bat.zsh.in index 536cd8df..3c3f81d1 100644 --- a/assets/completions/bat.zsh.in +++ b/assets/completions/bat.zsh.in @@ -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]' diff --git a/assets/manual/bat.1.in b/assets/manual/bat.1.in index e35b10f9..288a3d30 100644 --- a/assets/manual/bat.1.in +++ b/assets/manual/bat.1.in @@ -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 .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 diff --git a/doc/long-help.txt b/doc/long-help.txt index d00c84e9..2c98ff25 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -61,8 +61,8 @@ Options: Set the tab width to T spaces. Use a width of 0 to pass tabs through directly --wrap - 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'. diff --git a/doc/short-help.txt b/doc/short-help.txt index 04ca57a6..b0c45314 100644 --- a/doc/short-help.txt +++ b/doc/short-help.txt @@ -26,7 +26,7 @@ Options: --tabs Set the tab width to T spaces. --wrap - 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 diff --git a/src/bin/bat/app.rs b/src/bin/bat/app.rs index 54578087..73ad60fe 100644 --- a/src/bin/bat/app.rs +++ b/src/bin/bat/app.rs @@ -405,6 +405,7 @@ impl App { } else { match self.matches.get_one::("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() { diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index d286904a..5e2b927c 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -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."), ) diff --git a/src/printer.rs b/src/printer.rs index 3c3facf5..119258bd 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -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 = 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,8 +860,21 @@ impl Printer for InteractivePrinter<'_> { cursor = 0; max_width = cursor_max; - line_buf.clear(); - current_width = cw; + 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); diff --git a/src/wrapping.rs b/src/wrapping.rs index 57201750..3a5fa457 100644 --- a/src/wrapping.rs +++ b/src/wrapping.rs @@ -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), } diff --git a/tests/examples/word-wrap.txt b/tests/examples/word-wrap.txt new file mode 100644 index 00000000..1c5141d0 --- /dev/null +++ b/tests/examples/word-wrap.txt @@ -0,0 +1,3 @@ +The quick brown fox jumps over the lazy dog and then runs away +superlongwordthatdefinitelyexceedstheterminalwidthandshouldfallbacktocharacterwrapping +short words here diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4972aa46..ee727eb0 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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"); +}