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 3ff716f..82df925 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..f0f2710 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -8,7 +8,9 @@ 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, write_file_header, FunctionLineFinder, TimestampStyle, +}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -315,16 +317,17 @@ 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 mut output = format!( - "*** {0}\t{1}\n--- {2}\t{3}\n", - params.from.to_string_lossy(), - from_modified_time, - params.to.to_string_lossy(), - to_modified_time - ) - .into_bytes(); + // 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 = Vec::new(); + write_file_header(&mut output, "***", ¶ms.from, &from_modified_time); + write_file_header(&mut output, "---", ¶ms.to, &to_modified_time); let diff_results = make_diff(expected, actual, params.context_count, params.brief); if diff_results.is_empty() { return Vec::new(); @@ -332,7 +335,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 +364,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 +1004,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..81298f2 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -3,27 +3,72 @@ // 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 crate::{context_diff, dir_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}; +use std::path::Path; +use std::process::ExitCode; + +/// The result of a comparison, from best to worst, so that the outcome of +/// several comparisons is their maximum. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum Outcome { + Identical, + Different, + Trouble, +} // Exit codes are documented at // https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff.html. // An exit status of 0 means no differences were found, // 1 means some differences were found, // and 2 means trouble. +impl From for ExitCode { + fn from(outcome: Outcome) -> Self { + match outcome { + Outcome::Identical => Self::SUCCESS, + Outcome::Different => Self::from(1), + Outcome::Trouble => Self::from(2), + } + } +} + pub fn main(opts: Peekable) -> ExitCode { - let params = parse_params(opts).unwrap_or_else(|error| { - eprintln!("{error}"); - exit(2); - }); - // if from and to are the same file, no need to perform any comparison + let (mut params, option_args) = match parse_params(opts) { + Ok(parsed) => parsed, + Err(error) => { + eprintln!("{error}"); + return ExitCode::from(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; + } + } + let both_directories = params.from != "-" + && params.to != "-" + && Path::new(¶ms.from).is_dir() + && Path::new(¶ms.to).is_dir(); + let outcome = if both_directories { + dir_diff::compare_directories(¶ms, &option_args) + } else { + compare_files(¶ms, None) + }; + outcome.into() +} + +/// Compares the files `params.from` and `params.to`, printing their +/// differences. When the files differ, `header` is printed first unless only +/// a brief report is requested. +pub fn compare_files(params: &Params, header: Option<&[u8]>) -> Outcome { let maybe_report_identical_files = || { if params.report_identical_files { println!( @@ -33,11 +78,12 @@ pub fn main(opts: Peekable) -> ExitCode { ); } }; + // if from and to are the same file, no need to perform any comparison if params.from == "-" && params.to == "-" || same_file::is_same_file(¶ms.from, ¶ms.to).unwrap_or(false) { maybe_report_identical_files(); - return ExitCode::SUCCESS; + return Outcome::Identical; } // read files @@ -52,6 +98,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 +111,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; @@ -67,36 +123,164 @@ pub fn main(opts: Peekable) -> ExitCode { } }; if io_error { - return ExitCode::from(2); + return Outcome::Trouble; + } + + if !params.text && (is_binary(&from_content) || is_binary(&to_content)) { + if from_content == to_content { + maybe_report_identical_files(); + return Outcome::Identical; + } + print_files_differ( + if params.brief { + "Files" + } else { + "Binary files" + }, + params, + ); + return Outcome::Different; } // run diff let result: Vec = match params.format { - Format::Normal => normal_diff::diff(&from_content, &to_content, ¶ms), - Format::Unified => unified_diff::diff(&from_content, &to_content, ¶ms), - Format::Context => context_diff::diff(&from_content, &to_content, ¶ms), - Format::Ed => ed_diff::diff(&from_content, &to_content, ¶ms).unwrap_or_else(|error| { - eprintln!("{error}"); - exit(2); - }), - Format::SideBySide => { - let mut output = stdout().lock(); - side_diff::diff(&from_content, &to_content, &mut output, ¶ms) - } + Format::Normal => normal_diff::diff(&from_content, &to_content, params), + Format::Unified => unified_diff::diff(&from_content, &to_content, params), + Format::Context => context_diff::diff(&from_content, &to_content, params), + Format::Ed => match ed_diff::diff(&from_content, &to_content, params) { + Ok(result) => result, + Err(error) => { + eprintln!("{error}"); + return Outcome::Trouble; + } + }, + // Side by side output is streamed, unless a header may have to come + // first. + Format::SideBySide => match header { + None => side_diff::diff(&from_content, &to_content, &mut stdout().lock(), params), + Some(header) => { + let mut output = Vec::new(); + let result = side_diff::diff(&from_content, &to_content, &mut output, params); + if !result.is_empty() { + output.splice(0..0, header.iter().copied()); + } + if stdout().write_all(&output).is_err() { + return Outcome::Trouble; + } + result + } + }, }; if params.brief && !result.is_empty() { - println!( - "Files {} and {} differ", - params.from.to_string_lossy(), - params.to.to_string_lossy() - ); - } else { - io::stdout().write_all(&result).unwrap(); + print_files_differ("Files", params); + } else if params.format != Format::SideBySide { + let header = if result.is_empty() { + &[][..] + } else { + header.unwrap_or_default() + }; + let colored = + output_palette(params).map(|palette| colorize(&result, params.format, &palette)); + let mut stdout = stdout().lock(); + let written = stdout + .write_all(header) + .and_then(|()| stdout.write_all(colored.as_deref().unwrap_or(&result))) + .and_then(|()| stdout.flush()); + if let Err(error) = written { + // 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 Outcome::Trouble; + } } if result.is_empty() { maybe_report_identical_files(); - ExitCode::SUCCESS + Outcome::Identical } else { - ExitCode::from(1) + Outcome::Different } } + +/// 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; + + 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/dir_diff.rs b/src/dir_diff.rs new file mode 100644 index 0000000..d8d90da --- /dev/null +++ b/src/dir_diff.rs @@ -0,0 +1,357 @@ +// 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. + +//! Comparison of two directories, entry by entry, descending into +//! subdirectories with `-r`. + +use std::borrow::Cow; +use std::cmp::Ordering; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, Metadata}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::diff::{compare_files, Outcome}; +use crate::params::{MissingFile, Params}; +use crate::utils::{ + fold_file_name_case, quote_file_name, quote_shell_argument, report_failure_to_read_input_file, +}; + +/// Compares the directories `params.from` and `params.to`. `option_args` are +/// the command line options, repeated in the header printed before the +/// differences of each pair of files. +pub fn compare_directories(params: &Params, option_args: &[OsString]) -> Outcome { + // As for subdirectories, a directory is not compared with itself. + if same_file::is_same_file(¶ms.from, ¶ms.to).unwrap_or(false) { + return Outcome::Identical; + } + let mut walker = DirectoryWalker { + params, + option_args, + ancestors: Vec::new(), + }; + walker.compare_directories(¶ms.from, ¶ms.to) +} + +struct DirectoryWalker<'a> { + params: &'a Params, + option_args: &'a [OsString], + /// Canonical paths of the directories being compared, from the top, to + /// detect symbolic links looping back to one of them. + ancestors: Vec, +} + +impl DirectoryWalker<'_> { + fn compare_directories(&mut self, from_dir: &OsStr, to_dir: &OsStr) -> Outcome { + let mut outcome = Outcome::Identical; + let mut canonical_dirs = Vec::new(); + for dir in [from_dir, to_dir] { + let Ok(canonical) = fs::canonicalize(dir) else { + // A directory missing on purpose (-N) has no entries. + continue; + }; + if self.ancestors.contains(&canonical) { + eprintln!( + "{}: {}: recursive directory loop", + self.params.executable.to_string_lossy(), + dir.to_string_lossy() + ); + return Outcome::Trouble; + } + canonical_dirs.push(canonical); + } + let (Some(mut from_names), Some(mut to_names)) = (self.list(from_dir), self.list(to_dir)) + else { + return Outcome::Trouble; + }; + // -S only applies to the operands, not to their subdirectories. + if let (true, Some(start)) = (self.ancestors.is_empty(), &self.params.starting_file) { + let start = self.name_key(start).into_owned(); + from_names.retain(|name| *self.name_key(name) >= *start); + to_names.retain(|name| *self.name_key(name) >= *start); + } + let ancestors_len = self.ancestors.len(); + self.ancestors.extend(canonical_dirs); + + let entries = pair_entries(&from_names, &to_names, |from, to| { + self.name_key(from).cmp(&self.name_key(to)) + }); + for entry in entries { + let entry_outcome = match entry { + Entry::Both(from, to) => { + self.compare_entries(&join(from_dir, from), &join(to_dir, to)) + } + Entry::FromOnly(name) if self.params.missing_file == MissingFile::Empty => { + self.compare_entries(&join(from_dir, name), &join(to_dir, name)) + } + Entry::FromOnly(name) => report_only_in(from_dir, name), + Entry::ToOnly(name) if self.params.missing_file == MissingFile::Error => { + report_only_in(to_dir, name) + } + Entry::ToOnly(name) => { + self.compare_entries(&join(from_dir, name), &join(to_dir, name)) + } + }; + outcome = outcome.max(entry_outcome); + } + self.ancestors.truncate(ancestors_len); + outcome + } + + /// Returns the sorted names of the entries of `dir`, or `None` after + /// reporting an error. A directory missing on purpose (-N) is empty. + fn list(&self, dir: &OsStr) -> Option> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) + if error.kind() == io::ErrorKind::NotFound + && self.params.missing_file != MissingFile::Error => + { + return Some(Vec::new()); + } + Err(error) => { + self.report_error(dir, &error); + return None; + } + }; + let mut names = Vec::new(); + for entry in entries { + match entry { + Ok(entry) => names.push(entry.file_name()), + Err(error) => { + self.report_error(dir, &error); + return None; + } + } + } + names.sort_unstable_by(|a, b| a.as_encoded_bytes().cmp(b.as_encoded_bytes())); + if self.params.ignore_file_name_case { + // A stable sort, so names equal ignoring case stay in byte order. + names.sort_by_cached_key(|name| fold_file_name_case(name)); + } + Some(names) + } + + /// Returns what entry names are sorted and matched by: the names + /// themselves, or their lowercase form with --ignore-file-name-case. + fn name_key<'n>(&self, name: &'n OsStr) -> Cow<'n, [u8]> { + if self.params.ignore_file_name_case { + Cow::Owned(fold_file_name_case(name)) + } else { + Cow::Borrowed(name.as_encoded_bytes()) + } + } + + /// Compares two entries of the compared directories, one of which may be + /// missing when missing files are treated as empty. + fn compare_entries(&mut self, from: &OsStr, to: &OsStr) -> Outcome { + let (Ok(from_metadata), Ok(to_metadata)) = (self.metadata(from), self.metadata(to)) else { + return Outcome::Trouble; + }; + match (from_metadata, to_metadata) { + (Some(from_metadata), Some(to_metadata)) + if from_metadata.is_dir() && to_metadata.is_dir() => + { + if same_file::is_same_file(from, to).unwrap_or(false) { + Outcome::Identical + } else { + self.compare_subdirectories(from, to) + } + } + (Some(from_metadata), Some(to_metadata)) + if !(from_metadata.is_file() && to_metadata.is_file()) => + { + let (from_kind, to_kind) = (file_kind(&from_metadata), file_kind(&to_metadata)); + println!( + "File {} is a {from_kind} while file {} is a {to_kind}", + from.to_string_lossy(), + to.to_string_lossy() + ); + Outcome::Different + } + // A directory on one side only, the other being missing on + // purpose (-N), is compared with an empty directory. + (Some(metadata), None) | (None, Some(metadata)) if metadata.is_dir() => { + self.compare_subdirectories(from, to) + } + _ => self.compare_files(from, to), + } + } + + /// Descends into two subdirectories with -r, or only reports them. + fn compare_subdirectories(&mut self, from: &OsStr, to: &OsStr) -> Outcome { + if self.params.recursive { + self.compare_directories(from, to) + } else { + println!( + "Common subdirectories: {} and {}", + from.to_string_lossy(), + to.to_string_lossy() + ); + Outcome::Identical + } + } + + fn compare_files(&self, from: &OsStr, to: &OsStr) -> Outcome { + let params = Params { + from: from.to_os_string(), + to: to.to_os_string(), + ..self.params.clone() + }; + let mut header = b"diff".to_vec(); + for arg in self.option_args { + header.push(b' '); + header.extend_from_slice("e_shell_argument(arg.as_encoded_bytes())); + } + for path in [from, to] { + header.push(b' '); + header.extend_from_slice("e_file_name(path.as_encoded_bytes())); + } + header.push(b'\n'); + compare_files(¶ms, Some(&header)) + } + + /// Returns the metadata of `path`, `None` if it is missing on purpose + /// (-N), or an error after reporting it. + fn metadata(&self, path: &OsStr) -> Result, ()> { + match fs::metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && self.params.missing_file != MissingFile::Error + && fs::symlink_metadata(path).is_err() => + { + Ok(None) + } + Err(error) => { + self.report_error(path, &error); + Err(()) + } + } + } + + fn report_error(&self, path: &OsStr, error: &io::Error) { + report_failure_to_read_input_file(&self.params.executable, &path.to_os_string(), error); + } +} + +/// An entry of either or both of the compared directories. +#[derive(Debug, PartialEq)] +enum Entry<'a> { + Both(&'a OsStr, &'a OsStr), + FromOnly(&'a OsStr), + ToOnly(&'a OsStr), +} + +/// Merges two lists of names sorted by `order`, pairing the names that are +/// equal according to it. +fn pair_entries<'a>( + from_names: &'a [OsString], + to_names: &'a [OsString], + order: impl Fn(&OsStr, &OsStr) -> Ordering, +) -> Vec> { + let mut entries = Vec::with_capacity(from_names.len().max(to_names.len())); + let mut from_names = from_names.iter().map(OsString::as_os_str).peekable(); + let mut to_names = to_names.iter().map(OsString::as_os_str).peekable(); + loop { + let entry = match (from_names.peek(), to_names.peek()) { + (None, None) => return entries, + (Some(&from), Some(&to)) => match order(from, to) { + Ordering::Equal => Entry::Both(from, to), + Ordering::Less => Entry::FromOnly(from), + Ordering::Greater => Entry::ToOnly(to), + }, + (Some(&from), None) => Entry::FromOnly(from), + (None, Some(&to)) => Entry::ToOnly(to), + }; + match entry { + Entry::Both(..) => { + from_names.next(); + to_names.next(); + } + Entry::FromOnly(_) => { + from_names.next(); + } + Entry::ToOnly(_) => { + to_names.next(); + } + } + entries.push(entry); + } +} + +fn report_only_in(dir: &OsStr, name: &OsStr) -> Outcome { + println!( + "Only in {}: {}", + dir.to_string_lossy(), + name.to_string_lossy() + ); + Outcome::Different +} + +/// Joins a directory and an entry name, without doubling the separator when +/// the directory was given with trailing slashes. +fn join(dir: &OsStr, name: &OsStr) -> OsString { + let dir: PathBuf = Path::new(dir).components().collect(); + dir.join(name).into_os_string() +} + +/// Describes the type of a file, as in "File X is a fifo while ...". +fn file_kind(metadata: &Metadata) -> &'static str { + let file_type = metadata.file_type(); + if file_type.is_dir() { + return "directory"; + } + if file_type.is_file() { + return if metadata.len() == 0 { + "regular empty file" + } else { + "regular file" + }; + } + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + if file_type.is_fifo() { + return "fifo"; + } + if file_type.is_socket() { + return "socket"; + } + if file_type.is_char_device() { + return "character special file"; + } + if file_type.is_block_device() { + return "block special file"; + } + } + "weird file" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entries_are_paired_by_name() { + let names = + |names: &[&str]| -> Vec { names.iter().map(OsString::from).collect() }; + let (from, to) = (names(&["b", "d", "e", "x"]), names(&["a", "d", "x", "z"])); + let entries = pair_entries(&from, &to, |a, b| a.cmp(b)); + let name = OsStr::new; + assert_eq!( + entries, + [ + Entry::ToOnly(name("a")), + Entry::FromOnly(name("b")), + Entry::Both(name("d"), name("d")), + Entry::FromOnly(name("e")), + Entry::Both(name("x"), name("x")), + Entry::ToOnly(name("z")), + ] + ); + assert_eq!(pair_entries(&[], &[], |a, b| a.cmp(b)), []); + } +} 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..2be2809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,9 +11,12 @@ use std::{ process::ExitCode, }; +mod bre; mod cmp; +mod color; mod context_diff; mod diff; +mod dir_diff; mod ed_diff; mod macros; mod normal_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..81b7048 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,9 +1,13 @@ -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::iter::Peekable; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use regex::Regex; +use crate::bre::Bre; +use crate::color::ColorWhen; +use crate::utils::fold_file_name_case; + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Format { #[default] @@ -14,6 +18,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 +43,25 @@ 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, + pub recursive: bool, + /// The name from which to start comparing the top-level directories + /// (`-S`). + pub starting_file: Option, + pub ignore_file_name_case: bool, } +/// 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,14 +75,30 @@ 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(), + recursive: false, + starting_file: None, + ignore_file_name_case: false, } } } -pub fn parse_params>(mut opts: Peekable) -> Result { - // parse CLI - - let Some(executable) = opts.next() else { +/// Parses the command line, and also returns its arguments other than the +/// program name and the operands. +pub fn parse_params>( + opts: Peekable, +) -> Result<(Params, Vec), String> { + let args: Vec = opts.collect(); + // Each argument comes with its position, to tell the operands apart from + // an option argument with the same value. + let mut opts = args.iter().cloned().enumerate().peekable(); + let mut operand_positions = Vec::new(); + let Some((_, executable)) = opts.next() else { return Err("Usage: ".to_string()); }; let mut params = Params { @@ -59,14 +109,16 @@ 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() { - let next_param = opts.peek(); + while let Some((position, param)) = opts.next() { + let next_param = opts.peek().map(|(_, next_param)| next_param); if param == "--" { break; } if param == "-" { + operand_positions.push(position); if from.is_none() { from = Some(param); } else if to.is_none() { @@ -87,6 +139,74 @@ 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; + } + if let Some(argument) = option_argument(¶m, "-F", "--show-function-line") { + let pattern = argument_value(argument, ¶m, &mut opts)?; + push_function_line_pattern(&mut params, &pattern.to_string_lossy())?; + continue; + } + if param == "--ignore-file-name-case" { + params.ignore_file_name_case = true; + continue; + } + if param == "--no-ignore-file-name-case" { + params.ignore_file_name_case = false; + continue; + } + if let Some(argument) = option_argument(¶m, "-S", "--starting-file") { + params.starting_file = Some(argument_value(argument, ¶m, &mut opts)?); + continue; + } + if param == "-r" || param == "--recursive" { + params.recursive = true; + continue; + } + if param == "-a" || param == "--text" { + params.text = true; + continue; + } if param == "-t" || param == "--expand-tabs" { params.expand_tabs = true; continue; @@ -197,6 +317,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu if param.to_string_lossy().starts_with('-') { return Err(format!("unrecognized option '{}'", param.to_string_lossy())); } + operand_positions.push(position); if from.is_none() { from = Some(param); } else if to.is_none() { @@ -210,7 +331,8 @@ pub fn parse_params>(mut opts: Peekable) -> Resu } params.from = if let Some(from) = from { from - } else if let Some(param) = opts.next() { + } else if let Some((position, param)) = opts.next() { + operand_positions.push(position); param } else { return Err(format!( @@ -220,7 +342,8 @@ pub fn parse_params>(mut opts: Peekable) -> Resu }; params.to = if let Some(to) = to { to - } else if let Some(param) = opts.next() { + } else if let Some((position, param)) = opts.next() { + operand_positions.push(position); param } else { return Err(format!( @@ -235,18 +358,93 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut to_path: PathBuf = PathBuf::from(¶ms.to); if from_path.is_dir() && to_path.is_file() { - from_path.push(to_path.file_name().unwrap()); + let name = directory_entry(&from_path, to_path.file_name().unwrap(), ¶ms); + from_path.push(name); params.from = from_path.into_os_string(); } else if from_path.is_file() && to_path.is_dir() { - to_path.push(from_path.file_name().unwrap()); + let name = directory_entry(&to_path, from_path.file_name().unwrap(), ¶ms); + to_path.push(name); 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) + let options = args + .into_iter() + .enumerate() + .skip(1) + .filter(|(position, _)| !operand_positions.contains(position)) + .map(|(_, arg)| arg) + .collect(); + Ok((params, options)) +} + +/// Returns the name of the entry of `dir` to compare with a file named +/// `name`: `name` itself, or with --ignore-file-name-case, an entry whose name +/// differs only by case when `name` does not exist. +fn directory_entry(dir: &Path, name: &OsStr, params: &Params) -> OsString { + if params.ignore_file_name_case && !dir.join(name).exists() { + let wanted = fold_file_name_case(name); + let entries = std::fs::read_dir(dir).into_iter().flatten().flatten(); + if let Some(entry) = entries + .map(|entry| entry.file_name()) + .find(|entry| fold_file_name_case(entry) == wanted) + { + return entry; + } + } + name.to_os_string() +} + +/// How the argument of an option is given. +enum OptionArgument<'a> { + /// In the same command line argument, as in `-Fx` or `--show-function-line=x`. + Attached(&'a str), + /// As the next command line argument. + Separate, +} + +/// Returns how the argument of the option named `short` (like `-F`) or +/// `long` is given, if `param` is that option. +fn option_argument<'a>(param: &'a OsStr, short: &str, long: &str) -> Option> { + if param == short || param == long { + return Some(OptionArgument::Separate); + } + let param = param.to_str()?; + param + .strip_prefix(long) + .and_then(|rest| rest.strip_prefix('=')) + .or_else(|| param.strip_prefix(short).filter(|value| !value.is_empty())) + .map(OptionArgument::Attached) +} + +/// Returns the value of the argument of the option `param`, taking the next +/// command line argument when it is not attached. +fn argument_value( + argument: OptionArgument, + param: &OsStr, + opts: &mut impl Iterator, +) -> Result { + match argument { + OptionArgument::Attached(value) => Ok(value.into()), + OptionArgument::Separate => opts + .next() + .map(|(_, value)| value) + .ok_or_else(|| format!("option '{}' requires an argument", param.to_string_lossy())), + } +} + +/// 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 { @@ -346,6 +544,10 @@ fn match_unified_diff_params( #[cfg(test)] mod tests { use super::*; + + fn parse_params>(opts: Peekable) -> Result { + super::parse_params(opts).map(|(params, _)| params) + } fn os(s: &str) -> OsString { OsString::from(s) } @@ -685,6 +887,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 +1188,133 @@ 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()) + ); + } + + #[test] + fn option_arguments() { + let options = |args: &[&str]| { + let (_, options) = + super::parse_params(args.iter().copied().map(os).peekable()).unwrap(); + options + }; + assert_eq!( + options(&["diff", "-u", "old", "-r", "new"]), + [os("-u"), os("-r")] + ); + // An option argument equal to an operand stays with its option. + assert_eq!( + options(&["diff", "-F", "new", "old", "new"]), + [os("-F"), os("new")] + ); + assert_eq!( + options(&["diff", "-q", "--", "-old", "new"]), + [os("-q"), os("--")] + ); + assert_eq!(options(&["diff", "-", "new", "-t"]), [os("-t")]); + } + + #[test] + fn starting_file() { + for args in [ + &["-S", "mid"][..], + &["-Smid"], + &["--starting-file=mid"], + &["--starting-file", "mid"], + ] { + let args = std::iter::once("diff") + .chain(args.iter().copied()) + .chain(["foo", "bar"]); + let params = parse_params(args.map(os).peekable()).unwrap(); + assert_eq!(params.starting_file, Some(os("mid"))); + assert_eq!(params.from, os("foo")); + } + assert!(parse_params( + [os("diff"), os("foo"), os("bar"), os("-S")] + .into_iter() + .peekable() + ) + .is_err()); + } + #[test] + fn ignore_file_name_case() { + // The last of --ignore-file-name-case and --no-ignore-file-name-case + // wins. + for (args, expected) in [ + (&[][..], false), + (&["--ignore-file-name-case"], true), + (&["--no-ignore-file-name-case"], false), + ( + &["--ignore-file-name-case", "--no-ignore-file-name-case"], + false, + ), + ( + &["--no-ignore-file-name-case", "--ignore-file-name-case"], + true, + ), + ] { + let args = std::iter::once("diff") + .chain(args.iter().copied()) + .chain(["foo", "bar"]); + let params = parse_params(args.map(os).peekable()).unwrap(); + assert_eq!(params.ignore_file_name_case, expected); + } + } } diff --git a/src/unified_diff.rs b/src/unified_diff.rs index bd83afe..e30da1d 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, write_file_header, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -242,16 +242,12 @@ 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 mut output = format!( - "--- {0}\t{1}\n+++ {2}\t{3}\n", - params.from.to_string_lossy(), - from_modified_time, - params.to.to_string_lossy(), - to_modified_time - ) - .into_bytes(); + 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 = Vec::new(); + write_file_header(&mut output, "---", ¶ms.from, &from_modified_time); + write_file_header(&mut output, "+++", ¶ms.to, &to_modified_time); let diff_results = make_diff(expected, actual, params.context_count, params.brief); if diff_results.is_empty() { return Vec::new(); @@ -259,7 +255,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 +371,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 +1034,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..7247fe9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,8 +3,13 @@ // 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 std::{ + borrow::Cow, + ffi::{OsStr, OsString}, + io::Write, +}; use unicode_width::UnicodeWidthStr; /// Replace tabs by spaces in the input line. @@ -52,9 +57,177 @@ pub fn do_write_line( } } +/// Quotes a file name for the headers of the diff output: a name containing +/// spaces, double quotes, backslashes, control characters or non-ASCII bytes +/// is written between double quotes with C-style escapes, other names are +/// written as is. On Windows, backslashes separate path components and are +/// left alone. +#[must_use] +pub fn quote_file_name(name: &[u8]) -> Cow<'_, [u8]> { + const SPECIAL_BACKSLASH: bool = cfg!(not(windows)); + let needs_quotes = |byte: u8| { + byte <= b' ' || byte == b'"' || (byte == b'\\' && SPECIAL_BACKSLASH) || byte >= 0x80 + }; + if !name.iter().copied().any(needs_quotes) { + return Cow::Borrowed(name); + } + let mut quoted = Vec::with_capacity(name.len() + 8); + quoted.push(b'"'); + for &byte in name { + let escape = match byte { + b'"' => b'"', + b'\\' if SPECIAL_BACKSLASH => b'\\', + 0x07 => b'a', + 0x08 => b'b', + b'\t' => b't', + b'\n' => b'n', + 0x0b => b'v', + 0x0c => b'f', + b'\r' => b'r', + b' ' => { + quoted.push(b' '); + continue; + } + byte if needs_quotes(byte) => { + quoted.extend_from_slice(format!("\\{byte:03o}").as_bytes()); + continue; + } + byte => { + quoted.push(byte); + continue; + } + }; + quoted.extend_from_slice(&[b'\\', escape]); + } + quoted.push(b'"'); + Cow::Owned(quoted) +} + +/// Appends a file header line of the context or unified formats, such as +/// `--- name\ttimestamp`, to `output`. +pub fn write_file_header(output: &mut Vec, marker: &str, name: &OsStr, timestamp: &str) { + output.extend_from_slice(marker.as_bytes()); + output.push(b' '); + output.extend_from_slice("e_file_name(name.as_encoded_bytes())); + output.push(b'\t'); + output.extend_from_slice(timestamp.as_bytes()); + output.push(b'\n'); +} + +/// Quotes a command line argument the way a shell would need it, for the +/// headers that repeat the options of a directory comparison. +#[must_use] +pub fn quote_shell_argument(arg: &[u8]) -> Cow<'_, [u8]> { + const SPECIAL: &[u8] = b"^*?!=()|&;<>`\\'\"$["; + let is_special = |byte: &u8| *byte <= b' ' || *byte == 0x7f || SPECIAL.contains(byte); + let special_start = matches!(arg.first(), Some(b'~' | b'#')); + if !special_start && !arg.iter().any(is_special) && !arg.is_empty() { + return Cow::Borrowed(arg); + } + let mut quoted = Vec::with_capacity(arg.len() + 2); + let only_single_quotes = arg + .iter() + .filter(|byte| is_special(byte)) + .all(|&byte| byte == b'\''); + if arg.contains(&b'\'') && only_single_quotes { + quoted.push(b'"'); + quoted.extend_from_slice(arg); + quoted.push(b'"'); + } else { + quoted.push(b'\''); + for &byte in arg { + if byte == b'\'' { + quoted.extend_from_slice(b"'\\''"); + } else { + quoted.push(byte); + } + } + quoted.push(b'\''); + } + Cow::Owned(quoted) +} + +/// Returns `name` in lowercase, to match file names ignoring case. +pub fn fold_file_name_case(name: &OsStr) -> Vec { + match name.to_str() { + Some(name) => name.to_lowercase().into_bytes(), + None => name.as_encoded_bytes().to_ascii_lowercase(), + } +} + +/// 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 +237,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( @@ -238,6 +411,54 @@ mod tests { } } + mod quoting { + use super::*; + use pretty_assertions::assert_eq; + + fn quoted_name(name: &[u8]) -> String { + String::from_utf8(quote_file_name(name).into_owned()).unwrap() + } + + fn quoted_arg(arg: &str) -> String { + String::from_utf8(quote_shell_argument(arg.as_bytes()).into_owned()).unwrap() + } + + #[test] + fn file_names() { + assert_eq!(quoted_name(b"src/main.rs"), "src/main.rs"); + assert_eq!(quoted_name(b"why?*$'#~"), "why?*$'#~"); + assert_eq!(quoted_name(b"two words"), r#""two words""#); + assert_eq!(quoted_name(b"say \"hi\""), r#""say \"hi\"""#); + if cfg!(windows) { + assert_eq!(quoted_name(b"C:\\temp"), r"C:\temp"); + assert_eq!(quoted_name(b"C:\\my docs"), r#""C:\my docs""#); + } else { + assert_eq!(quoted_name(b"C:\\temp"), r#""C:\\temp""#); + } + assert_eq!( + quoted_name(b"\x07\x08\t\n\x0b\x0c\r"), + r#""\a\b\t\n\v\f\r""# + ); + assert_eq!(quoted_name(b"\x1bx\x7f"), "\"\\033x\x7f\""); + assert_eq!(quoted_name("caf\u{e9}".as_bytes()), r#""caf\303\251""#); + assert_eq!(quoted_name(b"\xfe"), r#""\376""#); + } + + #[test] + fn shell_arguments() { + assert_eq!(quoted_arg("-u"), "-u"); + assert_eq!(quoted_arg("a,b:c@d%e+f{g}h]/i.j"), "a,b:c@d%e+f{g}h]/i.j"); + assert_eq!(quoted_arg("x~#"), "x~#"); + assert_eq!(quoted_arg("~x"), "'~x'"); + assert_eq!(quoted_arg("#x"), "'#x'"); + assert_eq!(quoted_arg("^fn (main|init)"), "'^fn (main|init)'"); + assert_eq!(quoted_arg("--width=9"), "'--width=9'"); + assert_eq!(quoted_arg(""), "''"); + assert_eq!(quoted_arg("don't"), "\"don't\""); + assert_eq!(quoted_arg("don't $pay"), "'don'\\''t $pay'"); + } + } + mod write_line { use super::*; use pretty_assertions::assert_eq; @@ -277,7 +498,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 +513,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 0053a46..8392ede 100644 --- a/tests/by-util/test_cmp.rs +++ b/tests/by-util/test_cmp.rs @@ -583,3 +583,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..0292275 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,486 @@ 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_directories() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let left = tmp_dir.path().join("left"); + let right = tmp_dir.path().join("right"); + for dir in [&left, &right] { + std::fs::create_dir_all(dir.join("nested/inner"))?; + std::fs::write(dir.join("equal"), "same\n")?; + } + std::fs::write(left.join("changed"), "old\n")?; + std::fs::write(right.join("changed"), "new\n")?; + std::fs::write(left.join("nested/inner/deep"), "one\n")?; + std::fs::write(right.join("nested/inner/deep"), "two\n")?; + std::fs::write(left.join("gone"), "bye\n")?; + std::fs::write(right.join("added"), "hi\n")?; + std::fs::write(left.join("kind"), "file\n")?; + std::fs::create_dir(right.join("kind"))?; + let left = left.to_str().unwrap(); + let right = right.to_str().unwrap(); + // Entry names are joined to the directories with the native separator. + let s = std::path::MAIN_SEPARATOR; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg(left).arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Only in {right}: added\n\ + diff {left}{s}changed {right}{s}changed\n1c1\n< old\n---\n> new\n\ + Only in {left}: gone\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + Common subdirectories: {left}{s}nested and {right}{s}nested\n" + )); + + // Options are repeated as given in the headers, and trailing slashes + // do not end up in the compared paths. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-r") + .arg("-q") + .arg(format!("{left}/")) + .arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Only in {right}: added\n\ + Files {left}{s}changed and {right}{s}changed differ\n\ + Only in {left}/: gone\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + Files {left}{s}nested{s}inner{s}deep and {right}{s}nested{s}inner{s}deep differ\n" + )); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-N") + .arg("-r") + .arg("-e") + .arg(left) + .arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "diff -N -r -e {left}{s}added {right}{s}added\n0a\nhi\n.\n\ + diff -N -r -e {left}{s}changed {right}{s}changed\n1c\nnew\n.\n\ + diff -N -r -e {left}{s}gone {right}{s}gone\n1d\n\ + File {left}{s}kind is a regular file while file {right}{s}kind is a directory\n\ + diff -N -r -e {left}{s}nested{s}inner{s}deep {right}{s}nested{s}inner{s}deep\n1c\ntwo\n.\n" + )); + + // -s reports the identical files, but a directory is not compared with + // itself. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-r").arg("-s").arg(left).arg(left); + cmd.assert().code(predicate::eq(0)).stdout(""); + + let copy = tmp_dir.path().join("copy"); + std::fs::create_dir_all(copy.join("nested/inner"))?; + std::fs::write(copy.join("changed"), "old\n")?; + std::fs::write(copy.join("equal"), "same\n")?; + std::fs::write(copy.join("nested/inner/deep"), "one\n")?; + let copy = copy.to_str().unwrap(); + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff").arg("-r").arg("-s").arg(left).arg(copy); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Files {left}{s}changed and {copy}{s}changed are identical\n\ + Files {left}{s}equal and {copy}{s}equal are identical\n\ + Only in {left}: gone\n\ + Only in {left}: kind\n\ + Files {left}{s}nested{s}inner{s}deep and {copy}{s}nested{s}inner{s}deep are identical\n" + )); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn compare_directories_special_entries() -> Result<(), Box> { + let tmp_dir = tempdir()?; + for dir in ["one", "two"] { + std::fs::create_dir_all(tmp_dir.path().join(dir).join("tools/bin"))?; + // Links to the same directory are not compared. + std::os::unix::fs::symlink("..", tmp_dir.path().join(dir).join("parent"))?; + let status = std::process::Command::new("mkfifo") + .arg(tmp_dir.path().join(dir).join("queue")) + .status()?; + assert!(status.success()); + } + std::fs::write(tmp_dir.path().join("one/tools/bin/run"), "go\n")?; + std::fs::remove_dir_all(tmp_dir.path().join("two/tools"))?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-N") + .arg("one") + .arg("two"); + cmd.assert().code(predicate::eq(1)).stdout( + "File one/queue is a fifo while file two/queue is a fifo\n\ + Common subdirectories: one/tools and two/tools\n", + ); + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-r") + .arg("-N") + .arg("one") + .arg("two"); + cmd.assert().code(predicate::eq(1)).stdout( + "File one/queue is a fifo while file two/queue is a fifo\n\ + diff -r -N one/tools/bin/run two/tools/bin/run\n1d0\n< go\n", + ); + + // A link back to a directory being compared is reported. + std::os::unix::fs::symlink("..", tmp_dir.path().join("one/tools/again"))?; + std::fs::create_dir(tmp_dir.path().join("two/tools"))?; + std::os::unix::fs::symlink("..", tmp_dir.path().join("two/tools/again"))?; + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(tmp_dir.path()) + .arg("diff") + .arg("-r") + .arg("one") + .arg("two"); + cmd.assert() + .code(predicate::eq(2)) + .stdout(predicate::str::contains("Only in one/tools: bin\n")) + .stderr("diff: one/tools/again: recursive directory loop\n"); + Ok(()) +} + +#[test] +fn directory_name_matching() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let left = tmp_dir.path().join("left"); + let right = tmp_dir.path().join("right"); + std::fs::create_dir_all(left.join("lib"))?; + std::fs::create_dir_all(right.join("lib"))?; + for name in ["Readme", "notes", "lib/early"] { + std::fs::write(left.join(name), "v1\n")?; + } + for name in ["readme", "Notes", "Todo", "lib/EARLY"] { + std::fs::write(right.join(name), "v1\n")?; + } + // Names differing only by case can only coexist on case-sensitive file + // systems; there, they pair in byte order. + std::fs::write(left.join("README"), "v1\n")?; + let case_sensitive = std::fs::read_dir(&left)?.count() == 4; + let unpaired_readme = if case_sensitive { + format!("Only in {}: Readme\n", left.display()) + } else { + String::new() + }; + let left = left.to_str().unwrap(); + let right = right.to_str().unwrap(); + let s = std::path::MAIN_SEPARATOR; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-r") + .arg("--ignore-file-name-case") + .arg(left) + .arg(right); + cmd.assert() + .code(predicate::eq(1)) + .stdout(format!("{unpaired_readme}Only in {right}: Todo\n")); + + // -S only skips names at the top level. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-r") + .arg("-S") + .arg("l") + .arg(left) + .arg(right); + cmd.assert().code(predicate::eq(1)).stdout(format!( + "Only in {right}{s}lib: EARLY\nOnly in {left}{s}lib: early\n\ + Only in {left}: notes\nOnly in {right}: readme\n" + )); + + // The starting name is matched regardless of case too. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--starting-file=NOTES") + .arg("--ignore-file-name-case") + .arg(left) + .arg(right); + cmd.assert() + .code(predicate::eq(1)) + .stdout(format!("{unpaired_readme}Only in {right}: Todo\n")); + + // A file compared with a directory finds its counterpart regardless of case. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("--ignore-file-name-case") + .arg(format!("{left}{s}notes")) + .arg(right); + cmd.assert().code(predicate::eq(0)).stdout(""); + Ok(()) +} + #[test] fn compare_file_to_directory() -> Result<(), Box> { let tmp_dir = tempdir()?; @@ -288,3 +770,153 @@ 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(()) +} + +#[cfg(unix)] +#[test] +fn quoted_file_names() -> Result<(), Box> { + let tmp_dir = tempdir()?; + let dir = tmp_dir.path().to_str().unwrap(); + std::fs::write(tmp_dir.path().join("my notes"), "draft\n")?; + std::fs::write(tmp_dir.path().join("final\tv2"), "done\n")?; + std::fs::write(tmp_dir.path().join("plain$name"), "done\n")?; + + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.env("TZ", "UTC0") + .arg("diff") + .arg("-u") + .arg(format!("{dir}/my notes")) + .arg(format!("{dir}/final\tv2")); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::is_match(format!( + "^--- \"{dir}/my notes\"\t[^\n]*\n\\+\\+\\+ \"{dir}/final\\\\tv2\"\t" + ))?); + + // Names without special characters are left alone. + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.arg("diff") + .arg("-c") + .arg(format!("{dir}/my notes")) + .arg(format!("{dir}/plain$name")); + cmd.assert() + .code(predicate::eq(1)) + .stdout(predicate::str::contains(format!( + "\n--- {dir}/plain$name\t" + ))); + + // The diff -r header quotes the options for the shell and the names as + // in the other headers. + for (side, content) in [("one", "a\n"), ("two", "b\n")] { + std::fs::create_dir(tmp_dir.path().join(side))?; + std::fs::write(tmp_dir.path().join(side).join("my file"), content)?; + } + let mut cmd = cargo_bin_cmd!("diffutils"); + cmd.current_dir(&tmp_dir) + .arg("diff") + .arg("-r") + .arg("-F") + .arg("my func") + .arg("one") + .arg("two"); + cmd.assert() + .code(predicate::eq(1)) + .stdout("diff -r -F 'my func' \"one/my file\" \"two/my file\"\n1c1\n< a\n---\n> b\n"); + Ok(()) +}