Skip to content

Commit d273dd6

Browse files
authored
diff: add -S and --ignore-file-name-case
1 parent 5e71b16 commit d273dd6

4 files changed

Lines changed: 233 additions & 22 deletions

File tree

‎src/dir_diff.rs‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! Comparison of two directories, entry by entry, descending into
77
//! subdirectories with `-r`.
88
9+
use std::borrow::Cow;
910
use std::cmp::Ordering;
1011
use std::ffi::{OsStr, OsString};
1112
use std::fs::{self, Metadata};
@@ -14,7 +15,9 @@ use std::path::{Path, PathBuf};
1415

1516
use crate::diff::{compare_files, Outcome};
1617
use crate::params::{MissingFile, Params};
17-
use crate::utils::{quote_file_name, quote_shell_argument, report_failure_to_read_input_file};
18+
use crate::utils::{
19+
fold_file_name_case, quote_file_name, quote_shell_argument, report_failure_to_read_input_file,
20+
};
1821

1922
/// Compares the directories `params.from` and `params.to`. `option_args` are
2023
/// the command line options, repeated in the header printed before the
@@ -59,14 +62,21 @@ impl DirectoryWalker<'_> {
5962
}
6063
canonical_dirs.push(canonical);
6164
}
62-
let (Some(from_names), Some(to_names)) = (self.list(from_dir), self.list(to_dir)) else {
65+
let (Some(mut from_names), Some(mut to_names)) = (self.list(from_dir), self.list(to_dir))
66+
else {
6367
return Outcome::Trouble;
6468
};
69+
// -S only applies to the operands, not to their subdirectories.
70+
if let (true, Some(start)) = (self.ancestors.is_empty(), &self.params.starting_file) {
71+
let start = self.name_key(start).into_owned();
72+
from_names.retain(|name| *self.name_key(name) >= *start);
73+
to_names.retain(|name| *self.name_key(name) >= *start);
74+
}
6575
let ancestors_len = self.ancestors.len();
6676
self.ancestors.extend(canonical_dirs);
6777

6878
let entries = pair_entries(&from_names, &to_names, |from, to| {
69-
from.as_encoded_bytes().cmp(to.as_encoded_bytes())
79+
self.name_key(from).cmp(&self.name_key(to))
7080
});
7181
for entry in entries {
7282
let entry_outcome = match entry {
@@ -117,9 +127,23 @@ impl DirectoryWalker<'_> {
117127
}
118128
}
119129
names.sort_unstable_by(|a, b| a.as_encoded_bytes().cmp(b.as_encoded_bytes()));
130+
if self.params.ignore_file_name_case {
131+
// A stable sort, so names equal ignoring case stay in byte order.
132+
names.sort_by_cached_key(|name| fold_file_name_case(name));
133+
}
120134
Some(names)
121135
}
122136

