diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..87de691 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,226 @@ +// This file is part of the uutils diffutils package. +// +// For the full copyright and license information, please view the LICENSE-* +// files that was distributed with this source code. + +//! Colorizing of the normal, unified and context output formats +//! (`--color` and `--palette`). + +use crate::params::Format; + +/// When to colorize the output (`--color[=WHEN]`). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ColorWhen { + #[default] + Never, + Always, + /// Only when standard output is a terminal. + Auto, +} + +/// SGR parameters used for each kind of output line. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Palette { + pub reset: String, + pub header: String, + pub added: String, + pub deleted: String, + pub line_numbers: String, +} + +impl Default for Palette { + fn default() -> Self { + Self { + reset: "0".to_string(), + header: "1".to_string(), + added: "32".to_string(), + deleted: "31".to_string(), + line_numbers: "36".to_string(), + } + } +} + +/// Why a `--palette` value was rejected. +#[derive(Debug, Eq, PartialEq)] +pub enum PaletteError { + /// An entry names a capability that does not exist. + UnrecognizedPrefix(String), + /// An entry is not of the form `name=value`. + Unparsable, +} + +impl Palette { + /// Parses a `--palette` value such as `ad=32:de=31`, starting from the + /// default palette so that only the listed capabilities are overridden. + pub fn parse(spec: &str) -> Result { + let mut palette = Self::default(); + for entry in spec.split(':').filter(|entry| !entry.is_empty()) { + let Some((name, value)) = entry.split_once('=') else { + return Err(PaletteError::Unparsable); + }; + let slot = match name { + "rs" => &mut palette.reset, + "hd" => &mut palette.header, + "ad" => &mut palette.added, + "de" => &mut palette.deleted, + "ln" => &mut palette.line_numbers, + _ => return Err(PaletteError::UnrecognizedPrefix(name.to_string())), + }; + value.clone_into(slot); + } + Ok(palette) + } +} + +/// The role of a line of diff output, which decides its color. +#[derive(Clone, Copy)] +enum LineKind { + Plain, + Header, + Added, + Deleted, + LineNumbers, +} + +/// Rewrites the output of a diff in the given format, wrapping each line in +/// the escape sequences of the palette entry matching its role. +/// +/// Each kind of line is recognizable from its prefix alone, because the text +/// of the compared files always appears behind a marker such as `< `, `+` or +/// `! `. In the context format, `! ` lines are deletions until the `--- ` +/// range line of the new file is reached, and additions after it. +#[must_use] +pub fn colorize(output: &[u8], format: Format, palette: &Palette) -> Vec { + let mut colored = Vec::with_capacity(output.len() + output.len() / 4); + let mut in_new_file_part = false; + let lines = output.split_inclusive(|&byte| byte == b'\n'); + for (index, line) in lines.enumerate() { + let kind = match format { + Format::Normal => match line.first() { + Some(b'<') => LineKind::Deleted, + Some(b'>') => LineKind::Added, + Some(b'0'..=b'9') => LineKind::LineNumbers, + _ => LineKind::Plain, + }, + Format::Unified => match line.first() { + _ if index < 2 => LineKind::Header, + Some(b'-') => LineKind::Deleted, + Some(b'+') => LineKind::Added, + Some(b'@') => LineKind::LineNumbers, + _ => LineKind::Plain, + }, + Format::Context => { + if index < 2 { + LineKind::Header + } else if line.starts_with(b"*** ") { + in_new_file_part = false; + LineKind::LineNumbers + } else if line.starts_with(b"--- ") { + in_new_file_part = true; + LineKind::LineNumbers + } else if line.starts_with(b"- ") { + LineKind::Deleted + } else if line.starts_with(b"+ ") { + LineKind::Added + } else if line.starts_with(b"! ") { + if in_new_file_part { + LineKind::Added + } else { + LineKind::Deleted + } + } else { + LineKind::Plain + } + } + Format::Ed | Format::SideBySide => LineKind::Plain, + }; + let sgr = match kind { + LineKind::Plain => { + colored.extend_from_slice(line); + continue; + } + LineKind::Header => &palette.header, + LineKind::Added => &palette.added, + LineKind::Deleted => &palette.deleted, + LineKind::LineNumbers => &palette.line_numbers, + }; + let (text, newline) = match line.strip_suffix(b"\n") { + Some(text) => (text, &b"\n"[..]), + None => (line, &b""[..]), + }; + push_sgr(&mut colored, sgr); + colored.extend_from_slice(text); + push_sgr(&mut colored, &palette.reset); + colored.extend_from_slice(newline); + } + colored +} + +fn push_sgr(output: &mut Vec, parameters: &str) { + output.extend_from_slice(b"\x1b["); + output.extend_from_slice(parameters.as_bytes()); + output.push(b'm'); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn colorize_str(output: &str, format: Format) -> String { + String::from_utf8(colorize(output.as_bytes(), format, &Palette::default())).unwrap() + } + + #[test] + fn normal() { + assert_eq!( + colorize_str("3,4c3\n< owl\n< elk\n---\n> yak\n", Format::Normal), + "\x1b[36m3,4c3\x1b[0m\n\x1b[31m< owl\x1b[0m\n\x1b[31m< elk\x1b[0m\n---\n\x1b[32m> yak\x1b[0m\n" + ); + } + + #[test] + fn unified() { + // The deleted text looks like a header line, but only the first two + // lines are headers. + let output = "--- left\n+++ right\n@@ -2 +2 @@\n--- not a header\n+++ nor this\n"; + assert_eq!( + colorize_str(output, Format::Unified), + "\x1b[1m--- left\x1b[0m\n\x1b[1m+++ right\x1b[0m\n\x1b[36m@@ -2 +2 @@\x1b[0m\n\ + \x1b[31m--- not a header\x1b[0m\n\x1b[32m+++ nor this\x1b[0m\n" + ); + } + + #[test] + fn context() { + let output = "*** left\n--- right\n***************\n*** 1,2 ****\n cat\n! dog\n\ + --- 1,3 ----\n cat\n! cow\n+ pig\n"; + assert_eq!( + colorize_str(output, Format::Context), + "\x1b[1m*** left\x1b[0m\n\x1b[1m--- right\x1b[0m\n***************\n\ + \x1b[36m*** 1,2 ****\x1b[0m\n cat\n\x1b[31m! dog\x1b[0m\n\ + \x1b[36m--- 1,3 ----\x1b[0m\n cat\n\x1b[32m! cow\x1b[0m\n\x1b[32m+ pig\x1b[0m\n" + ); + } + + #[test] + fn missing_newline_marker_is_plain() { + assert_eq!( + colorize_str("1c1\n< ant\n---\n> bee\n\\ No newline at end of file\n", Format::Normal), + "\x1b[36m1c1\x1b[0m\n\x1b[31m< ant\x1b[0m\n---\n\x1b[32m> bee\x1b[0m\n\\ No newline at end of file\n" + ); + } + + #[test] + fn palette() { + let palette = Palette::parse("ad=1;34:ln=35").unwrap(); + assert_eq!(palette.added, "1;34"); + assert_eq!(palette.line_numbers, "35"); + assert_eq!(palette.deleted, Palette::default().deleted); + assert_eq!(Palette::parse("ad"), Err(PaletteError::Unparsable)); + assert_eq!( + Palette::parse("xx=1"), + Err(PaletteError::UnrecognizedPrefix("xx".to_string())) + ); + } +} diff --git a/src/diff.rs b/src/diff.rs index 072b9e0..d74b022 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -3,13 +3,14 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. +use crate::color::{colorize, ColorWhen, Palette, PaletteError}; use crate::params::{parse_params, Format, MissingFile, Params}; use crate::utils::report_failure_to_read_input_file; use crate::{context_diff, ed_diff, normal_diff, side_diff, unified_diff}; use std::env::ArgsOs; use std::ffi::OsString; use std::fs; -use std::io::{self, stdout, Read, Write}; +use std::io::{self, stdout, IsTerminal, Read, Write}; use std::iter::Peekable; use std::process::{exit, ExitCode}; @@ -119,7 +120,21 @@ pub fn main(opts: Peekable) -> ExitCode { if params.brief && !result.is_empty() { print_files_differ("Files", ¶ms); } else { - io::stdout().write_all(&result).unwrap(); + let written = match output_palette(¶ms) { + Some(palette) => stdout().write_all(&colorize(&result, params.format, &palette)), + None => stdout().write_all(&result), + }; + if let Err(error) = written.and_then(|()| stdout().flush()) { + // A reader that went away, as with `| head`, is not worth a message. + if error.kind() != io::ErrorKind::BrokenPipe { + report_failure_to_read_input_file( + ¶ms.executable, + &OsString::from("standard output"), + &error, + ); + } + return ExitCode::from(2); + } } if result.is_empty() { maybe_report_identical_files(); @@ -129,6 +144,37 @@ pub fn main(opts: Peekable) -> ExitCode { } } +/// Returns the palette to colorize the output with, or `None` when the +/// output is not to be colorized. An invalid `--palette` is reported and +/// disables colors. +fn output_palette(params: &Params) -> Option { + let enabled = match params.color { + ColorWhen::Never => false, + ColorWhen::Always => true, + ColorWhen::Auto => { + (params.presume_output_tty || stdout().is_terminal()) + && std::env::var_os("TERM").is_some_and(|term| term != "dumb") + } + }; + if !enabled { + return None; + } + let Some(spec) = ¶ms.palette else { + return Some(Palette::default()); + }; + let executable = params.executable.to_string_lossy(); + match Palette::parse(spec) { + Ok(palette) => Some(palette), + Err(error) => { + if let PaletteError::UnrecognizedPrefix(prefix) = error { + eprintln!("{executable}: unrecognized prefix: {prefix}"); + } + eprintln!("{executable}: unparsable value for --palette"); + None + } + } +} + /// Number of leading bytes inspected when deciding whether an input is binary. const BINARY_PROBE_LEN: usize = 16 * 1024; diff --git a/src/lib.rs b/src/lib.rs index 342b01c..d672d4b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cmp; +pub mod color; pub mod context_diff; pub mod ed_diff; pub mod macros; diff --git a/src/main.rs b/src/main.rs index d0adb69..a34cc04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use std::{ }; mod cmp; +mod color; mod context_diff; mod diff; mod ed_diff; diff --git a/src/params.rs b/src/params.rs index 2e65974..a0607b2 100644 --- a/src/params.rs +++ b/src/params.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; use regex::Regex; +use crate::color::ColorWhen; + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Format { #[default] @@ -41,6 +43,10 @@ pub struct Params { pub width: usize, pub missing_file: MissingFile, pub text: bool, + pub color: ColorWhen, + /// The raw `--palette` value, validated when colors are enabled. + pub palette: Option, + pub presume_output_tty: bool, } impl Default for Params { @@ -58,6 +64,9 @@ impl Default for Params { width: 130, missing_file: MissingFile::default(), text: false, + color: ColorWhen::default(), + palette: None, + presume_output_tty: false, } } } @@ -114,6 +123,34 @@ pub fn parse_params>(mut opts: Peekable) -> Resu } continue; } + if param == "--color" { + params.color = ColorWhen::Auto; + continue; + } + if let Some(when) = param.to_str().and_then(|p| p.strip_prefix("--color=")) { + params.color = match when { + "auto" => ColorWhen::Auto, + "always" => ColorWhen::Always, + "never" => ColorWhen::Never, + _ => return Err(format!("invalid color '{when}'")), + }; + continue; + } + if param == "--palette" { + let Some(palette) = opts.next() else { + return Err("option '--palette' requires an argument".to_string()); + }; + params.palette = Some(palette.to_string_lossy().into_owned()); + continue; + } + if let Some(palette) = param.to_str().and_then(|p| p.strip_prefix("--palette=")) { + params.palette = Some(palette.to_string()); + continue; + } + if param == "---presume-output-tty" { + params.presume_output_tty = true; + continue; + } if param == "-a" || param == "--text" { params.text = true; continue; @@ -1017,4 +1054,26 @@ mod tests { .is_err()); } } + + #[test] + fn color_options() { + let parse = |args: &[&str]| { + let args = std::iter::once("diff") + .chain(args.iter().copied()) + .chain(["foo", "bar"]); + parse_params(args.map(os).peekable()) + }; + assert_eq!(parse(&["--color"]).unwrap().color, ColorWhen::Auto); + assert_eq!(parse(&["--color=never"]).unwrap().color, ColorWhen::Never); + let params = parse(&["--palette", "ad=35", "--color=always"]).unwrap(); + assert_eq!(params.color, ColorWhen::Always); + assert_eq!(params.palette.as_deref(), Some("ad=35")); + assert_eq!( + parse(&["--palette=hd=4"]).unwrap().palette.as_deref(), + Some("hd=4") + ); + for option in ["--colorful", "--color=", "--color=yes"] { + assert!(parse(&[option]).is_err(), "{option}"); + } + } } diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index cd375f2..d745bde 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -322,6 +322,102 @@ fn stdin_is_directory() -> Result<(), Box> { Ok(()) } +#[test] +fn color() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let old = tmp_dir.path().join("old"); + let new = tmp_dir.path().join("new"); + std::fs::write(&old, "sun\nrain\n")?; + std::fs::write(&new, "sun\nsnow\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("--color=always").arg(&old).arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("\x1b[36m2c2\x1b[0m\n\x1b[31m< rain\x1b[0m\n---\n\x1b[32m> snow\x1b[0m\n"); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--color=always") + .arg("--palette=de=4:ad=7") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("\x1b[36m2c2\x1b[0m\n\x1b[4m< rain\x1b[0m\n---\n\x1b[7m> snow\x1b[0m\n"); + + // Standard output is not a terminal here, and TERM=dumb never gets colors. + for args in [&["--color"][..], &["--color=auto", "---presume-output-tty"]] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.env("TERM", "dumb") + .arg("diff") + .args(args) + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("2c2\n< rain\n---\n> snow\n"); + } + + // An invalid palette is reported and disables colors. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--color=always") + .arg("--palette=up=1") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("2c2\n< rain\n---\n> snow\n") + .stderr(predicate::str::contains("unrecognized prefix: up")); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("--color=sometimes").arg(&old).arg(&new); + cmd.assert() + .code(predicate::eq(2)) + .stderr(predicate::str::contains("invalid color 'sometimes'")); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("--colour").arg(&old).arg(&new); + cmd.assert() + .code(predicate::eq(2)) + .stderr(predicate::str::contains("unrecognized option '--colour'")); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--palette") + .arg("ln=1") + .arg("--color=always") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("\x1b[1m2c2\x1b[0m\n\x1b[31m< rain\x1b[0m\n---\n\x1b[32m> snow\x1b[0m\n"); + Ok(()) +} + +#[cfg(target_os = "linux")] +#[test] +fn write_error() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let old = tmp_dir.path().join("old"); + let new = tmp_dir.path().join("new"); + std::fs::write(&old, "left\n")?; + std::fs::write(&new, "right\n")?; + + let output = std::process::Command::new(assert_cmd::cargo::cargo_bin!("diffutils")) + .arg("diff") + .arg(&old) + .arg(&new) + .stdout(File::create("/dev/full")?) + .output()?; + output + .assert() + .code(predicate::eq(2)) + .stderr("diff: standard output: No space left on device\n"); + Ok(()) +} + #[test] fn compare_file_to_directory() -> Result<(), Box> { let tmp_dir = tempdir()?;