diff --git a/src/bre.rs b/src/bre.rs new file mode 100644 index 0000000..bcd2a22 --- /dev/null +++ b/src/bre.rs @@ -0,0 +1,350 @@ +// 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. + +//! Translation of POSIX basic regular expressions (the `grep` syntax that +//! options such as `-F` take) into the syntax of the `regex` crate. +//! +//! In a basic regular expression, `\(`, `\)`, `\{`, `\}`, `\|`, `\+` and `\?` +//! are operators while their unescaped forms are ordinary characters, `*` is +//! literal where it has nothing to repeat, `^` only anchors at the start of +//! the expression or of a group or alternative, and `$` only anchors at their +//! end. + +use regex::bytes::Regex; + +/// A compiled basic regular expression. Two of them are equal when their +/// patterns are. +#[derive(Clone, Debug)] +pub struct Bre { + pattern: String, + regex: Regex, +} + +impl Bre { + /// Compiles `pattern`, or returns an error message describing what is + /// wrong with it. + pub fn new(pattern: &str) -> Result { + let regex = Regex::new(&translate(pattern)?).map_err(|error| match error { + regex::Error::CompiledTooBig(_) => TOO_BIG.to_string(), + _ => "Invalid regular expression".to_string(), + })?; + Ok(Self { + pattern: pattern.to_string(), + regex, + }) + } + + pub fn is_match(&self, text: &[u8]) -> bool { + self.regex.is_match(text) + } +} + +impl PartialEq for Bre { + fn eq(&self, other: &Self) -> bool { + self.pattern == other.pattern + } +} + +impl Eq for Bre {} + +const TOO_BIG: &str = "Regular expression too big"; +/// The largest count of an interval, as in `a\{2,100\}`. +const MAX_REPETITIONS: u32 = 0x7fff; + +/// Translates a basic regular expression into `regex` crate syntax. +fn translate(pattern: &str) -> Result { + let chars: Vec = pattern.chars().collect(); + let mut translated = String::with_capacity(pattern.len() * 2); + // Whether the previous token opens an expression, a group or an + // alternative, where `^` anchors and repetition operators are literal. + let mut at_expression_start = true; + let mut open_groups = 0_usize; + let mut index = 0; + while index < chars.len() { + let c = chars[index]; + index += 1; + let mut opens_expression = false; + match c { + '\\' => { + let Some(&escaped) = chars.get(index) else { + return Err("Trailing backslash".to_string()); + }; + index += 1; + match escaped { + '(' => { + open_groups += 1; + translated.push('('); + opens_expression = true; + } + ')' => { + open_groups = open_groups + .checked_sub(1) + .ok_or_else(|| r"Unmatched ) or \)".to_string())?; + translated.push(')'); + } + '|' => { + translated.push('|'); + opens_expression = true; + } + '{' | '}' | '+' | '?' if at_expression_start => { + push_literal(&mut translated, escaped); + } + '{' => index = translate_interval(&chars, index, &mut translated)?, + '+' | '?' => translated.push(escaped), + '<' | '>' | 'b' | 'B' | 'w' | 'W' | 's' | 'S' => { + translated.push('\\'); + translated.push(escaped); + } + '`' => translated.push_str(r"\A"), + '\'' => translated.push_str(r"\z"), + '1'..='9' => return Err("Invalid back reference".to_string()), + _ => push_literal(&mut translated, escaped), + } + } + '^' if at_expression_start => { + translated.push('^'); + opens_expression = true; + } + '$' if ends_expression(&chars[index..]) => translated.push('$'), + '*' if at_expression_start => translated.push_str(r"\*"), + '.' | '*' => translated.push(c), + '[' => index = translate_bracket(&chars, index, &mut translated)?, + _ => push_literal(&mut translated, c), + } + at_expression_start = opens_expression; + } + if open_groups > 0 { + return Err(r"Unmatched ( or \(".to_string()); + } + Ok(translated) +} + +/// Returns whether a `$` followed by `rest` ends an expression, a group or an +/// alternative. +fn ends_expression(rest: &[char]) -> bool { + matches!(rest, [] | ['\\', ')' | '|', ..]) +} + +/// Translates the interval whose content starts at `index` (just after the +/// `\{`), and returns the index following its closing `\}`. +fn translate_interval( + chars: &[char], + index: usize, + translated: &mut String, +) -> Result { + let end = (index..chars.len().saturating_sub(1)) + .find(|&end| chars[end] == '\\' && chars[end + 1] == '}') + .ok_or_else(|| r"Unmatched \{".to_string())?; + let content: String = chars[index..end].iter().collect(); + let invalid = || r"Invalid content of \{\}".to_string(); + let count = |digits: &str| -> Result, String> { + if digits.is_empty() { + return Ok(None); + } + if !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(invalid()); + } + match digits.parse::() { + Ok(count) if count <= MAX_REPETITIONS => Ok(Some(count)), + _ => Err(TOO_BIG.to_string()), + } + }; + match content.split_once(',') { + None => { + let exact = count(&content)?.ok_or_else(invalid)?; + translated.push_str(&format!("{{{exact}}}")); + } + Some((min, max)) => { + let min = count(min)?.unwrap_or(0); + match count(max)? { + Some(max) if max < min => return Err(invalid()), + Some(max) => translated.push_str(&format!("{{{min},{max}}}")), + None => translated.push_str(&format!("{{{min},}}")), + } + } + } + Ok(end + 2) +} + +/// Characters with a meaning in `regex` crate syntax, inside or outside of +/// bracket expressions. +const META_CHARACTERS: &str = r"\.+*?()|[]{}^$#&-~"; + +/// The names of the character classes of bracket expressions, as in +/// `[[:alpha:]]`. +const CLASS_NAMES: [&str; 12] = [ + "alnum", "alpha", "blank", "cntrl", "digit", "graph", "lower", "print", "punct", "space", + "upper", "xdigit", +]; + +fn push_literal(translated: &mut String, c: char) { + if META_CHARACTERS.contains(c) { + translated.push('\\'); + } + translated.push(c); +} + +/// Translates the bracket expression whose content starts at `index` (just +/// after the `[`), and returns the index following its closing `]`. +fn translate_bracket( + chars: &[char], + mut index: usize, + translated: &mut String, +) -> Result { + const UNMATCHED: &str = "Unmatched [, [^, [:, [., or [="; + translated.push('['); + if chars.get(index) == Some(&'^') { + translated.push('^'); + index += 1; + } + let content_start = index; + // The last single character member, which can start a range. + let mut range_start = None; + loop { + let Some(&c) = chars.get(index) else { + return Err(UNMATCHED.to_string()); + }; + // A `]` first in the list is an ordinary member. + if c == ']' && index > content_start { + translated.push(']'); + return Ok(index + 1); + } + if c == '[' && matches!(chars.get(index + 1), Some(':' | '.' | '=')) { + let delimiter = chars[index + 1]; + let end = (index + 2..chars.len().saturating_sub(1)) + .find(|&end| chars[end] == delimiter && chars[end + 1] == ']') + .ok_or_else(|| UNMATCHED.to_string())?; + let name: String = chars[index + 2..end].iter().collect(); + if delimiter == ':' { + // A character class such as `[:alpha:]`, kept verbatim. + if !CLASS_NAMES.contains(&name.as_str()) { + return Err("Invalid character class name".to_string()); + } + translated.extend(&chars[index..end + 2]); + range_start = None; + } else { + // A collating element or equivalence class such as `[.a.]` + // or `[=a=]`, which only stands for a single character here. + let mut name_chars = name.chars(); + let (Some(member), None) = (name_chars.next(), name_chars.next()) else { + return Err("Invalid collation character".to_string()); + }; + push_literal(translated, member); + range_start = Some(member); + } + index = end + 2; + continue; + } + let is_range = c == '-' + && index > content_start + && chars.get(index + 1).is_some_and(|&next| next != ']'); + if is_range { + if let (Some(start), Some(&end)) = (range_start, chars.get(index + 1)) { + if end < start { + return Err("Invalid range end".to_string()); + } + } + translated.push('-'); + range_start = None; + } else { + // Backslashes are ordinary members of a bracket expression, and + // the `regex` crate gives meaning to `[`, `&&`, `--` and `~~`. + push_literal(translated, c); + range_start = Some(c); + } + index += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn matches(pattern: &str, text: &str) -> bool { + Bre::new(pattern).unwrap().is_match(text.as_bytes()) + } + + #[test] + fn groups_and_alternatives() { + assert!(matches(r"^ *\(sub\|fn\) ", " fn main")); + assert!(matches(r"^ *\(sub\|fn\) ", "sub x")); + assert!(!matches(r"^ *\(sub\|fn\) ", " let x")); + // Unescaped operators of extended expressions are ordinary characters. + assert!(matches(r"f(x)|y+?", "f(x)|y+?")); + assert!(!matches(r"f(x)|y+?", "fx")); + } + + #[test] + fn intervals_and_repetitions() { + assert!(matches(r"^ab\{2,3\}c$", "abbc")); + assert!(!matches(r"^ab\{2,3\}c$", "abc")); + assert!(matches(r"^x\+$", "xxx")); + assert!(matches(r"^y\?z$", "z")); + // `*` with nothing to repeat is literal. + assert!(matches(r"*q", "a*q")); + assert!(matches(r"^*q", "*q")); + assert!(matches(r"\(*\)", "*")); + assert!(matches(r"to*k", "tk")); + } + + #[test] + fn anchors_only_at_the_ends() { + assert!(matches(r"a^b", "a^b")); + assert!(matches(r"a$b", "a$b")); + assert!(matches(r"end$", "the end")); + assert!(!matches(r"end$", "endless")); + assert!(matches(r"\(x$\|^y\)", "yes")); + } + + #[test] + fn bracket_expressions() { + assert!(matches(r"^[[:alpha:]$_]", "$var")); + assert!(matches(r"^[[:alpha:]$_]", "_x")); + assert!(!matches(r"^[[:alpha:]$_]", " x")); + assert!(matches(r"[]x]", "]")); + assert!(matches(r"[^]x]", "y")); + assert!(matches(r"[a\]", "\\")); + assert!(matches(r"[a-c]", "b")); + assert!(matches(r"[a-]", "-")); + assert!(matches(r"[&&~~[]", "[")); + } + + #[test] + fn errors() { + for (pattern, message) in [ + ("ab\\", "Trailing backslash"), + ("[ab", "Unmatched [, [^, [:, [., or [="), + ("[[:alpha:]", "Unmatched [, [^, [:, [., or [="), + (r"\(a\)\1", "Invalid back reference"), + (r"x\(y", r"Unmatched ( or \("), + (r"x\)y", r"Unmatched ) or \)"), + (r"k\{4", r"Unmatched \{"), + (r"k\{\}", r"Invalid content of \{\}"), + (r"k\{2,z\}", r"Invalid content of \{\}"), + (r"k\{5,2\}", r"Invalid content of \{\}"), + (r"k\{40000\}", "Regular expression too big"), + ("[[:vowel:]]", "Invalid character class name"), + ("[[.ab.]]", "Invalid collation character"), + ("[m-c]", "Invalid range end"), + ] { + assert_eq!( + Bre::new(pattern).err().as_deref(), + Some(message), + "{pattern}" + ); + } + } + + #[test] + fn literal_operators_with_nothing_to_repeat() { + assert!(matches(r"\{3\}", "{3}")); + assert!(matches(r"\(\+x\)", "+x")); + assert!(matches(r"q\{,2\}r", "r")); + assert!(matches(r"^q\{2,\}$", "qqqq")); + assert!(matches(r"[[.-.][=k=]]", "-")); + assert!(matches(r"[[.-.][=k=]]", "k")); + } +} diff --git a/src/cmp.rs b/src/cmp.rs index dc212d2..eddc542 100644 --- a/src/cmp.rs +++ b/src/cmp.rs @@ -3,8 +3,8 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. -use crate::utils::format_failure_to_read_input_file; -use std::env::{self, ArgsOs}; +use crate::utils::{format_failure_to_read_input_file, is_posix_locale}; +use std::env::ArgsOs; use std::ffi::OsString; use std::io::{BufRead, BufReader, BufWriter, Read, Write}; use std::iter::Peekable; @@ -664,20 +664,6 @@ fn report_eof( } } -fn is_posix_locale() -> bool { - let locale = if let Ok(locale) = env::var("LC_ALL") { - locale - } else if let Ok(locale) = env::var("LC_MESSAGES") { - locale - } else if let Ok(locale) = env::var("LANG") { - locale - } else { - "C".to_string() - }; - - locale == "C" || locale == "POSIX" -} - #[inline] fn report_difference( from_byte: u8, @@ -690,7 +676,7 @@ fn report_difference( return; } - let term = if is_posix_locale() && !params.print_bytes { + let term = if is_posix_locale("LC_MESSAGES") && !params.print_bytes { "char" } else { "byte" 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/context_diff.rs b/src/context_diff.rs index a37e776..334a661 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -8,7 +8,7 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::get_modification_time; +use crate::utils::{get_modification_time, is_posix_locale, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -315,8 +315,14 @@ fn make_diff( #[must_use] pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { - let from_modified_time = get_modification_time(¶ms.from.to_string_lossy()); - let to_modified_time = get_modification_time(¶ms.to.to_string_lossy()); + // The context format keeps the traditional timestamp in the POSIX locale. + let style = if is_posix_locale("LC_TIME") { + TimestampStyle::Traditional + } else { + TimestampStyle::Iso + }; + let from_modified_time = get_modification_time(¶ms.from.to_string_lossy(), style); + let to_modified_time = get_modification_time(¶ms.to.to_string_lossy(), style); let mut output = format!( "*** {0}\t{1}\n--- {2}\t{3}\n", params.from.to_string_lossy(), @@ -332,7 +338,11 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { if params.brief { return output; } + let mut function_lines = FunctionLineFinder::new(expected, ¶ms.function_line_patterns); for result in diff_results { + let function_line = function_lines + .as_mut() + .and_then(|finder| finder.find(result.line_number_expected)); let mut line_number_expected = result.line_number_expected; let mut line_number_actual = result.line_number_actual; let mut expected_count = result.expected.len(); @@ -357,11 +367,13 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { } else { format!("{line_number_actual},") }; - writeln!( - output, - "***************\n*** {exp_start}{end_line_number_expected} ****" - ) - .expect("write to Vec is infallible"); + output.extend_from_slice(b"***************"); + if let Some(function_line) = function_line { + output.push(b' '); + output.extend_from_slice(&function_line); + } + writeln!(output, "\n*** {exp_start}{end_line_number_expected} ****") + .expect("write to Vec is infallible"); if !result.expected_all_context { for line in result.expected { match line { @@ -995,4 +1007,39 @@ mod tests { let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); assert_diff_eq!(output, expected); } + + #[test] + fn test_function_lines() { + use crate::assert_diff_eq; + + let from = [ + "(defun area", + " (* w h))", + "(defun volume", + " (* w h d))", + "", + ] + .join("\n"); + let to = from.replace("d))", "depth))"); + let params = Params { + from: "old".into(), + to: "new".into(), + context_count: 0, + function_line_patterns: vec![crate::bre::Bre::new("^(").unwrap()], + ..Default::default() + }; + let expected = [ + "*** old\tTIMESTAMP", + "--- new\tTIMESTAMP", + "*************** (defun volume", + "*** 4 ****", + "! (* w h d))", + "--- 4 ----", + "! (* w h depth))", + "", + ] + .join("\n"); + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + assert_diff_eq!(output, expected); + } } diff --git a/src/diff.rs b/src/diff.rs index f4c0614..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::params::{parse_params, Format}; +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}; @@ -19,10 +20,16 @@ use std::process::{exit, ExitCode}; // 1 means some differences were found, // and 2 means trouble. pub fn main(opts: Peekable) -> ExitCode { - let params = parse_params(opts).unwrap_or_else(|error| { + let mut params = parse_params(opts).unwrap_or_else(|error| { eprintln!("{error}"); exit(2); }); + // diff - FILE with a directory on stdin => diff DIRECTORY/FILE FILE + if params.from == "-" && params.to != "-" { + if let Some(from) = stdin_directory_entry(¶ms.to) { + params.from = from; + } + } // if from and to are the same file, no need to perform any comparison let maybe_report_identical_files = || { if params.report_identical_files { @@ -52,6 +59,11 @@ pub fn main(opts: Peekable) -> ExitCode { let mut io_error = false; let from_content = match read_file_contents(¶ms.from) { Ok(from_content) => from_content, + Err(e) + if e.kind() == io::ErrorKind::NotFound && params.missing_file != MissingFile::Error => + { + vec![] + } Err(e) => { report_failure_to_read_input_file(¶ms.executable, ¶ms.from, &e); io_error = true; @@ -60,6 +72,11 @@ pub fn main(opts: Peekable) -> ExitCode { }; let to_content = match read_file_contents(¶ms.to) { Ok(to_content) => to_content, + Err(e) + if e.kind() == io::ErrorKind::NotFound && params.missing_file == MissingFile::Empty => + { + vec![] + } Err(e) => { report_failure_to_read_input_file(¶ms.executable, ¶ms.to, &e); io_error = true; @@ -70,6 +87,22 @@ pub fn main(opts: Peekable) -> ExitCode { return ExitCode::from(2); } + if !params.text && (is_binary(&from_content) || is_binary(&to_content)) { + if from_content == to_content { + maybe_report_identical_files(); + return ExitCode::SUCCESS; + } + print_files_differ( + if params.brief { + "Files" + } else { + "Binary files" + }, + ¶ms, + ); + return ExitCode::from(1); + } + // run diff let result: Vec = match params.format { Format::Normal => normal_diff::diff(&from_content, &to_content, ¶ms), @@ -85,13 +118,23 @@ pub fn main(opts: Peekable) -> ExitCode { } }; if params.brief && !result.is_empty() { - println!( - "Files {} and {} differ", - params.from.to_string_lossy(), - params.to.to_string_lossy() - ); + 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(); @@ -100,3 +143,83 @@ pub fn main(opts: Peekable) -> ExitCode { ExitCode::from(1) } } + +/// 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; + +/// An input is treated as binary when a NUL byte occurs near its start. +fn is_binary(content: &[u8]) -> bool { + content[..content.len().min(BINARY_PROBE_LEN)].contains(&0) +} + +/// Print the one-line summary used instead of the differences, `kind` naming +/// what was compared. +fn print_files_differ(kind: &str, params: &Params) { + println!( + "{kind} {} and {} differ", + params.from.to_string_lossy(), + params.to.to_string_lossy() + ); +} + +/// When standard input is a directory, returns the path of the entry in it +/// that has the same name as `other`, provided `other` is not a directory. +/// Entries are reached through `/dev/fd/0`, which only names the directory +/// itself where it links to the open file, as with Linux procfs. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn stdin_directory_entry(other: &OsString) -> Option { + use std::os::fd::AsFd; + use std::path::Path; + + let stdin_is_dir = io::stdin() + .as_fd() + .try_clone_to_owned() + .ok() + .and_then(|fd| fs::File::from(fd).metadata().ok()) + .is_some_and(|metadata| metadata.is_dir()); + let other = Path::new(other); + if !stdin_is_dir || other.is_dir() { + return None; + } + Some( + Path::new("/dev/fd/0") + .join(other.file_name()?) + .into_os_string(), + ) +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +fn stdin_directory_entry(_other: &OsString) -> Option { + None +} diff --git a/src/ed_diff.rs b/src/ed_diff.rs index 81ddfb4..f1ac5cd 100644 --- a/src/ed_diff.rs +++ b/src/ed_diff.rs @@ -126,6 +126,7 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Result, match (expected_count, actual_count) { (0, 0) => unreachable!(), (0, _) => writeln!(&mut output, "{}a", line_number_expected - 1).unwrap(), + (1, 0) => writeln!(&mut output, "{line_number_expected}d").unwrap(), (_, 0) => writeln!( &mut output, "{},{}d", @@ -180,6 +181,12 @@ mod tests { assert_eq!(diff, expected.as_bytes()); } + #[test] + fn test_single_line_deleted() { + let diff = diff(b"a\nb\n", b"a\n", &Params::default()).unwrap(); + assert_eq!(diff, b"2d\n"); + } + #[test] fn test_permutations() { let target = "target/ed-diff/"; diff --git a/src/lib.rs b/src/lib.rs index 342b01c..e1e43b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ +pub mod bre; pub mod cmp; +pub mod color; pub mod context_diff; pub mod ed_diff; pub mod macros; diff --git a/src/macros.rs b/src/macros.rs index 90a4eaa..3bcc3aa 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,5 +1,5 @@ // asserts equality of the actual diff and expected diff -// considering datetime varitations +// considering datetime varitations (ISO and traditional formats) // // It replaces the modification time in the actual diff // with placeholder "TIMESTAMP" and then asserts the equality @@ -17,7 +17,11 @@ macro_rules! assert_diff_eq { use std::str; let diff = str::from_utf8(&$actual).unwrap(); - let re = Regex::new(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ [+-]\d{4}").unwrap(); + let re = Regex::new(concat!( + r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ [+-]\d{4}", + r"|[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d{2}:\d{2}:\d{2} \d{4}" + )) + .unwrap(); let actual = re.replacen(diff, 2, "TIMESTAMP"); assert_eq!(actual, $expected); diff --git a/src/main.rs b/src/main.rs index d0adb69..3ee3c5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,9 @@ use std::{ process::ExitCode, }; +mod bre; mod cmp; +mod color; mod context_diff; mod diff; mod ed_diff; diff --git a/src/normal_diff.rs b/src/normal_diff.rs index 652dc84..c930b95 100644 --- a/src/normal_diff.rs +++ b/src/normal_diff.rs @@ -133,6 +133,13 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { let actual_count = result.actual.len(); match (expected_count, actual_count) { (0, 0) => unreachable!(), + (0, 1) => writeln!( + // exactly one line added + &mut output, + "{}a{line_number_actual}", + line_number_expected - 1 + ) + .unwrap(), (0, _) => writeln!( // 'a' stands for "Add lines" &mut output, @@ -142,6 +149,13 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { line_number_actual + actual_count - 1 ) .unwrap(), + (1, 0) => writeln!( + // exactly one line deleted + &mut output, + "{line_number_expected}d{}", + line_number_actual - 1 + ) + .unwrap(), (_, 0) => writeln!( // 'd' stands for "Delete lines" &mut output, @@ -228,6 +242,15 @@ mod tests { assert_eq!(diff, expected); } + #[test] + fn test_single_line_added_or_deleted() { + // A one-line range is written as a single number, as for c. + let added = diff(b"a\n", b"a\nb\n", &Params::default()); + assert_eq!(added, b"1a2\n> b\n".to_vec()); + let deleted = diff(b"a\nb\n", b"a\n", &Params::default()); + assert_eq!(deleted, b"2d1\n< b\n".to_vec()); + } + #[test] fn test_permutations() { let target = "target/normal-diff/"; diff --git a/src/params.rs b/src/params.rs index 239ebca..578bbaa 100644 --- a/src/params.rs +++ b/src/params.rs @@ -4,6 +4,9 @@ use std::path::PathBuf; use regex::Regex; +use crate::bre::Bre; +use crate::color::ColorWhen; + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Format { #[default] @@ -14,6 +17,19 @@ pub enum Format { SideBySide, } +/// How to handle an operand that does not exist (`-N` and +/// `--unidirectional-new-file`). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MissingFile { + /// A missing operand is an error. + #[default] + Error, + /// A missing operand is treated as an empty file. + Empty, + /// Only a missing first operand is treated as an empty file. + EmptyIfFirst, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct Params { pub executable: OsString, @@ -26,8 +42,20 @@ pub struct Params { pub expand_tabs: bool, pub tabsize: usize, 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, + /// Basic regular expressions matching the lines shown after hunk + /// headers (`-F` and `-p`). + pub function_line_patterns: Vec, } +/// The pattern of `-p`, matching the first line of C functions. +const C_FUNCTION_PATTERN: &str = "^[[:alpha:]$_]"; + impl Default for Params { fn default() -> Self { Self { @@ -41,6 +69,12 @@ impl Default for Params { expand_tabs: false, tabsize: 8, width: 130, + missing_file: MissingFile::default(), + text: false, + color: ColorWhen::default(), + palette: None, + presume_output_tty: false, + function_line_patterns: Vec::new(), } } } @@ -59,6 +93,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut to = None; let mut format = None; let mut context = None; + let mut show_c_function = false; let tabsize_re = Regex::new(r"^--tabsize=(?\d+)$").unwrap(); let width_re = Regex::new(r"^--width=(?P\d+)$").unwrap(); while let Some(param) = opts.next() { @@ -87,6 +122,72 @@ pub fn parse_params>(mut opts: Peekable) -> Resu params.brief = true; continue; } + if param == "-N" || param == "--new-file" { + params.missing_file = MissingFile::Empty; + continue; + } + if param == "--unidirectional-new-file" { + if params.missing_file != MissingFile::Empty { + params.missing_file = MissingFile::EmptyIfFirst; + } + 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 == "-p" || param == "--show-c-function" { + show_c_function = true; + push_function_line_pattern(&mut params, C_FUNCTION_PATTERN)?; + continue; + } + let attached_function_line = param.to_str().and_then(|p| { + p.strip_prefix("--show-function-line=") + .or_else(|| p.strip_prefix("-F").filter(|pattern| !pattern.is_empty())) + }); + if let Some(pattern) = attached_function_line { + push_function_line_pattern(&mut params, pattern)?; + continue; + } + if param == "-F" || param == "--show-function-line" { + let Some(pattern) = next_param.map(|p| p.to_string_lossy().into_owned()) else { + return Err(format!( + "option '{}' requires an argument", + param.to_string_lossy() + )); + }; + opts.next(); + push_function_line_pattern(&mut params, &pattern)?; + continue; + } + if param == "-a" || param == "--text" { + params.text = true; + continue; + } if param == "-t" || param == "--expand-tabs" { params.expand_tabs = true; continue; @@ -242,13 +343,24 @@ pub fn parse_params>(mut opts: Peekable) -> Resu params.to = to_path.into_os_string(); } - params.format = format.unwrap_or(Format::default()); + params.format = match format { + Some(format) => format, + None if show_c_function => Format::Context, + None => Format::default(), + }; if let Some(context_count) = context { params.context_count = context_count; } Ok(params) } +/// Validates and records a `-F` pattern. +fn push_function_line_pattern(params: &mut Params, pattern: &str) -> Result<(), String> { + let bre = Bre::new(pattern).map_err(|error| format!("'{pattern}': {error}"))?; + params.function_line_patterns.push(bre); + Ok(()) +} + struct DiffStyleMatch { is_match: bool, context_count: Option, @@ -685,6 +797,26 @@ mod tests { ); } #[test] + fn text() { + for option in ["-a", "--text"] { + assert_eq!( + Ok(Params { + executable: os("diff"), + from: os("old.bin"), + to: os("new.bin"), + text: true, + ..Default::default() + }), + parse_params( + [os("diff"), os(option), os("old.bin"), os("new.bin")] + .iter() + .cloned() + .peekable() + ) + ); + } + } + #[test] fn expand_tabs() { assert_eq!( Ok(Params { @@ -966,4 +1098,63 @@ 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}"); + } + } + + #[test] + fn function_lines() { + let patterns = |patterns: &[&str]| -> Vec { + patterns + .iter() + .map(|pattern| Bre::new(pattern).unwrap()) + .collect() + }; + let parse = |args: &[&str]| { + let args = std::iter::once("diff") + .chain(args.iter().copied()) + .chain(["foo", "bar"]); + parse_params(args.map(os).peekable()) + }; + let params = parse(&["-p"]).unwrap(); + assert_eq!(params.format, Format::Context); + assert_eq!( + params.function_line_patterns, + patterns(&[C_FUNCTION_PATTERN]) + ); + + let params = parse(&["-u", "-p"]).unwrap(); + assert_eq!(params.format, Format::Unified); + + let params = parse(&["-F", "^fn", "-Fx", "--show-function-line=^#", "-e"]).unwrap(); + assert_eq!(params.format, Format::Ed); + assert_eq!(params.function_line_patterns, patterns(&["^fn", "x", "^#"])); + + // -F alone selects no output format. + assert_eq!(parse(&["-F", "^fn"]).unwrap().format, Format::Normal); + + assert_eq!( + parse(&["-F", "oops\\"]), + Err("'oops\\': Trailing backslash".to_string()) + ); + } } diff --git a/src/unified_diff.rs b/src/unified_diff.rs index bd83afe..094b4e1 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -8,7 +8,7 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::get_modification_time; +use crate::utils::{get_modification_time, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -242,8 +242,9 @@ fn make_diff( #[must_use] pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { - let from_modified_time = get_modification_time(¶ms.from.to_string_lossy()); - let to_modified_time = get_modification_time(¶ms.to.to_string_lossy()); + let from_modified_time = + get_modification_time(¶ms.from.to_string_lossy(), TimestampStyle::Iso); + let to_modified_time = get_modification_time(¶ms.to.to_string_lossy(), TimestampStyle::Iso); let mut output = format!( "--- {0}\t{1}\n+++ {2}\t{3}\n", params.from.to_string_lossy(), @@ -259,7 +260,11 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { if params.brief { return output; } + let mut function_lines = FunctionLineFinder::new(expected, ¶ms.function_line_patterns); for result in diff_results { + let function_line = function_lines + .as_mut() + .and_then(|finder| finder.find(result.line_number_expected as usize)); let mut line_number_expected = result.line_number_expected; let mut line_number_actual = result.line_number_actual; let mut expected_count = 0; @@ -371,11 +376,16 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { } else { format!(",{actual_count}") }; - writeln!( + write!( output, "@@ -{line_number_expected}{exp_ct} +{line_number_actual}{act_ct} @@" ) .expect("write to Vec is infallible"); + if let Some(function_line) = function_line { + output.push(b' '); + output.extend_from_slice(&function_line); + } + output.push(b'\n'); for line in result.lines { match line { DiffLine::Expected(e) => { @@ -1029,4 +1039,57 @@ mod tests { let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); assert_diff_eq!(output, expected); } + + #[test] + fn test_function_lines() { + use crate::assert_diff_eq; + + let from = [ + "section Apples", + " red", + " green", + " section Pears with a heading longer than forty characters", + " ripe", + " hard", + " sweet", + " sour", + "", + ] + .join("\n"); + let to = from.replace("red", "crimson").replace("sour", "tart"); + let params = Params { + from: "old".into(), + to: "new".into(), + context_count: 0, + function_line_patterns: vec![ + crate::bre::Bre::new(r"^ *section \(Apples\|Pears\)").unwrap() + ], + ..Default::default() + }; + // The heading is found before the hunk, trimmed and cut at 40 + // characters, and the closest heading wins. + let expected = [ + "--- old\tTIMESTAMP", + "+++ new\tTIMESTAMP", + "@@ -2 +2 @@ section Apples", + "- red", + "+ crimson", + "@@ -8 +8 @@ section Pears with a heading longer than", + "- sour", + "+ tart", + "", + ] + .join("\n"); + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + assert_diff_eq!(output, expected); + + // Nothing is shown when no line precedes the hunk. + let params = Params { + context_count: 1, + ..params + }; + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("\n@@ -1,3 +1,3 @@\n")); + } } diff --git a/src/utils.rs b/src/utils.rs index d9415af..8e35bc9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. +use crate::bre::Bre; use regex::Regex; use std::{ffi::OsString, io::Write}; use unicode_width::UnicodeWidthStr; @@ -52,9 +53,79 @@ pub fn do_write_line( } } +/// Finds the lines shown after hunk headers by `-F` and `-p`: the nearest +/// line of the old file preceding a hunk that matches one of the patterns. +pub struct FunctionLineFinder<'a> { + lines: Vec<&'a [u8]>, + patterns: &'a [Bre], + /// Index of the first line not examined yet. + next_line: usize, + last_match: Option<&'a [u8]>, +} + +impl<'a> FunctionLineFinder<'a> { + /// Maximum number of characters of a function line that are shown. + const MAX_WIDTH: usize = 40; + + /// Returns `None` when there are no patterns, so no function lines. + pub fn new(old_text: &'a [u8], patterns: &'a [Bre]) -> Option { + if patterns.is_empty() { + return None; + } + Some(Self { + lines: old_text.split(|&byte| byte == b'\n').collect(), + patterns, + next_line: 0, + last_match: None, + }) + } + + /// Returns the text to show after the header of a hunk starting at + /// `first_line` (1-based) of the old file, if any. Hunks must be queried + /// in increasing order. + pub fn find(&mut self, first_line: usize) -> Option> { + let end = first_line.saturating_sub(1).min(self.lines.len()); + for line in &self.lines[self.next_line.min(end)..end] { + if self.patterns.iter().any(|pattern| pattern.is_match(line)) { + self.last_match = Some(line); + } + } + self.next_line = self.next_line.max(end); + let line = self.last_match?.trim_ascii_start(); + let end = match std::str::from_utf8(line) { + Ok(text) => text + .char_indices() + .nth(Self::MAX_WIDTH) + .map_or(line.len(), |(end, _)| end), + Err(_) => line.len().min(Self::MAX_WIDTH), + }; + Some(line[..end].trim_ascii_end().to_vec()) + } +} + +/// How a modification time is rendered in a diff header. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimestampStyle { + /// `2026-03-05 07:08:09.500000000 +0100` + Iso, + /// `Thu Mar 5 07:08:09 2026` + Traditional, +} + +/// Returns whether the locale selected for `category` (e.g. `LC_TIME`) is the +/// POSIX one, following the `LC_ALL` > category > `LANG` precedence, where an +/// empty variable counts as unset. +pub fn is_posix_locale(category: &str) -> bool { + let locale = ["LC_ALL", category, "LANG"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + .unwrap_or_else(|| "C".to_string()); + locale == "C" || locale == "POSIX" +} + /// Retrieves the modification time of the input file specified by file path /// If an error occurs, it returns the current system time -pub fn get_modification_time(file_path: &str) -> String { +pub fn get_modification_time(file_path: &str, style: TimestampStyle) -> String { use chrono::{DateTime, Local}; use std::fs; use std::time::SystemTime; @@ -64,11 +135,11 @@ pub fn get_modification_time(file_path: &str) -> String { .unwrap_or(SystemTime::now()); let modification_time: DateTime = modification_time.into(); - let modification_time: String = modification_time - .format("%Y-%m-%d %H:%M:%S%.9f %z") - .to_string(); - - modification_time + let format = match style { + TimestampStyle::Iso => "%Y-%m-%d %H:%M:%S%.9f %z", + TimestampStyle::Traditional => "%a %b %e %H:%M:%S %Y", + }; + modification_time.format(format).to_string() } pub fn format_failure_to_read_input_file( @@ -277,7 +348,7 @@ mod tests { // verify assert_eq!( current, - get_modification_time(&temp.path().to_string_lossy()) + get_modification_time(&temp.path().to_string_lossy(), TimestampStyle::Iso) ); } @@ -292,7 +363,9 @@ mod tests { // Because the file is invalid, it will return SystemTime::now() // which will be greater than previously saved time let current_time: DateTime = SystemTime::now().into(); - let m_time: DateTime = get_modification_time(invalid_file).parse().unwrap(); + let m_time: DateTime = get_modification_time(invalid_file, TimestampStyle::Iso) + .parse() + .unwrap(); assert!(m_time > current_time); } diff --git a/tests/by-util/test_cmp.rs b/tests/by-util/test_cmp.rs index a36d16a..ea4dd2b 100644 --- a/tests/by-util/test_cmp.rs +++ b/tests/by-util/test_cmp.rs @@ -611,3 +611,45 @@ fn cmp_fast_paths() -> Result<(), Box> { Ok(()) } + +#[test] +fn cmp_difference_term_follows_messages_locale() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let a_path = tmp_dir.path().join("a"); + let b_path = tmp_dir.path().join("b"); + std::fs::write(&a_path, "abc\n")?; + std::fs::write(&b_path, "bcd\n")?; + + // LC_ALL overrides LC_MESSAGES, which overrides LANG; empty values are unset. + for (variables, term) in [ + (&[][..], "char"), + ( + &[("LC_ALL", "C"), ("LC_MESSAGES", "en_US.UTF-8")][..], + "char", + ), + (&[("LC_ALL", "en_US.UTF-8"), ("LC_MESSAGES", "C")], "byte"), + (&[("LC_MESSAGES", "POSIX"), ("LANG", "en_US.UTF-8")], "char"), + (&[("LC_MESSAGES", "en_US.UTF-8"), ("LANG", "C")], "byte"), + (&[("LC_ALL", ""), ("LANG", "en_US.UTF-8")], "byte"), + ( + &[("LC_ALL", ""), ("LC_MESSAGES", ""), ("LANG", "C")], + "char", + ), + ] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.env_remove("LC_ALL") + .env_remove("LC_MESSAGES") + .env_remove("LANG") + .envs(variables.iter().copied()) + .arg("cmp") + .arg(&a_path) + .arg(&b_path); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stdout(predicate::str::ends_with(format!( + " differ: {term} 1, line 1\n" + ))); + } + Ok(()) +} diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index fbdd494..ae5fbf8 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -5,6 +5,8 @@ use diffutilslib::assert_diff_eq; +#[cfg(any(target_os = "linux", target_os = "android"))] +use assert_cmd::assert::OutputAssertExt; use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; use std::fs::File; @@ -206,6 +208,273 @@ fn read_from_stdin() -> Result<(), Box> { Ok(()) } +#[test] +fn missing_file_as_empty() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let present = tmp_dir.path().join("present"); + let absent = tmp_dir.path().join("absent"); + std::fs::write(&present, "kiwi\nmango\n")?; + + for option in ["-N", "--new-file", "--unidirectional-new-file"] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(option).arg(&absent).arg(&present); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stdout("0a1,2\n> kiwi\n> mango\n"); + } + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-N").arg(&present).arg(&absent); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stdout("1,2d0\n< kiwi\n< mango\n"); + + // Only a missing first operand is accepted here. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--unidirectional-new-file") + .arg(&present) + .arg(&absent); + cmd.assert().code(predicate::eq(2)).failure(); + + // Without the option, a missing file is trouble. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(&absent).arg(&present); + cmd.assert().code(predicate::eq(2)).failure(); + Ok(()) +} + +#[test] +fn binary_files() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let left = tmp_dir.path().join("left.bin"); + let right = tmp_dir.path().join("right.bin"); + std::fs::write(&left, b"header\x00\x7fpayload-one\n")?; + std::fs::write(&right, b"header\x00\x7fpayload-two\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(&left).arg(&right); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stderr(predicate::str::is_empty()) + .stdout(format!( + "Binary files {} and {} differ\n", + left.display(), + right.display() + )); + + // Identical binary inputs are not reported. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(&left).arg("-"); + cmd.write_stdin(b"header\x00\x7fpayload-one\n".to_vec()); + cmd.assert().code(predicate::eq(0)).success().stdout(""); + + // -q keeps its usual wording. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-q").arg(&left).arg(&right); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stderr(predicate::str::is_empty()) + .stdout(format!( + "Files {} and {} differ\n", + left.display(), + right.display() + )); + + // -a and --text force a line-by-line comparison. + for option in ["-a", "--text"] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(option).arg(&left).arg(&right); + cmd.assert() + .code(predicate::eq(1)) + .failure() + .stdout(predicate::str::starts_with("1c1\n")); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +#[test] +fn stdin_is_directory() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let dir = tmp_dir.path().join("dir"); + std::fs::create_dir(&dir)?; + std::fs::write(dir.join("notes"), "alpha\n")?; + let notes = tmp_dir.path().join("notes"); + std::fs::write(¬es, "beta\n")?; + + // The directory itself must be standard input, not its contents. + let output = std::process::Command::new(assert_cmd::cargo::cargo_bin!("diffutils")) + .arg("diff") + .arg("-") + .arg(¬es) + .stdin(File::open(&dir)?) + .output()?; + output + .assert() + .code(predicate::eq(1)) + .failure() + .stdout("1c1\n< alpha\n---\n> beta\n"); + 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(()) +} + +// TZ is not honored on Windows. +#[cfg(unix)] +#[test] +fn context_timestamp_follows_time_locale() -> 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")?; + // 2009-02-13 23:31:30.25 UTC + let mtime = std::time::UNIX_EPOCH + std::time::Duration::from_millis(1_234_567_890_250); + for path in [&old, &new] { + File::options() + .write(true) + .open(path)? + .set_modified(mtime)?; + } + + let traditional = "Fri Feb 13 23:31:30 2009"; + let iso = "2009-02-13 23:31:30.250000000 +0000"; + // LC_ALL overrides LC_TIME, which overrides LANG; empty values are unset. + for (variables, timestamp) in [ + (&[("LC_ALL", "C")][..], traditional), + ( + &[("LC_ALL", "POSIX"), ("LC_TIME", "en_US.UTF-8")], + traditional, + ), + (&[("LC_ALL", "en_US.UTF-8"), ("LC_TIME", "C")], iso), + (&[("LC_TIME", "C"), ("LANG", "en_US.UTF-8")], traditional), + (&[("LC_TIME", "en_US.UTF-8"), ("LANG", "C")], iso), + ( + &[("LC_ALL", ""), ("LC_TIME", ""), ("LANG", "POSIX")], + traditional, + ), + (&[("LANG", "fr_FR.UTF-8")], iso), + ] { + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.env("TZ", "UTC0") + .env_remove("LC_ALL") + .env_remove("LC_TIME") + .env_remove("LANG") + .envs(variables.iter().copied()) + .arg("diff") + .arg("-c") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::starts_with(format!( + "*** {}\t{timestamp}\n--- {}\t{timestamp}\n", + old.display(), + new.display() + ))); + } + Ok(()) +} + #[test] fn compare_file_to_directory() -> Result<(), Box> { let tmp_dir = tempdir()?; @@ -288,3 +557,100 @@ fn sdiff_gutter_marker_column() -> Result<(), Box> { Ok(()) } + +#[test] +fn normal_format_single_line_ranges() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let old = tmp_dir.path().join("old"); + let new = tmp_dir.path().join("new"); + std::fs::write(&old, "north\neast\nsouth\nwest\n")?; + std::fs::write(&new, "north\nsouth\nwest\nup\ndown\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(&old).arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout("2d1\n< east\n4a4,5\n> up\n> down\n"); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("/dev/null").arg(&old); + if cfg!(unix) { + cmd.assert() + .code(predicate::eq(1)) + .stdout("0a1,4\n> north\n> east\n> south\n> west\n"); + } + + std::fs::write(&new, "north\neast\nsouth\nwest\nup\n")?; + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(&old).arg(&new); + cmd.assert().code(predicate::eq(1)).stdout("4a5\n> up\n"); + Ok(()) +} + +#[test] +fn ed_format_single_line_deletions() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let old = tmp_dir.path().join("old"); + let new = tmp_dir.path().join("new"); + std::fs::write(&old, "red\norange\nyellow\ngreen\nblue\n")?; + std::fs::write(&new, "red\norange\nyellow\nblue\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-e").arg(&old).arg(&new); + cmd.assert().code(predicate::eq(1)).stdout("4d\n"); + + std::fs::write(&new, "red\nblue\n")?; + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-e").arg(&old).arg(&new); + cmd.assert().code(predicate::eq(1)).stdout("2,4d\n"); + Ok(()) +} + +#[test] +fn function_lines() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let old = tmp_dir.path().join("old.c"); + let new = tmp_dir.path().join("new.c"); + std::fs::write( + &old, + "int zero;\nstatic int twice(int n)\n{\n int r;\n r = n * 2;\n return r;\n}\n", + )?; + std::fs::write( + &new, + "int zero;\nstatic int twice(int n)\n{\n int r;\n r = n + n;\n return r;\n}\n", + )?; + + // -p defaults to the context format and shows the enclosing C function. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-p").arg("-C1").arg(&old).arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::contains( + "\n*************** static int twice(int n)\n*** 4,6 ****\n", + )); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-U0") + .arg("-F") + .arg("^int") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::ends_with( + "@@ -5 +5 @@ int zero;\n- r = n * 2;\n+ r = n + n;\n", + )); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-u") + .arg("-F") + .arg("\\(") + .arg(&old) + .arg(&new); + cmd.assert() + .code(predicate::eq(2)) + .stderr("'\\(': Unmatched ( or \\(\n"); + Ok(()) +}