137+
/// Returns what entry names are sorted and matched by: the names
138+
/// themselves, or their lowercase form with --ignore-file-name-case.
139+
fn name_key<'n>(&self, name: &'n OsStr) -> Cow<'n, [u8]> {
140+
if self.params.ignore_file_name_case {
141+
Cow::Owned(fold_file_name_case(name))
142+
} else {
143+
Cow::Borrowed(name.as_encoded_bytes())
144+
}
145+
}
146+
123147
/// Compares two entries of the compared directories, one of which may be
124148
/// missing when missing files are treated as empty.
125149
fn compare_entries(&mut self, from: &OsStr, to: &OsStr) -> Outcome {

‎src/params.rs‎

Lines changed: 128 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
use std::ffi::OsString;
1+
use std::ffi::{OsStr, OsString};
22
use std::iter::Peekable;
3-
use std::path::PathBuf;
3+
use std::path::{Path, PathBuf};
44

55
use regex::Regex;
66

77
use crate::bre::Bre;
88
use crate::color::ColorWhen;
9+
use crate::utils::fold_file_name_case;
910

1011
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1112
pub enum Format {
@@ -52,6 +53,10 @@ pub struct Params {
5253
/// headers (`-F` and `-p`).
5354
pub function_line_patterns: Vec<Bre>,
5455
pub recursive: bool,
56+
/// The name from which to start comparing the top-level directories
57+
/// (`-S`).
58+
pub starting_file: Option<OsString>,
59+
pub ignore_file_name_case: bool,
5560
}
5661

5762
/// The pattern of `-p`, matching the first line of C functions.
@@ -77,6 +82,8 @@ impl Default for Params {
7782
presume_output_tty: false,
7883
function_line_patterns: Vec::new(),
7984
recursive: false,
85+
starting_file: None,
86+
ignore_file_name_case: false,
8087
}
8188
}
8289
}
@@ -175,23 +182,21 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
175182
push_function_line_pattern(&mut params, C_FUNCTION_PATTERN)?;
176183
continue;
177184
}
178-
let attached_function_line = param.to_str().and_then(|p| {
179-
p.strip_prefix("--show-function-line=")
180-
.or_else(|| p.strip_prefix("-F").filter(|pattern| !pattern.is_empty()))
181-
});
182-
if let Some(pattern) = attached_function_line {
183-
push_function_line_pattern(&mut params, pattern)?;
185+
if let Some(argument) = option_argument(&param, "-F", "--show-function-line") {
186+
let pattern = argument_value(argument, &param, &mut opts)?;
187+
push_function_line_pattern(&mut params, &pattern.to_string_lossy())?;
184188
continue;
185189
}
186-
if param == "-F" || param == "--show-function-line" {
187-
let Some(pattern) = next_param.map(|p| p.to_string_lossy().into_owned()) else {
188-
return Err(format!(
189-
"option '{}' requires an argument",
190-
param.to_string_lossy()
191-
));
192-
};
193-
opts.next();
194-
push_function_line_pattern(&mut params, &pattern)?;
190+
if param == "--ignore-file-name-case" {
191+
params.ignore_file_name_case = true;
192+
continue;
193+
}
194+
if param == "--no-ignore-file-name-case" {
195+
params.ignore_file_name_case = false;
196+
continue;
197+
}
198+
if let Some(argument) = option_argument(&param, "-S", "--starting-file") {
199+
params.starting_file = Some(argument_value(argument, &param, &mut opts)?);
195200
continue;
196201
}
197202
if param == "-r" || param == "--recursive" {
@@ -353,10 +358,12 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
353358
let mut to_path: PathBuf = PathBuf::from(&params.to);
354359

355360
if from_path.is_dir() && to_path.is_file() {
356-
from_path.push(to_path.file_name().unwrap());
361+
let name = directory_entry(&from_path, to_path.file_name().unwrap(), &params);
362+
from_path.push(name);
357363
params.from = from_path.into_os_string();
358364
} else if from_path.is_file() && to_path.is_dir() {
359-
to_path.push(from_path.file_name().unwrap());
365+
let name = directory_entry(&to_path, from_path.file_name().unwrap(), &params);
366+
to_path.push(name);
360367
params.to = to_path.into_os_string();
361368
}
362369

@@ -378,6 +385,61 @@ pub fn parse_params<I: Iterator<Item = OsString>>(
378385
Ok((params, options))
379386
}
380387

388+
/// Returns the name of the entry of `dir` to compare with a file named
389+
/// `name`: `name` itself, or with --ignore-file-name-case, an entry whose name
390+
/// differs only by case when `name` does not exist.
391+
fn directory_entry(dir: &Path, name: &OsStr, params: &Params) -> OsString {
392+
if params.ignore_file_name_case && !dir.join(name).exists() {
393+
let wanted = fold_file_name_case(name);
394+
let entries = std::fs::read_dir(dir).into_iter().flatten().flatten();
395+
if let Some(entry) = entries
396+
.map(|entry| entry.file_name())
397+
.find(|entry| fold_file_name_case(entry) == wanted)
398+
{
399+
return entry;
400+
}
401+
}
402+
name.to_os_string()
403+
}
404+
405+
/// How the argument of an option is given.
406+
enum OptionArgument<'a> {
407+
/// In the same command line argument, as in `-Fx` or `--show-function-line=x`.
408+
Attached(&'a str),
409+
/// As the next command line argument.
410+
Separate,
411+
}
412+
413+
/// Returns how the argument of the option named `short` (like `-F`) or
414+
/// `long` is given, if `param` is that option.
415+
fn option_argument<'a>(param: &'a OsStr, short: &str, long: &str) -> Option<OptionArgument<'a>> {
416+
if param == short || param == long {
417+
return Some(OptionArgument::Separate);
418+
}
419+
let param = param.to_str()?;
420+
param
421+
.strip_prefix(long)
422+
.and_then(|rest| rest.strip_prefix('='))
423+
.or_else(|| param.strip_prefix(short).filter(|value| !value.is_empty()))
424+
.map(OptionArgument::Attached)
425+
}
426+
427+
/// Returns the value of the argument of the option `param`, taking the next
428+
/// command line argument when it is not attached.
429+
fn argument_value(
430+
argument: OptionArgument,
431+
param: &OsStr,
432+
opts: &mut impl Iterator<Item = (usize, OsString)>,
433+
) -> Result<OsString, String> {
434+
match argument {
435+
OptionArgument::Attached(value) => Ok(value.into()),
436+
OptionArgument::Separate => opts
437+
.next()
438+
.map(|(_, value)| value)
439+
.ok_or_else(|| format!("option '{}' requires an argument", param.to_string_lossy())),
440+
}
441+
}
442+
381443
/// Validates and records a `-F` pattern.
382444
fn push_function_line_pattern(params: &mut Params, pattern: &str) -> Result<(), String> {
383445
let bre = Bre::new(pattern).map_err(|error| format!("'{pattern}': {error}"))?;
@@ -1208,4 +1270,51 @@ mod tests {
12081270
);
12091271
assert_eq!(options(&["diff", "-", "new", "-t"]), [os("-t")]);
12101272
}
1273+
1274+
#[test]
1275+
fn starting_file() {
1276+
for args in [
1277+
&["-S", "mid"][..],
1278+
&["-Smid"],
1279+
&["--starting-file=mid"],
1280+
&["--starting-file", "mid"],
1281+
] {
1282+
let args = std::iter::once("diff")
1283+
.chain(args.iter().copied())
1284+
.chain(["foo", "bar"]);
1285+
let params = parse_params(args.map(os).peekable()).unwrap();
1286+
assert_eq!(params.starting_file, Some(os("mid")));
1287+
assert_eq!(params.from, os("foo"));
1288+
}
1289+
assert!(parse_params(
1290+
[os("diff"), os("foo"), os("bar"), os("-S")]
1291+
.into_iter()
1292+
.peekable()
1293+
)
1294+
.is_err());
1295+
}
1296+
#[test]
1297+
fn ignore_file_name_case() {
1298+
// The last of --ignore-file-name-case and --no-ignore-file-name-case
1299+
// wins.
1300+
for (args, expected) in [
1301+
(&[][..], false),
1302+
(&["--ignore-file-name-case"], true),
1303+
(&["--no-ignore-file-name-case"], false),
1304+
(
1305+
&["--ignore-file-name-case", "--no-ignore-file-name-case"],
1306+
false,
1307+
),
1308+
(
1309+
&["--no-ignore-file-name-case", "--ignore-file-name-case"],
1310+
true,
1311+
),
1312+
] {
1313+
let args = std::iter::once("diff")
1314+
.chain(args.iter().copied())
1315+
.chain(["foo", "bar"]);
1316+
let params = parse_params(args.map(os).peekable()).unwrap();
1317+
assert_eq!(params.ignore_file_name_case, expected);
1318+
}
1319+
}
12111320
}

