Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions src/dir_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
147 changes: 128 additions & 19 deletions src/params.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -52,6 +53,10 @@ pub struct Params {
/// headers (`-F` and `-p`).
pub function_line_patterns: Vec<Bre>,
pub recursive: bool,
/// The name from which to start comparing the top-level directories
/// (`-S`).
pub starting_file: Option<OsString>,
pub ignore_file_name_case: bool,
}

/// The pattern of `-p`, matching the first line of C functions.
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -175,23 +182,21 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
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(&param, "-F", "--show-function-line") {
let pattern = argument_value(argument, &param, &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(&param, "-S", "--starting-file") {
params.starting_file = Some(argument_value(argument, &param, &mut opts)?);
continue;
}
if param == "-r" || param == "--recursive" {
Expand Down Expand Up @@ -353,10 +358,12 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
let mut to_path: PathBuf = PathBuf::from(&params.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(), &params);
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(), &params);
to_path.push(name);
params.to = to_path.into_os_string();
}

Expand All @@ -378,6 +385,61 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
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<OptionArgument<'a>> {
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<Item = (usize, OsString)>,
) -> Result<OsString, String> {
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}"))?;
Expand Down Expand Up @@ -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);
}
}
}
8 changes: 8 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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> {
Expand Down
70 changes: 70 additions & 0 deletions tests/by-util/test_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,76 @@ fn compare_directories_special_entries() -> Result<(), Box<dyn std::error::Error
Ok(())
}

#[test]
fn directory_name_matching() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let tmp_dir = tempdir()?;
Expand Down
Loading