diff --git a/src/dir_diff.rs b/src/dir_diff.rs index a7a8505..d8d90da 100644 --- a/src/dir_diff.rs +++ b/src/dir_diff.rs @@ -6,6 +6,7 @@ //! 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}; @@ -14,7 +15,9 @@ use std::path::{Path, PathBuf}; use crate::diff::{compare_files, Outcome}; use crate::params::{MissingFile, Params}; -use crate::utils::{quote_file_name, quote_shell_argument, report_failure_to_read_input_file}; +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 @@ -59,14 +62,21 @@ impl DirectoryWalker<'_> { } canonical_dirs.push(canonical); } - let (Some(from_names), Some(to_names)) = (self.list(from_dir), self.list(to_dir)) else { + 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| { - from.as_encoded_bytes().cmp(to.as_encoded_bytes()) + self.name_key(from).cmp(&self.name_key(to)) }); for entry in entries { let entry_outcome = match entry { @@ -117,9 +127,23 @@ impl DirectoryWalker<'_> { } } 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 { diff --git a/src/params.rs b/src/params.rs index d92346f..81b7048 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,11 +1,12 @@ -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 { @@ -52,6 +53,10 @@ pub struct Params { /// 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. @@ -77,6 +82,8 @@ impl Default for Params { presume_output_tty: false, function_line_patterns: Vec::new(), recursive: false, + starting_file: None, + ignore_file_name_case: false, } } } @@ -175,23 +182,21 @@ pub fn parse_params>( 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)?; + 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 == "-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)?; + 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" { @@ -353,10 +358,12 @@ pub fn parse_params>( 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(); } @@ -378,6 +385,61 @@ pub fn parse_params>( 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}"))?; @@ -1208,4 +1270,51 @@ mod tests { ); 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/utils.rs b/src/utils.rs index 0265109..7247fe9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -147,6 +147,14 @@ pub fn quote_shell_argument(arg: &[u8]) -> Cow<'_, [u8]> { 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> { diff --git a/tests/by-util/test_diff.rs b/tests/by-util/test_diff.rs index 2b29278..0292275 100644 --- a/tests/by-util/test_diff.rs +++ b/tests/by-util/test_diff.rs @@ -618,6 +618,76 @@ fn compare_directories_special_entries() -> Result<(), Box 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()?;