‎src/utils.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,14 @@ pub fn quote_shell_argument(arg: &[u8]) -> Cow<'_, [u8]> {
147147
Cow::Owned(quoted)
148148
}
149149

150+
/// Returns `name` in lowercase, to match file names ignoring case.
151+
pub fn fold_file_name_case(name: &OsStr) -> Vec<u8> {
152+
match name.to_str() {
153+
Some(name) => name.to_lowercase().into_bytes(),
154+
None => name.as_encoded_bytes().to_ascii_lowercase(),
155+
}
156+
}
157+
150158
/// Finds the lines shown after hunk headers by `-F` and `-p`: the nearest
151159
/// line of the old file preceding a hunk that matches one of the patterns.
152160
pub struct FunctionLineFinder<'a> {

‎tests/by-util/test_diff.rs‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,76 @@ fn compare_directories_special_entries() -> Result<(), Box<dyn std::error::Error
618618
Ok(())
619619
}
620620

621+
#[test]
622+
fn directory_name_matching() -> Result<(), Box<dyn std::error::Error>> {
623+
let tmp_dir = tempdir()?;
624+
let left = tmp_dir.path().join("left");
625+
let right = tmp_dir.path().join("right");
626+
std::fs::create_dir_all(left.join("lib"))?;
627+
std::fs::create_dir_all(right.join("lib"))?;
628+
for name in ["Readme", "notes", "lib/early"] {
629+
std::fs::write(left.join(name), "v1\n")?;
630+
}
631+
for name in ["readme", "Notes", "Todo", "lib/EARLY"] {
632+
std::fs::write(right.join(name), "v1\n")?;
633+
}
634+
// Names differing only by case can only coexist on case-sensitive file
635+
// systems; there, they pair in byte order.
636+
std::fs::write(left.join("README"), "v1\n")?;
637+
let case_sensitive = std::fs::read_dir(&left)?.count() == 4;
638+
let unpaired_readme = if case_sensitive {
639+
format!("Only in {}: Readme\n", left.display())
640+
} else {
641+
String::new()
642+
};
643+
let left = left.to_str().unwrap();
644+
let right = right.to_str().unwrap();
645+
let s = std::path::MAIN_SEPARATOR;
646+
647+
let mut cmd = cargo_bin_cmd!("diffutils");
648+
cmd.arg("diff")
649+
.arg("-r")
650+
.arg("--ignore-file-name-case")
651+
.arg(left)
652+
.arg(right);
653+
cmd.assert()
654+
.code(predicate::eq(1))
655+
.stdout(format!("{unpaired_readme}Only in {right}: Todo\n"));
656+
657+
// -S only skips names at the top level.
658+
let mut cmd = cargo_bin_cmd!("diffutils");
659+
cmd.arg("diff")
660+
.arg("-r")
661+
.arg("-S")
662+
.arg("l")
663+
.arg(left)
664+
.arg(right);
665+
cmd.assert().code(predicate::eq(1)).stdout(format!(
666+
"Only in {right}{s}lib: EARLY\nOnly in {left}{s}lib: early\n\
667+
Only in {left}: notes\nOnly in {right}: readme\n"
668+
));
669+
670+
// The starting name is matched regardless of case too.
671+
let mut cmd = cargo_bin_cmd!("diffutils");
672+
cmd.arg("diff")
673+
.arg("--starting-file=NOTES")
674+
.arg("--ignore-file-name-case")
675+
.arg(left)
676+
.arg(right);
677+
cmd.assert()
678+
.code(predicate::eq(1))
679+
.stdout(format!("{unpaired_readme}Only in {right}: Todo\n"));
680+
681+
// A file compared with a directory finds its counterpart regardless of case.
682+
let mut cmd = cargo_bin_cmd!("diffutils");
683+
cmd.arg("diff")
684+
.arg("--ignore-file-name-case")
685+
.arg(format!("{left}{s}notes"))
686+
.arg(right);
687+
cmd.assert().code(predicate::eq(0)).stdout("");
688+
Ok(())
689+
}
690+
621691
#[test]
622692
fn compare_file_to_directory() -> Result<(), Box<dyn std::error::Error>> {
623693
let tmp_dir = tempdir()?;

0 commit comments

Comments
 (0)