diff --git a/Cargo.lock b/Cargo.lock index 4453f09..4d331a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,7 @@ dependencies = [ "regex", "same-file", "tempfile", + "unicode-width", ] [[package]] @@ -406,6 +407,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + [[package]] name = "wait-timeout" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 32f0cda..78af2c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ chrono = "0.4.35" diff = "0.1.10" regex = "1.10.4" same-file = "1.0.6" +unicode-width = "0.1.11" [dev-dependencies] pretty_assertions = "1" diff --git a/fuzz/fuzz_targets/fuzz_ed.rs b/fuzz/fuzz_targets/fuzz_ed.rs index 5c5132e..69461d1 100644 --- a/fuzz/fuzz_targets/fuzz_ed.rs +++ b/fuzz/fuzz_targets/fuzz_ed.rs @@ -8,7 +8,7 @@ use std::io::Write; use std::process::Command; fn diff_w(expected: &[u8], actual: &[u8], filename: &str) -> Result, DiffError> { - let mut output = ed_diff::diff(expected, actual)?; + let mut output = ed_diff::diff(expected, actual, false, false, 8)?; writeln!(&mut output, "w {filename}").unwrap(); Ok(output) } diff --git a/fuzz/fuzz_targets/fuzz_normal.rs b/fuzz/fuzz_targets/fuzz_normal.rs index a44ece3..2d38641 100644 --- a/fuzz/fuzz_targets/fuzz_normal.rs +++ b/fuzz/fuzz_targets/fuzz_normal.rs @@ -21,7 +21,7 @@ fuzz_target!(|x: (Vec, Vec)| { } else { return }*/ - let diff = normal_diff::diff(&from, &to); + let diff = normal_diff::diff(&from, &to, false, false, 8); File::create("target/fuzz.file.original") .unwrap() .write_all(&from) diff --git a/fuzz/fuzz_targets/fuzz_patch.rs b/fuzz/fuzz_targets/fuzz_patch.rs index d353523..15e4967 100644 --- a/fuzz/fuzz_targets/fuzz_patch.rs +++ b/fuzz/fuzz_targets/fuzz_patch.rs @@ -26,6 +26,9 @@ fuzz_target!(|x: (Vec, Vec, u8)| { &to, "target/fuzz.file", context as usize, + false, + false, + 8, ); File::create("target/fuzz.file.original") .unwrap() diff --git a/src/context_diff.rs b/src/context_diff.rs index bb1dfe1..9df10d0 100644 --- a/src/context_diff.rs +++ b/src/context_diff.rs @@ -6,6 +6,8 @@ use std::collections::VecDeque; use std::io::Write; +use crate::utils::do_write_line; + #[derive(Debug, PartialEq)] pub enum DiffLine { Context(Vec), @@ -279,6 +281,7 @@ fn get_modification_time(file_path: &str) -> String { } #[must_use] +#[allow(clippy::too_many_arguments)] pub fn diff( expected: &[u8], expected_filename: &str, @@ -286,6 +289,8 @@ pub fn diff( actual_filename: &str, context_size: usize, stop_early: bool, + expand_tabs: bool, + tabsize: usize, ) -> Vec { let expected_file_modified_time = get_modification_time(expected_filename); let actual_file_modified_time = get_modification_time(actual_filename); @@ -332,17 +337,20 @@ pub fn diff( match line { DiffLine::Context(e) => { write!(output, " ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Change(e) => { write!(output, "! ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Add(e) => { write!(output, "- ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } } @@ -359,17 +367,20 @@ pub fn diff( match line { DiffLine::Context(e) => { write!(output, " ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Change(e) => { write!(output, "! ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Add(e) => { write!(output, "+ ").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } } @@ -443,6 +454,8 @@ mod tests { &format!("{target}/alef"), 2, false, + false, + 8, ); File::create(&format!("{target}/ab.diff")) .unwrap() @@ -523,6 +536,8 @@ mod tests { &format!("{target}/alef_"), 2, false, + false, + 8, ); File::create(&format!("{target}/ab_.diff")) .unwrap() @@ -606,6 +621,8 @@ mod tests { &format!("{target}/alefx"), 2, false, + false, + 8, ); File::create(&format!("{target}/abx.diff")) .unwrap() @@ -692,6 +709,8 @@ mod tests { &format!("{target}/alefr"), 2, false, + false, + 8, ); File::create(&format!("{target}/abr.diff")) .unwrap() @@ -730,10 +749,10 @@ mod tests { let from_filename = "foo"; let _ = File::create(&format!("foo")).unwrap(); - let from = vec!["a", "b", "c", ""].join("\n"); + let from = ["a", "b", "c", ""].join("\n"); let to_filename = "bar"; let _ = File::create(&format!("bar")).unwrap(); - let to = vec!["a", "d", "c", ""].join("\n"); + let to = ["a", "d", "c", ""].join("\n"); let context_size: usize = 3; let diff_full = diff( @@ -743,13 +762,15 @@ mod tests { to_filename, context_size, false, + false, + 8, ); let diff_full_text = str::from_utf8(&diff_full).unwrap(); let re = Regex::new(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ [+-]\d{4}").unwrap(); let diff_full = re.replace_all(diff_full_text, ""); - let expected_full = vec![ + let expected_full = [ "*** foo\t", "--- bar\t", "***************", @@ -773,12 +794,14 @@ mod tests { to_filename, context_size, true, + false, + 8, ); let diff_brief_text = str::from_utf8(&diff_brief).unwrap(); let re = Regex::new(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ [+-]\d{4}").unwrap(); let diff_brief = re.replace_all(diff_brief_text, ""); - let expected_brief = vec!["*** foo\t", "--- bar\t", ""].join("\n"); + let expected_brief = ["*** foo\t", "--- bar\t", ""].join("\n"); assert_eq!(diff_brief, expected_brief); let nodiff_full = diff( @@ -788,6 +811,8 @@ mod tests { to_filename, context_size, false, + false, + 8, ); assert!(nodiff_full.is_empty()); @@ -798,6 +823,8 @@ mod tests { to_filename, context_size, true, + false, + 8, ); assert!(nodiff_brief.is_empty()); } diff --git a/src/ed_diff.rs b/src/ed_diff.rs index eec1fb4..c02289c 100644 --- a/src/ed_diff.rs +++ b/src/ed_diff.rs @@ -5,6 +5,8 @@ use std::io::Write; +use crate::utils::do_write_line; + #[derive(Debug, PartialEq)] struct Mismatch { pub line_number_expected: usize, @@ -107,7 +109,13 @@ fn make_diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Result Result, DiffError> { +pub fn diff( + expected: &[u8], + actual: &[u8], + stop_early: bool, + expand_tabs: bool, + tabsize: usize, +) -> Result, DiffError> { let mut output = Vec::new(); let diff_results = make_diff(expected, actual, stop_early)?; if stop_early && !diff_results.is_empty() { @@ -145,7 +153,7 @@ pub fn diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Result, if actual == b"." { writeln!(&mut output, "..\n.\ns/.//\na").unwrap(); } else { - output.write_all(actual).unwrap(); + do_write_line(&mut output, actual, expand_tabs, tabsize).unwrap(); writeln!(&mut output).unwrap(); } } @@ -160,7 +168,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; pub fn diff_w(expected: &[u8], actual: &[u8], filename: &str) -> Result, DiffError> { - let mut output = diff(expected, actual, false)?; + let mut output = diff(expected, actual, false, false, 8)?; writeln!(&mut output, "w {filename}").unwrap(); Ok(output) } @@ -169,8 +177,8 @@ mod tests { fn test_basic() { let from = b"a\n"; let to = b"b\n"; - let diff = diff(from, to, false).unwrap(); - let expected = vec!["1c", "b", ".", ""].join("\n"); + let diff = diff(from, to, false, false, 8).unwrap(); + let expected = ["1c", "b", ".", ""].join("\n"); assert_eq!(diff, expected.as_bytes()); } @@ -401,21 +409,21 @@ mod tests { #[test] fn test_stop_early() { - let from = vec!["a", "b", "c", ""].join("\n"); - let to = vec!["a", "d", "c", ""].join("\n"); + let from = ["a", "b", "c", ""].join("\n"); + let to = ["a", "d", "c", ""].join("\n"); - let diff_full = diff(from.as_bytes(), to.as_bytes(), false).unwrap(); - let expected_full = vec!["2c", "d", ".", ""].join("\n"); + let diff_full = diff(from.as_bytes(), to.as_bytes(), false, false, 8).unwrap(); + let expected_full = ["2c", "d", ".", ""].join("\n"); assert_eq!(diff_full, expected_full.as_bytes()); - let diff_brief = diff(from.as_bytes(), to.as_bytes(), true).unwrap(); + let diff_brief = diff(from.as_bytes(), to.as_bytes(), true, false, 8).unwrap(); let expected_brief = "\0".as_bytes(); assert_eq!(diff_brief, expected_brief); - let nodiff_full = diff(from.as_bytes(), from.as_bytes(), false).unwrap(); + let nodiff_full = diff(from.as_bytes(), from.as_bytes(), false, false, 8).unwrap(); assert!(nodiff_full.is_empty()); - let nodiff_brief = diff(from.as_bytes(), from.as_bytes(), true).unwrap(); + let nodiff_brief = diff(from.as_bytes(), from.as_bytes(), true, false, 8).unwrap(); assert!(nodiff_brief.is_empty()); } } diff --git a/src/lib.rs b/src/lib.rs index a78b64d..faf5df2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod context_diff; pub mod ed_diff; pub mod normal_diff; pub mod unified_diff; +pub mod utils; // Re-export the public functions/types you need pub use context_diff::diff as context_diff; diff --git a/src/main.rs b/src/main.rs index 6ff2a0f..2a6d4ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod ed_diff; mod normal_diff; mod params; mod unified_diff; +mod utils; // Exit codes are documented at // https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff.html. @@ -30,6 +31,8 @@ fn main() -> ExitCode { format, report_identical_files, brief, + expand_tabs, + tabsize, } = parse_params(opts).unwrap_or_else(|error| { eprintln!("{error}"); exit(2); @@ -65,7 +68,9 @@ fn main() -> ExitCode { }; // run diff let result: Vec = match format { - Format::Normal => normal_diff::diff(&from_content, &to_content, brief), + Format::Normal => { + normal_diff::diff(&from_content, &to_content, brief, expand_tabs, tabsize) + } Format::Unified => unified_diff::diff( &from_content, &from.to_string_lossy(), @@ -73,6 +78,8 @@ fn main() -> ExitCode { &to.to_string_lossy(), context_count, brief, + expand_tabs, + tabsize, ), Format::Context => context_diff::diff( &from_content, @@ -81,11 +88,14 @@ fn main() -> ExitCode { &to.to_string_lossy(), context_count, brief, + expand_tabs, + tabsize, ), - Format::Ed => ed_diff::diff(&from_content, &to_content, brief).unwrap_or_else(|error| { - eprintln!("{error}"); - exit(2); - }), + Format::Ed => ed_diff::diff(&from_content, &to_content, brief, expand_tabs, tabsize) + .unwrap_or_else(|error| { + eprintln!("{error}"); + exit(2); + }), }; if brief && !result.is_empty() { println!( diff --git a/src/normal_diff.rs b/src/normal_diff.rs index e324ada..b26de77 100644 --- a/src/normal_diff.rs +++ b/src/normal_diff.rs @@ -5,6 +5,8 @@ use std::io::Write; +use crate::utils::do_write_line; + #[derive(Debug, PartialEq)] struct Mismatch { pub line_number_expected: usize, @@ -114,7 +116,13 @@ fn make_diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Vec } #[must_use] -pub fn diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Vec { +pub fn diff( + expected: &[u8], + actual: &[u8], + stop_early: bool, + expand_tabs: bool, + tabsize: usize, +) -> Vec { // See https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Normal.html // for details on the syntax of the normal format. let mut output = Vec::new(); @@ -188,7 +196,7 @@ pub fn diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Vec { } for expected in &result.expected { write!(&mut output, "< ").unwrap(); - output.write_all(expected).unwrap(); + do_write_line(&mut output, expected, expand_tabs, tabsize).unwrap(); writeln!(&mut output).unwrap(); } if result.expected_missing_nl { @@ -199,7 +207,7 @@ pub fn diff(expected: &[u8], actual: &[u8], stop_early: bool) -> Vec { } for actual in &result.actual { write!(&mut output, "> ").unwrap(); - output.write_all(actual).unwrap(); + do_write_line(&mut output, actual, expand_tabs, tabsize).unwrap(); writeln!(&mut output).unwrap(); } if result.actual_missing_nl { @@ -220,7 +228,7 @@ mod tests { a.write_all(b"a\n").unwrap(); let mut b = Vec::new(); b.write_all(b"b\n").unwrap(); - let diff = diff(&a, &b, false); + let diff = diff(&a, &b, false, false, 8); let expected = b"1c1\n< a\n---\n> b\n".to_vec(); assert_eq!(diff, expected); } @@ -273,7 +281,7 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. - let diff = diff(&alef, &bet, false); + let diff = diff(&alef, &bet, false, false, 8); File::create(&format!("{target}/ab.diff")) .unwrap() .write_all(&diff) @@ -365,7 +373,7 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. - let diff = diff(&alef, &bet, false); + let diff = diff(&alef, &bet, false, false, 8); File::create(&format!("{target}/abn.diff")) .unwrap() .write_all(&diff) @@ -439,7 +447,7 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. - let diff = diff(&alef, &bet, false); + let diff = diff(&alef, &bet, false, false, 8); File::create(&format!("{target}/ab_.diff")) .unwrap() .write_all(&diff) @@ -517,7 +525,7 @@ mod tests { } // This test diff is intentionally reversed. // We want it to turn the alef into bet. - let diff = diff(&alef, &bet, false); + let diff = diff(&alef, &bet, false, false, 8); File::create(&format!("{target}/abr.diff")) .unwrap() .write_all(&diff) @@ -549,21 +557,21 @@ mod tests { #[test] fn test_stop_early() { - let from = vec!["a", "b", "c"].join("\n"); - let to = vec!["a", "d", "c"].join("\n"); + let from = ["a", "b", "c"].join("\n"); + let to = ["a", "d", "c"].join("\n"); - let diff_full = diff(from.as_bytes(), to.as_bytes(), false); - let expected_full = vec!["2c2", "< b", "---", "> d", ""].join("\n"); + let diff_full = diff(from.as_bytes(), to.as_bytes(), false, false, 8); + let expected_full = ["2c2", "< b", "---", "> d", ""].join("\n"); assert_eq!(diff_full, expected_full.as_bytes()); - let diff_brief = diff(from.as_bytes(), to.as_bytes(), true); + let diff_brief = diff(from.as_bytes(), to.as_bytes(), true, false, 8); let expected_brief = "\0".as_bytes(); assert_eq!(diff_brief, expected_brief); - let nodiff_full = diff(from.as_bytes(), from.as_bytes(), false); + let nodiff_full = diff(from.as_bytes(), from.as_bytes(), false, false, 8); assert!(nodiff_full.is_empty()); - let nodiff_brief = diff(from.as_bytes(), from.as_bytes(), true); + let nodiff_brief = diff(from.as_bytes(), from.as_bytes(), true, false, 8); assert!(nodiff_brief.is_empty()); } } diff --git a/src/params.rs b/src/params.rs index 661cd37..6a6d261 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,7 +1,10 @@ use std::ffi::{OsStr, OsString}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +use regex::Regex; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Format { + #[default] Normal, Unified, Context, @@ -27,6 +30,23 @@ pub struct Params { pub context_count: usize, pub report_identical_files: bool, pub brief: bool, + pub expand_tabs: bool, + pub tabsize: usize, +} + +impl Default for Params { + fn default() -> Self { + Self { + from: OsString::default(), + to: OsString::default(), + format: Format::default(), + context_count: 3, + report_identical_files: false, + brief: false, + expand_tabs: false, + tabsize: 8, + } + } } pub fn parse_params>(opts: I) -> Result { @@ -36,12 +56,11 @@ pub fn parse_params>(opts: I) -> Result ".to_string()); }; + let mut params = Params::default(); let mut from = None; let mut to = None; let mut format = None; - let mut context_count = 3; - let mut report_identical_files = false; - let mut brief = false; + let tabsize_re = Regex::new(r"^--tabsize=(?\d+)$").unwrap(); while let Some(param) = opts.next() { if param == "--" { break; @@ -57,11 +76,31 @@ pub fn parse_params>(opts: I) -> Result() { + Ok(num) => num, + Err(_) => return Err(format!("invalid tabsize «{}»", tabsize_str)), + }; continue; } let p = osstr_bytes(¶m); @@ -72,10 +111,10 @@ pub fn parse_params>(opts: I) -> Result { - context_count = (b - b'0') as usize; + params.context_count = (b - b'0') as usize; while let Some(b'0'..=b'9') = bit.peek() { - context_count *= 10; - context_count += (bit.next().unwrap() - b'0') as usize; + params.context_count *= 10; + params.context_count += (bit.next().unwrap() - b'0') as usize; } } b'c' => { @@ -109,7 +148,7 @@ pub fn parse_params>(opts: I) -> Result>(opts: I) -> Result ", exe.to_string_lossy())); } } - let from = if let Some(from) = from { + params.from = if let Some(from) = from { from } else if let Some(param) = opts.next() { param } else { return Err(format!("Usage: {} ", exe.to_string_lossy())); }; - let to = if let Some(to) = to { + params.to = if let Some(to) = to { to } else if let Some(param) = opts.next() { param } else { return Err(format!("Usage: {} ", exe.to_string_lossy())); }; - let format = format.unwrap_or(Format::Normal); - Ok(Params { - from, - to, - format, - context_count, - report_identical_files, - brief, - }) + params.format = format.unwrap_or(Format::default()); + Ok(params) } #[cfg(test)] @@ -162,10 +194,7 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("foo"), os("bar")].iter().cloned()) ); @@ -177,9 +206,7 @@ mod tests { from: os("foo"), to: os("bar"), format: Format::Ed, - context_count: 3, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("-e"), os("foo"), os("bar")].iter().cloned()) ); @@ -192,8 +219,7 @@ mod tests { to: os("bar"), format: Format::Unified, context_count: 54, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params( [os("diff"), os("-u54"), os("foo"), os("bar")] @@ -207,8 +233,7 @@ mod tests { to: os("bar"), format: Format::Unified, context_count: 54, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params( [os("diff"), os("-U54"), os("foo"), os("bar")] @@ -222,8 +247,7 @@ mod tests { to: os("bar"), format: Format::Unified, context_count: 54, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params( [os("diff"), os("-U"), os("54"), os("foo"), os("bar")] @@ -237,8 +261,7 @@ mod tests { to: os("bar"), format: Format::Context, context_count: 54, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params( [os("diff"), os("-c54"), os("foo"), os("bar")] @@ -253,10 +276,7 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("foo"), os("bar")].iter().cloned()) ); @@ -264,10 +284,8 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, report_identical_files: true, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("-s"), os("foo"), os("bar")].iter().cloned()) ); @@ -275,10 +293,8 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, report_identical_files: true, - brief: false, + ..Default::default() }), parse_params( [ @@ -298,10 +314,7 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("foo"), os("bar")].iter().cloned()) ); @@ -309,10 +322,8 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, brief: true, + ..Default::default() }), parse_params([os("diff"), os("-q"), os("foo"), os("bar")].iter().cloned()) ); @@ -320,10 +331,8 @@ mod tests { Ok(Params { from: os("foo"), to: os("bar"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, brief: true, + ..Default::default() }), parse_params( [os("diff"), os("--brief"), os("foo"), os("bar"),] @@ -333,15 +342,116 @@ mod tests { ); } #[test] + fn expand_tabs() { + assert_eq!( + Ok(Params { + from: os("foo"), + to: os("bar"), + ..Default::default() + }), + parse_params([os("diff"), os("foo"), os("bar")].iter().cloned()) + ); + for option in ["-t", "--expand-tabs"] { + assert_eq!( + Ok(Params { + from: os("foo"), + to: os("bar"), + expand_tabs: true, + ..Default::default() + }), + parse_params( + [os("diff"), os(option), os("foo"), os("bar")] + .iter() + .cloned() + ) + ); + } + } + #[test] + fn tabsize() { + assert_eq!( + Ok(Params { + from: os("foo"), + to: os("bar"), + ..Default::default() + }), + parse_params([os("diff"), os("foo"), os("bar")].iter().cloned()) + ); + assert_eq!( + Ok(Params { + from: os("foo"), + to: os("bar"), + tabsize: 0, + ..Default::default() + }), + parse_params( + [os("diff"), os("--tabsize=0"), os("foo"), os("bar")] + .iter() + .cloned() + ) + ); + assert_eq!( + Ok(Params { + from: os("foo"), + to: os("bar"), + tabsize: 42, + ..Default::default() + }), + parse_params( + [os("diff"), os("--tabsize=42"), os("foo"), os("bar")] + .iter() + .cloned() + ) + ); + assert!(parse_params( + [os("diff"), os("--tabsize"), os("foo"), os("bar")] + .iter() + .cloned() + ) + .is_err()); + assert!(parse_params( + [os("diff"), os("--tabsize="), os("foo"), os("bar")] + .iter() + .cloned() + ) + .is_err()); + assert!(parse_params( + [os("diff"), os("--tabsize=r2"), os("foo"), os("bar")] + .iter() + .cloned() + ) + .is_err()); + assert!(parse_params( + [os("diff"), os("--tabsize=-1"), os("foo"), os("bar")] + .iter() + .cloned() + ) + .is_err()); + assert!(parse_params( + [os("diff"), os("--tabsize=r2"), os("foo"), os("bar")] + .iter() + .cloned() + ) + .is_err()); + assert!(parse_params( + [ + os("diff"), + os("--tabsize=92233720368547758088"), + os("foo"), + os("bar") + ] + .iter() + .cloned() + ) + .is_err()); + } + #[test] fn double_dash() { assert_eq!( Ok(Params { from: os("-g"), to: os("-h"), - format: Format::Normal, - context_count: 3, - report_identical_files: false, - brief: false, + ..Default::default() }), parse_params([os("diff"), os("--"), os("-g"), os("-h")].iter().cloned()) ); diff --git a/src/unified_diff.rs b/src/unified_diff.rs index becf721..0d3ec38 100644 --- a/src/unified_diff.rs +++ b/src/unified_diff.rs @@ -6,6 +6,8 @@ use std::collections::VecDeque; use std::io::Write; +use crate::utils::do_write_line; + #[derive(Debug, PartialEq)] pub enum DiffLine { Context(Vec), @@ -234,6 +236,7 @@ fn make_diff( } #[must_use] +#[allow(clippy::too_many_arguments)] pub fn diff( expected: &[u8], expected_filename: &str, @@ -241,6 +244,8 @@ pub fn diff( actual_filename: &str, context_size: usize, stop_early: bool, + expand_tabs: bool, + tabsize: usize, ) -> Vec { let mut output = format!("--- {expected_filename}\t\n+++ {actual_filename}\t\n").into_bytes(); let diff_results = make_diff(expected, actual, context_size, stop_early); @@ -371,17 +376,20 @@ pub fn diff( match line { DiffLine::Expected(e) => { write!(output, "-").expect("write to Vec is infallible"); - output.write_all(&e).expect("write to Vec is infallible"); + do_write_line(&mut output, &e, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Context(c) => { write!(output, " ").expect("write to Vec is infallible"); - output.write_all(&c).expect("write to Vec is infallible"); + do_write_line(&mut output, &c, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::Actual(r) => { write!(output, "+",).expect("write to Vec is infallible"); - output.write_all(&r).expect("write to Vec is infallible"); + do_write_line(&mut output, &r, expand_tabs, tabsize) + .expect("write to Vec is infallible"); writeln!(output).unwrap(); } DiffLine::MissingNL => { @@ -454,6 +462,8 @@ mod tests { &format!("{target}/alef"), 2, false, + false, + 8, ); File::create(&format!("{target}/ab.diff")) .unwrap() @@ -568,6 +578,8 @@ mod tests { &format!("{target}/alefn"), 2, false, + false, + 8, ); File::create(&format!("{target}/abn.diff")) .unwrap() @@ -662,6 +674,8 @@ mod tests { &format!("{target}/alef_"), 2, false, + false, + 8, ); File::create(&format!("{target}/ab_.diff")) .unwrap() @@ -741,6 +755,8 @@ mod tests { &format!("{target}/alefx"), 2, false, + false, + 8, ); File::create(&format!("{target}/abx.diff")) .unwrap() @@ -825,6 +841,8 @@ mod tests { &format!("{target}/alefr"), 2, false, + false, + 8, ); File::create(&format!("{target}/abr.diff")) .unwrap() @@ -857,9 +875,9 @@ mod tests { #[test] fn test_stop_early() { let from_filename = "foo"; - let from = vec!["a", "b", "c", ""].join("\n"); + let from = ["a", "b", "c", ""].join("\n"); let to_filename = "bar"; - let to = vec!["a", "d", "c", ""].join("\n"); + let to = ["a", "d", "c", ""].join("\n"); let context_size: usize = 3; let diff_full = diff( @@ -869,8 +887,10 @@ mod tests { to_filename, context_size, false, + false, + 8, ); - let expected_full = vec![ + let expected_full = [ "--- foo\t", "+++ bar\t", "@@ -1,3 +1,3 @@", @@ -890,8 +910,10 @@ mod tests { to_filename, context_size, true, + false, + 8, ); - let expected_brief = vec!["--- foo\t", "+++ bar\t", ""].join("\n"); + let expected_brief = ["--- foo\t", "+++ bar\t", ""].join("\n"); assert_eq!(diff_brief, expected_brief.as_bytes()); let nodiff_full = diff( @@ -901,6 +923,8 @@ mod tests { to_filename, context_size, false, + false, + 8, ); assert!(nodiff_full.is_empty()); @@ -911,6 +935,8 @@ mod tests { to_filename, context_size, true, + false, + 8, ); assert!(nodiff_brief.is_empty()); } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..94d950f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,117 @@ +// 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. + +use std::io::Write; + +use unicode_width::UnicodeWidthStr; + +/// Replace tabs by spaces in the input line. +/// Correctly handle multi-bytes characters. +/// This assumes that line does not contain any line breaks (if it does, the result is undefined). +pub fn do_expand_tabs(line: &[u8], tabsize: usize) -> Vec { + let tab = b'\t'; + let ntabs = line.iter().filter(|c| **c == tab).count(); + if ntabs == 0 { + return line.to_vec(); + } + let mut result = Vec::with_capacity(line.len() + ntabs * (tabsize - 1)); + let mut offset = 0; + + let mut iter = line.split(|c| *c == tab).peekable(); + while let Some(chunk) = iter.next() { + match String::from_utf8(chunk.to_vec()) { + Ok(s) => offset += UnicodeWidthStr::width(s.as_str()), + Err(_) => offset += chunk.len(), + } + result.extend_from_slice(chunk); + if iter.peek().is_some() { + result.resize(result.len() + tabsize - offset % tabsize, b' '); + offset = 0; + } + } + + result +} + +/// Write a single line to an output stream, expanding tabs to space if necessary. +/// This assumes that line does not contain any line breaks +/// (if it does and tabs are to be expanded to spaces, the result is undefined). +pub fn do_write_line( + output: &mut Vec, + line: &[u8], + expand_tabs: bool, + tabsize: usize, +) -> std::io::Result<()> { + if expand_tabs { + output.write_all(do_expand_tabs(line, tabsize).as_slice()) + } else { + output.write_all(line) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod expand_tabs { + use super::*; + use pretty_assertions::assert_eq; + + fn assert_tab_expansion(line: &str, tabsize: usize, expected: &str) { + assert_eq!( + do_expand_tabs(line.as_bytes(), tabsize), + expected.as_bytes() + ); + } + + #[test] + fn basics() { + assert_tab_expansion("foo barr baz", 8, "foo barr baz"); + assert_tab_expansion("foo\tbarr\tbaz", 8, "foo barr baz"); + assert_tab_expansion("foo\tbarr\tbaz", 5, "foo barr baz"); + assert_tab_expansion("foo\tbarr\tbaz", 2, "foo barr baz"); + } + + #[test] + fn multibyte_chars() { + assert_tab_expansion("foo\tépée\tbaz", 8, "foo épée baz"); + assert_tab_expansion("foo\t😉\tbaz", 5, "foo 😉 baz"); + + // Note: The Woman Scientist emoji (👩‍🔬) is a ZWJ sequence combining + // the Woman emoji (👩) and the Microscope emoji (🔬). On supported platforms + // it is displayed as a single emoji and should have a print size of 2 columns, + // but terminal emulators tend to not support this, and display the two emojis + // side by side, thus accounting for a print size of 4 columns. + assert_tab_expansion("foo\t👩‍🔬\tbaz", 6, "foo 👩‍🔬 baz"); + } + + #[test] + fn invalid_utf8() { + // [240, 240, 152, 137] is an invalid UTF-8 sequence, so it is handled as 4 bytes + assert_eq!( + do_expand_tabs(&[240, 240, 152, 137, 9, 102, 111, 111], 8), + &[240, 240, 152, 137, 32, 32, 32, 32, 102, 111, 111] + ); + } + } + + mod write_line { + use super::*; + use pretty_assertions::assert_eq; + + fn assert_line_written(line: &str, expand_tabs: bool, tabsize: usize, expected: &str) { + let mut output: Vec = Vec::new(); + assert!(do_write_line(&mut output, line.as_bytes(), expand_tabs, tabsize).is_ok()); + assert_eq!(output, expected.as_bytes()); + } + + #[test] + fn basics() { + assert_line_written("foo bar baz", false, 8, "foo bar baz"); + assert_line_written("foo bar\tbaz", false, 8, "foo bar\tbaz"); + assert_line_written("foo bar\tbaz", true, 8, "foo bar baz"); + } + } +}