1
0
mirror of https://github.com/sharkdp/bat synced 2026-07-25 17:21:43 +00:00

Merge remote-tracking branch 'origin/master' into patch-2

This commit is contained in:
Keith Hall
2026-03-14 09:21:19 +02:00
38 changed files with 1973 additions and 111 deletions
+143 -15
View File
@@ -210,6 +210,7 @@ impl HighlightingAssets {
pub(crate) fn get_syntax(
&self,
language: Option<&str>,
fallback_syntax: Option<&str>,
input: &mut OpenedInput,
mapping: &SyntaxMapping,
) -> Result<SyntaxReferenceInSet<'_>> {
@@ -222,21 +223,50 @@ impl HighlightingAssets {
}
let path = input.path();
let path_syntax = if let Some(path) = path {
self.get_syntax_for_path(
PathAbs::new(path).map_or_else(|_| path.to_owned(), |p| p.as_path().to_path_buf()),
mapping,
)
let absolute_path = path.and_then(|p| {
PathAbs::new(p)
.ok()
.map(|abs| abs.as_path().to_path_buf())
.or_else(|| Some(p.to_owned()))
});
let path_syntax = if let Some(ref path) = absolute_path {
self.get_syntax_for_path(path, mapping).or_else(|e| {
// If syntax detection failed on the given path, retry with the
// canonicalized path (which resolves symlinks). This handles
// cases like `Aliases/0install -> ../Formula/zero-install.rb`
// where the symlink name has no extension but the target does.
// See #1001.
if matches!(e, Error::UndetectedSyntax(_)) {
if let Ok(resolved) = fs::canonicalize(path) {
if resolved != *path {
return match self.get_syntax_for_path(&resolved, mapping) {
Ok(syntax) => Ok(syntax),
Err(Error::UndetectedSyntax(_)) => Err(e),
Err(err) => Err(err),
};
}
}
}
Err(e)
})
} else {
Err(Error::UndetectedSyntax("[unknown]".into()))
};
// If a path wasn't provided, or if path based syntax detection
// above failed, we fall back to first-line syntax detection.
match path_syntax {
// If a path wasn't provided, or if path based syntax detection
// above failed, we fall back to first-line syntax detection.
Err(Error::UndetectedSyntax(path)) => self
.get_first_line_syntax(&mut input.reader)?
.ok_or(Error::UndetectedSyntax(path)),
Err(Error::UndetectedSyntax(path)) => {
if let Some(syntax_in_set) = self.get_first_line_syntax(&mut input.reader)? {
Ok(syntax_in_set)
} else if let Some(language) = fallback_syntax {
self.find_syntax_by_token(language)?
.ok_or_else(|| Error::UnknownSyntax(language.to_owned()))
} else {
Err(Error::UndetectedSyntax(path))
}
}
_ => path_syntax,
}
}
@@ -262,6 +292,24 @@ impl HighlightingAssets {
.map(|syntax| SyntaxReferenceInSet { syntax, syntax_set }))
}
fn find_syntax_by_hidden_file_name(
&self,
file_name: &OsStr,
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
let Some(hidden_file_extension) = file_name
.to_str()
.and_then(|name| name.strip_prefix('.'))
.filter(|name| !name.is_empty())
else {
return Ok(None);
};
// syntect stores `hidden_file_extensions` in the same extension list as
// regular file extensions, but dotfiles must be queried without the
// leading period.
self.find_syntax_by_extension(Some(OsStr::new(hidden_file_extension)))
}
fn find_syntax_by_token(&self, token: &str) -> Result<Option<SyntaxReferenceInSet<'_>>> {
let syntax_set = self.get_syntax_set()?;
Ok(syntax_set
@@ -275,6 +323,9 @@ impl HighlightingAssets {
ignored_suffixes: &IgnoredSuffixes,
) -> Result<Option<SyntaxReferenceInSet<'_>>> {
let mut syntax = self.find_syntax_by_extension(Some(file_name))?;
if syntax.is_none() {
syntax = self.find_syntax_by_hidden_file_name(file_name)?;
}
if syntax.is_none() {
syntax =
ignored_suffixes.try_with_stripped_suffix(file_name, |stripped_file_name| {
@@ -395,11 +446,12 @@ mod tests {
fn get_syntax_name(
&self,
language: Option<&str>,
fallback_syntax: Option<&str>,
input: &mut OpenedInput,
mapping: &SyntaxMapping,
) -> String {
self.assets
.get_syntax(language, input, mapping)
.get_syntax(language, fallback_syntax, input, mapping)
.map(|syntax_in_set| syntax_in_set.syntax.name.clone())
.unwrap_or_else(|_| "!no syntax!".to_owned())
}
@@ -419,7 +471,7 @@ mod tests {
let dummy_stdin: &[u8] = &[];
let mut opened_input = input.open(dummy_stdin, None).unwrap();
self.get_syntax_name(None, &mut opened_input, &self.syntax_mapping)
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
}
fn syntax_for_file_with_content_os(&self, file_name: &OsStr, first_line: &str) -> String {
@@ -429,7 +481,7 @@ mod tests {
let dummy_stdin: &[u8] = &[];
let mut opened_input = input.open(dummy_stdin, None).unwrap();
self.get_syntax_name(None, &mut opened_input, &self.syntax_mapping)
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
}
#[cfg(unix)]
@@ -449,7 +501,7 @@ mod tests {
let input = Input::stdin().with_name(Some(file_name));
let mut opened_input = input.open(content, None).unwrap();
self.get_syntax_name(None, &mut opened_input, &self.syntax_mapping)
self.get_syntax_name(None, None, &mut opened_input, &self.syntax_mapping)
}
fn syntax_is_same_for_inputkinds(&self, file_name: &str, content: &str) -> bool {
@@ -661,6 +713,55 @@ mod tests {
);
}
#[cfg(feature = "build-assets")]
#[test]
fn syntax_detection_hidden_file_extensions() {
let source_dir = TempDir::new().expect("creation of temporary source directory");
let cache_dir = TempDir::new().expect("creation of temporary cache directory");
let syntax_dir = source_dir.path().join("syntaxes");
std::fs::create_dir_all(&syntax_dir).expect("creation of syntax directory succeeds");
std::fs::write(
syntax_dir.join("HiddenFileExtension.sublime-syntax"),
r#"%YAML 1.2
---
name: Hidden File Extension
hidden_file_extensions:
- testrc
scope: source.hiddenfileextension
contexts:
main:
- match: .
scope: source.hiddenfileextension
"#,
)
.expect("custom syntax can be written");
build(
source_dir.path(),
false,
false,
cache_dir.path(),
env!("CARGO_PKG_VERSION"),
)
.expect("custom assets can be built");
let test = SyntaxDetectionTest {
assets: HighlightingAssets::from_cache(cache_dir.path())
.expect("custom syntax cache can be loaded"),
syntax_mapping: SyntaxMapping::new(),
temp_dir: TempDir::new().expect("creation of temporary directory"),
};
assert_eq!(test.syntax_for_file(".testrc"), "Hidden File Extension");
assert_eq!(
test.syntax_for_stdin_with_content(".testrc", b""),
"Hidden File Extension"
);
assert!(test.syntax_is_same_for_inputkinds(".testrc", ""));
}
#[cfg(unix)]
#[test]
fn syntax_detection_for_symlinked_file() {
@@ -682,8 +783,35 @@ mod tests {
let mut opened_input = input.open(dummy_stdin, None).unwrap();
assert_eq!(
test.get_syntax_name(None, &mut opened_input, &test.syntax_mapping),
test.get_syntax_name(None, None, &mut opened_input, &test.syntax_mapping),
"SSH Config"
);
}
#[cfg(unix)]
#[test]
fn syntax_detection_for_symlinked_file_by_target_extension() {
use std::os::unix::fs::symlink;
let test = SyntaxDetectionTest::new();
let formula_dir = test.temp_dir.path().join("Formula");
std::fs::create_dir(&formula_dir).unwrap();
let target = formula_dir.join("zero-install.rb");
File::create(&target).unwrap();
let aliases_dir = test.temp_dir.path().join("Aliases");
std::fs::create_dir(&aliases_dir).unwrap();
let link = aliases_dir.join("0install");
symlink(&target, &link).unwrap();
let input = Input::ordinary_file(&link);
let dummy_stdin: &[u8] = &[];
let mut opened_input = input.open(dummy_stdin, None).unwrap();
assert_eq!(
test.get_syntax_name(None, None, &mut opened_input, &test.syntax_mapping),
"Ruby"
);
}
}
+24 -11
View File
@@ -384,6 +384,10 @@ impl App {
None
}
}),
fallback_syntax: self
.matches
.get_one::<String>("fallback-syntax")
.map(|s| s.as_str()),
show_nonprintable: self.matches.get_flag("show-all"),
nonprintable_notation: match self
.matches
@@ -399,27 +403,30 @@ impl App {
Some("no-printing") => BinaryBehavior::NoPrinting,
_ => unreachable!("other values for --binary are not allowed"),
},
wrapping_mode: if self.interactive_output || maybe_term_width.is_some() {
if !self.matches.get_flag("chop-long-lines") {
wrapping_mode: {
if self.matches.get_flag("chop-long-lines") {
WrappingMode::NoWrapping(true)
} 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 style_components.plain() && maybe_term_width.is_none() {
WrappingMode::NoWrapping(false)
if self.interactive_output || maybe_term_width.is_some() {
if style_components.plain() && maybe_term_width.is_none() {
WrappingMode::NoWrapping(false)
} else {
WrappingMode::Character
}
} else {
WrappingMode::Character
// We don't have the tty width when piping to another program.
// There's no point in wrapping when this is the case.
WrappingMode::NoWrapping(false)
}
}
_ => unreachable!("other values for --wrap are not allowed"),
}
} else {
WrappingMode::NoWrapping(true)
}
} else {
// We don't have the tty width when piping to another program.
// There's no point in wrapping when this is the case.
WrappingMode::NoWrapping(false)
},
colored_output: self.matches.get_flag("force-colorization")
|| match self.matches.get_one::<String>("color").map(|s| s.as_str()) {
@@ -462,6 +469,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 +627,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)
}
+32 -15
View File
@@ -1,7 +1,5 @@
use bat::style::StyleComponentList;
use clap::{
crate_name, crate_version, value_parser, Arg, ArgAction, ArgGroup, ColorChoice, Command,
};
use clap::{crate_name, crate_version, value_parser, Arg, ArgAction, ColorChoice, Command};
use once_cell::sync::Lazy;
use std::env;
use std::path::{Path, PathBuf};
@@ -122,6 +120,17 @@ pub fn build_app(interactive_output: bool) -> Command {
language names and file extensions.",
),
)
.arg(
Arg::new("fallback-syntax")
.long("fallback-syntax")
.visible_alias("fallback-language")
.help("Set a fallback language for undetected syntaxes.")
.long_help(
"Set a fallback language for syntax highlighting when auto-detection fails. \
Unlike '--language', this is only used when no syntax could be detected from \
filename, custom syntax mappings, or first-line detection.",
),
)
.arg(
Arg::new("highlight-line")
.long("highlight-line")
@@ -213,11 +222,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."),
)
@@ -550,11 +559,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(
@@ -694,11 +706,20 @@ pub fn build_app(interactive_output: bool) -> Command {
Command::new("cache")
.hide(true)
.about("Modify the syntax-definition and theme cache")
.arg_required_else_help(true)
.arg(
Arg::new("help")
.short('h')
.long("help")
.action(ArgAction::Help)
.help("Print help"),
)
.arg(
Arg::new("build")
.long("build")
.short('b')
.action(ArgAction::SetTrue)
.conflicts_with("clear")
.help("Initialize (or update) the syntax/theme cache.")
.long_help(
"Initialize (or update) the syntax/theme cache by loading from \
@@ -710,13 +731,9 @@ pub fn build_app(interactive_output: bool) -> Command {
.long("clear")
.short('c')
.action(ArgAction::SetTrue)
.conflicts_with("build")
.help("Remove the cached syntax definitions and themes."),
)
.group(
ArgGroup::new("cache-actions")
.args(["build", "clear"])
.required(true),
)
.arg(
Arg::new("source")
.long("source")
+55 -4
View File
@@ -2,7 +2,7 @@ use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::directories::PROJECT_DIRS;
@@ -104,18 +104,32 @@ pub fn generate_config_file() -> bat::error::Result<()> {
pub fn get_args_from_config_file() -> Result<Vec<OsString>, shell_words::ParseError> {
let mut config = String::new();
if let Ok(c) = fs::read_to_string(system_config_file()) {
let system_config = system_config_file();
let user_config = config_file();
if let Ok(c) = fs::read_to_string(&system_config) {
config.push_str(&c);
config.push('\n');
}
if let Ok(c) = fs::read_to_string(config_file()) {
config.push_str(&c);
// Skip the user config if it resolves to the same file as the system config,
// which can happen when BAT_CONFIG_DIR is set to e.g. "/etc/bat". See #3589.
if !same_file(&system_config, &user_config) {
if let Ok(c) = fs::read_to_string(&user_config) {
config.push_str(&c);
}
}
get_args_from_str(&config)
}
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
pub fn get_args_from_env_opts_var() -> Option<Result<Vec<OsString>, shell_words::ParseError>> {
env::var("BAT_OPTS").ok().map(|s| get_args_from_str(&s))
}
@@ -214,3 +228,40 @@ fn comments() {
get_args_from_str(config).unwrap()
);
}
#[test]
fn same_file_identical_paths() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("config");
fs::write(&file, "").unwrap();
assert!(same_file(&file, &file));
}
#[test]
fn same_file_different_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
fs::write(&a, "").unwrap();
fs::write(&b, "").unwrap();
assert!(!same_file(&a, &b));
}
#[test]
fn same_file_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
assert!(!same_file(&a, &b));
}
#[cfg(unix)]
#[test]
fn same_file_via_symlink() {
let dir = tempfile::tempdir().unwrap();
let original = dir.path().join("config");
let link = dir.path().join("link");
fs::write(&original, "").unwrap();
std::os::unix::fs::symlink(&original, &link).unwrap();
assert!(same_file(&original, &link));
}
+6
View File
@@ -38,6 +38,9 @@ pub struct Config<'a> {
/// The explicitly configured language, if any
pub language: Option<&'a str>,
/// The fallback syntax used when auto-detection fails
pub fallback_syntax: Option<&'a str>,
/// Whether or not to show/replace non-printable characters like space, tab and newline.
pub show_nonprintable: bool,
@@ -110,6 +113,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\
+27 -5
View File
@@ -105,6 +105,10 @@ impl OutputType {
let resolved_path = match grep_cli::resolve_binary(&pager.bin) {
Ok(path) => path,
Err(_) => {
crate::bat_warning!(
"Pager '{}' not found, outputting to stdout instead",
pager.bin
);
return Ok(OutputType::stdout());
}
};
@@ -131,8 +135,13 @@ impl OutputType {
p.arg("-S"); // Short version of --chop-long-lines for compatibility
}
let less_version = retrieve_less_version(&pager.bin);
// Ensures that 'less' quits together with 'bat'
p.arg("-K"); // Short version of '--quit-on-intr'
// The BusyBox version of less does not support -K
if less_version != Some(LessVersion::BusyBox) {
p.arg("-K"); // Short version of '--quit-on-intr'
}
// Passing '--no-init' fixes a bug with '--quit-if-one-screen' in older
// versions of 'less'. Unfortunately, it also breaks mouse-wheel support.
@@ -142,7 +151,7 @@ impl OutputType {
// For newer versions (530 or 558 on Windows), we omit '--no-init' as it
// is not needed anymore.
if single_screen_action == SingleScreenAction::Quit {
match retrieve_less_version(&pager.bin) {
match less_version {
None => {
p.arg("--no-init");
}
@@ -169,7 +178,13 @@ impl OutputType {
Ok(p.stdin(Stdio::piped())
.spawn()
.map(OutputType::Pager)
.unwrap_or_else(|_| OutputType::stdout()))
.unwrap_or_else(|_| {
crate::bat_warning!(
"Pager '{}' not found, outputting to stdout instead",
&pager.bin
);
OutputType::stdout()
}))
}
pub(crate) fn stdout() -> Self {
@@ -210,8 +225,8 @@ impl Drop for OutputType {
let _ = command.wait();
}
OutputType::BuiltinPager(ref mut pager) => {
if pager.handle.is_some() {
let _ = pager.handle.take().unwrap().join().unwrap();
if let Some(handle) = pager.handle.take() {
let _ = handle.join();
}
}
OutputType::Stdout(_) => (),
@@ -231,4 +246,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(()),
}
}
}
+56 -4
View File
@@ -268,7 +268,12 @@ impl<'a> InteractivePrinter<'a> {
const PLAIN_TEXT_SYNTAX: &str = "Plain Text";
const MANPAGE_SYNTAX: &str = "Manpage";
const COMMAND_HELP_SYNTAX: &str = "Command Help";
match assets.get_syntax(config.language, input, &config.syntax_mapping) {
match assets.get_syntax(
config.language,
config.fallback_syntax,
input,
&config.syntax_mapping,
) {
Ok(syntax_in_set) => (
syntax_in_set.syntax.name == PLAIN_TEXT_SYNTAX,
syntax_in_set.syntax.name == MANPAGE_SYNTAX
@@ -781,11 +786,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 +822,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 +865,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);
+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),
}