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/context_diff.rs b/src/context_diff.rs index 94c95be..334a661 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -8,7 +8,7 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::{get_modification_time, is_posix_locale, TimestampStyle}; +use crate::utils::{get_modification_time, is_posix_locale, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -338,7 +338,11 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { if params.brief { return output; } + let mut function_lines = FunctionLineFinder::new(expected, ¶ms.function_line_patterns); for result in diff_results { + let function_line = function_lines + .as_mut() + .and_then(|finder| finder.find(result.line_number_expected)); let mut line_number_expected = result.line_number_expected; let mut line_number_actual = result.line_number_actual; let mut expected_count = result.expected.len(); @@ -363,11 +367,13 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { } else { format!("{line_number_actual},") }; - writeln!( - output, - "***************\n*** {exp_start}{end_line_number_expected} ****" - ) - .expect("write to Vec is infallible"); + output.extend_from_slice(b"***************"); + if let Some(function_line) = function_line { + output.push(b' '); + output.extend_from_slice(&function_line); + } + writeln!(output, "\n*** {exp_start}{end_line_number_expected} ****") + .expect("write to Vec is infallible"); if !result.expected_all_context { for line in result.expected { match line { @@ -1001,4 +1007,39 @@ mod tests { let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); assert_diff_eq!(output, expected); } + + #[test] + fn test_function_lines() { + use crate::assert_diff_eq; + + let from = [ + "(defun area", + " (* w h))", + "(defun volume", + " (* w h d))", + "", + ] + .join("\n"); + let to = from.replace("d))", "depth))"); + let params = Params { + from: "old".into(), + to: "new".into(), + context_count: 0, + function_line_patterns: vec![crate::bre::Bre::new("^(").unwrap()], + ..Default::default() + }; + let expected = [ + "*** old\tTIMESTAMP", + "--- new\tTIMESTAMP", + "*************** (defun volume", + "*** 4 ****", + "! (* w h d))", + "--- 4 ----", + "! (* w h depth))", + "", + ] + .join("\n"); + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + assert_diff_eq!(output, expected); + } } diff --git a/src/lib.rs b/src/lib.rs index d672d4b..e1e43b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod bre; pub mod cmp; pub mod color; pub mod context_diff; diff --git a/src/main.rs b/src/main.rs index a34cc04..3ee3c5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use std::{ process::ExitCode, }; +mod bre; mod cmp; mod color; mod context_diff; diff --git a/src/params.rs b/src/params.rs index a0607b2..578bbaa 100644 --- a/src/params.rs +++ b/src/params.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use regex::Regex; +use crate::bre::Bre; use crate::color::ColorWhen; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -47,8 +48,14 @@ pub struct Params { /// The raw `--palette` value, validated when colors are enabled. pub palette: Option, pub presume_output_tty: bool, + /// Basic regular expressions matching the lines shown after hunk + /// headers (`-F` and `-p`). + pub function_line_patterns: Vec, } +/// The pattern of `-p`, matching the first line of C functions. +const C_FUNCTION_PATTERN: &str = "^[[:alpha:]$_]"; + impl Default for Params { fn default() -> Self { Self { @@ -67,6 +74,7 @@ impl Default for Params { color: ColorWhen::default(), palette: None, presume_output_tty: false, + function_line_patterns: Vec::new(), } } } @@ -85,6 +93,7 @@ pub fn parse_params>(mut opts: Peekable) -> Resu let mut to = None; let mut format = None; let mut context = None; + let mut show_c_function = false; let tabsize_re = Regex::new(r"^--tabsize=(?\d+)$").unwrap(); let width_re = Regex::new(r"^--width=(?P\d+)$").unwrap(); while let Some(param) = opts.next() { @@ -151,6 +160,30 @@ pub fn parse_params>(mut opts: Peekable) -> Resu params.presume_output_tty = true; continue; } + if param == "-p" || param == "--show-c-function" { + show_c_function = true; + push_function_line_pattern(&mut params, C_FUNCTION_PATTERN)?; + continue; + } + let attached_function_line = param.to_str().and_then(|p| { + p.strip_prefix("--show-function-line=") + .or_else(|| p.strip_prefix("-F").filter(|pattern| !pattern.is_empty())) + }); + if let Some(pattern) = attached_function_line { + push_function_line_pattern(&mut params, pattern)?; + continue; + } + if param == "-F" || param == "--show-function-line" { + let Some(pattern) = next_param.map(|p| p.to_string_lossy().into_owned()) else { + return Err(format!( + "option '{}' requires an argument", + param.to_string_lossy() + )); + }; + opts.next(); + push_function_line_pattern(&mut params, &pattern)?; + continue; + } if param == "-a" || param == "--text" { params.text = true; continue; @@ -310,13 +343,24 @@ pub fn parse_params>(mut opts: Peekable) -> Resu params.to = to_path.into_os_string(); } - params.format = format.unwrap_or(Format::default()); + params.format = match format { + Some(format) => format, + None if show_c_function => Format::Context, + None => Format::default(), + }; if let Some(context_count) = context { params.context_count = context_count; } Ok(params) } +/// Validates and records a `-F` pattern. +fn push_function_line_pattern(params: &mut Params, pattern: &str) -> Result<(), String> { + let bre = Bre::new(pattern).map_err(|error| format!("'{pattern}': {error}"))?; + params.function_line_patterns.push(bre); + Ok(()) +} + struct DiffStyleMatch { is_match: bool, context_count: Option, @@ -1076,4 +1120,41 @@ mod tests { assert!(parse(&[option]).is_err(), "{option}"); } } + + #[test] + fn function_lines() { + let patterns = |patterns: &[&str]| -> Vec { + patterns + .iter() + .map(|pattern| Bre::new(pattern).unwrap()) + .collect() + }; + let parse = |args: &[&str]| { + let args = std::iter::once("diff") + .chain(args.iter().copied()) + .chain(["foo", "bar"]); + parse_params(args.map(os).peekable()) + }; + let params = parse(&["-p"]).unwrap(); + assert_eq!(params.format, Format::Context); + assert_eq!( + params.function_line_patterns, + patterns(&[C_FUNCTION_PATTERN]) + ); + + let params = parse(&["-u", "-p"]).unwrap(); + assert_eq!(params.format, Format::Unified); + + let params = parse(&["-F", "^fn", "-Fx", "--show-function-line=^#", "-e"]).unwrap(); + assert_eq!(params.format, Format::Ed); + assert_eq!(params.function_line_patterns, patterns(&["^fn", "x", "^#"])); + + // -F alone selects no output format. + assert_eq!(parse(&["-F", "^fn"]).unwrap().format, Format::Normal); + + assert_eq!( + parse(&["-F", "oops\\"]), + Err("'oops\\': Trailing backslash".to_string()) + ); + } } diff --git a/src/unified_diff.rs b/src/unified_diff.rs index 329a88b..094b4e1 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -8,7 +8,7 @@ use std::io::Write; use crate::params::Params; use crate::utils::do_write_line; -use crate::utils::{get_modification_time, TimestampStyle}; +use crate::utils::{get_modification_time, FunctionLineFinder, TimestampStyle}; #[derive(Debug, PartialEq)] pub enum DiffLine { @@ -260,7 +260,11 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { if params.brief { return output; } + let mut function_lines = FunctionLineFinder::new(expected, ¶ms.function_line_patterns); for result in diff_results { + let function_line = function_lines + .as_mut() + .and_then(|finder| finder.find(result.line_number_expected as usize)); let mut line_number_expected = result.line_number_expected; let mut line_number_actual = result.line_number_actual; let mut expected_count = 0; @@ -372,11 +376,16 @@ pub fn diff(expected: &[u8], actual: &[u8], params: &Params) -> Vec { } else { format!(",{actual_count}") }; - writeln!( + write!( output, "@@ -{line_number_expected}{exp_ct} +{line_number_actual}{act_ct} @@" ) .expect("write to Vec is infallible"); + if let Some(function_line) = function_line { + output.push(b' '); + output.extend_from_slice(&function_line); + } + output.push(b'\n'); for line in result.lines { match line { DiffLine::Expected(e) => { @@ -1030,4 +1039,57 @@ mod tests { let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); assert_diff_eq!(output, expected); } + + #[test] + fn test_function_lines() { + use crate::assert_diff_eq; + + let from = [ + "section Apples", + " red", + " green", + " section Pears with a heading longer than forty characters", + " ripe", + " hard", + " sweet", + " sour", + "", + ] + .join("\n"); + let to = from.replace("red", "crimson").replace("sour", "tart"); + let params = Params { + from: "old".into(), + to: "new".into(), + context_count: 0, + function_line_patterns: vec![ + crate::bre::Bre::new(r"^ *section \(Apples\|Pears\)").unwrap() + ], + ..Default::default() + }; + // The heading is found before the hunk, trimmed and cut at 40 + // characters, and the closest heading wins. + let expected = [ + "--- old\tTIMESTAMP", + "+++ new\tTIMESTAMP", + "@@ -2 +2 @@ section Apples", + "- red", + "+ crimson", + "@@ -8 +8 @@ section Pears with a heading longer than", + "- sour", + "+ tart", + "", + ] + .join("\n"); + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + assert_diff_eq!(output, expected); + + // Nothing is shown when no line precedes the hunk. + let params = Params { + context_count: 1, + ..params + }; + let output = diff(from.as_bytes(), to.as_bytes(), ¶ms); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("\n@@ -1,3 +1,3 @@\n")); + } } diff --git a/src/utils.rs b/src/utils.rs index ba35cc2..8e35bc9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. +use crate::bre::Bre; use regex::Regex; use std::{ffi::OsString, io::Write}; use unicode_width::UnicodeWidthStr; @@ -52,6 +53,56 @@ pub fn do_write_line( } } +/// Finds the lines shown after hunk headers by `-F` and `-p`: the nearest +/// line of the old file preceding a hunk that matches one of the patterns. +pub struct FunctionLineFinder<'a> { + lines: Vec<&'a [u8]>, + patterns: &'a [Bre], + /// Index of the first line not examined yet. + next_line: usize, + last_match: Option<&'a [u8]>, +} + +impl<'a> FunctionLineFinder<'a> { + /// Maximum number of characters of a function line that are shown. + const MAX_WIDTH: usize = 40; + + /// Returns `None` when there are no patterns, so no function lines. + pub fn new(old_text: &'a [u8], patterns: &'a [Bre]) -> Option { + if patterns.is_empty() { + return None; + } + Some(Self { + lines: old_text.split(|&byte| byte == b'\n').collect(), + patterns, + next_line: 0, + last_match: None, + }) + } + + /// Returns the text to show after the header of a hunk starting at + /// `first_line` (1-based) of the old file, if any. Hunks must be queried + /// in increasing order. + pub fn find(&mut self, first_line: usize) -> Option> { + let end = first_line.saturating_sub(1).min(self.lines.len()); + for line in &self.lines[self.next_line.min(end)..end] { + if self.patterns.iter().any(|pattern| pattern.is_match(line)) { + self.last_match = Some(line); + } + } + self.next_line = self.next_line.max(end); + let line = self.last_match?.trim_ascii_start(); + let end = match std::str::from_utf8(line) { + Ok(text) => text + .char_indices() + .nth(Self::MAX_WIDTH) + .map_or(line.len(), |(end, _)| end), + Err(_) => line.len().min(Self::MAX_WIDTH), + }; + Some(line[..end].trim_ascii_end().to_vec()) + } +} + /// How a modification time is rendered in a diff header. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TimestampStyle { diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 069fa15..ae5fbf8 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -605,3 +605,52 @@ fn ed_format_single_line_deletions() -> Result<(), Box> { 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(()) +}