From 7c53ed7ee4e7b577842d6f1c230783a6aa51921d Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Wed, 12 Aug 2026 23:41:50 -0300 Subject: [PATCH 01/18] build(deps): add test-case as a dev-dependency The side-by-side layout tests need parameterized cases so that each width and tab size shows up as its own named test instead of a loop that reports a single failure with no indication of which input broke. --- Cargo.lock | 34 ++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 35 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index af7c96e..b287de2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,7 @@ dependencies = [ "regex", "same-file", "tempfile", + "test-case", "unicode-width", ] @@ -776,6 +777,39 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "test-case-core", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index ded812c..a23e9bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ pretty_assertions = "1.4.0" predicates = "3.1.0" rand = "0.10.0" tempfile = "3.26.0" +test-case = "3.3.1" [profile.release] lto = "thin" From fc18aa5df6070ed746ca15e306e51c3e12243a8d Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Wed, 12 Aug 2026 23:43:19 -0300 Subject: [PATCH 02/18] fix(side-diff): compute the column layout without signed casts `Config::new` sized the side-by-side columns with signed arithmetic on the `--tabsize` value, which `params.rs` accepts with no upper bound. `tab_size + GUTTER_WIDTH_MIN` overflowed for a large one: with `-C overflow-checks=on` that aborted, and in release it wrapped and drew bogus widths. A value above `isize::MAX` did not even abort, it cast to a negative number and silently laid the columns out as if the tab size were small. Closes #264. The calculation moves into `Config::layout`, in `usize` throughout, and holds for every input on its own rather than relying on validation upstream: - An early return for `tab_size > full_width`. This is exact, not a clamp: the offset is a multiple of `tab_size`, so it is either zero or already past the right edge, and neither leaves room for a second column. It is also what keeps the sum below in range. - `saturating_add` for the gutter sum, which covers the one case the strict `>` lets through, `full_width == tab_size == usize::MAX`. - `saturating_sub` for the two bounds that could go negative. `full_width = 5, tab_size = 8` really does produce an offset past the right edge. - `usize::midpoint` for the balance point, which cannot overflow. - `saturating_sub` on `separator_pos`, which underflowed for a zero width. Unreachable from the CLI, reachable through the library. A tab stop every zero columns has no meaning and every reader of `tab_size` divides by it, so `Config::new` now normalizes it to one. The layout alone was not enough: fixing it only moved the division by zero from `layout` to `format_tabs_and_spaces`. The new `mod layout` covers the boundaries, including the two that pin the design down: `full_width = 10, tab_size = 8` still leaves two columns, so no looser guard is correct, and `tab_size == full_width` yields a non-zero offset with an empty half line. --- src/side_diff.rs | 191 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 171 insertions(+), 20 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index 56953d2..e4fbf9d 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -3,7 +3,7 @@ // For the full copyright and license information, please view the LICENSE-* // files that was distributed with this source code. -use core::cmp::{max, min}; +use core::cmp::min; use diff::Result; use std::{io::Write, vec}; use unicode_width::UnicodeWidthStr; @@ -74,26 +74,49 @@ impl<'a> Iterator for CharIter<'a> { impl Config { pub fn new(full_width: usize, tab_size: usize, expanded: bool) -> Self { - // diff uses this calculation to calculate the size of a half line - // based on the options passed (like -w, -t, etc.). It's actually - // pretty useless, because we (actually) don't have any size modifiers - // that can change this, however I just want to leave the calculate - // here, since it's not very clear and may cause some confusion - - let w = full_width as isize; - let t = tab_size as isize; - let t_plus_g = t + GUTTER_WIDTH_MIN as isize; - let unaligned_off = (w >> 1) + (t_plus_g >> 1) + (w & t_plus_g & 1); - let off = unaligned_off - unaligned_off % t; - let hw = max(0, min(off - GUTTER_WIDTH_MIN as isize, w - off)) as usize; - let c2o = if hw != 0 { off as usize } else { w as usize }; + let tab_size = tab_size.max(1); + + let (half_width, column_two_offset) = Self::layout(full_width, tab_size); Self { expanded, - sdiff_column_two_offset: c2o, + sdiff_column_two_offset: column_two_offset, tab_size, - sdiff_half_width: hw, - separator_pos: ((hw + c2o - 1) >> 1), + sdiff_half_width: half_width, + separator_pos: (half_width + column_two_offset).saturating_sub(1) >> 1, + } + } + + fn layout(full_width: usize, tab_size: usize) -> (usize, usize) { + debug_assert!(tab_size != 0); + + // The offset is a multiple of tab_size, so a tab stop wider than the + // whole line leaves only two possibilities, zero, or past the right + // edge. Neither leaves room for a second column, so the layout + // collapses to one + if tab_size > full_width { + return (0, full_width); + } + + // Column two starts at the tab stop nearest the midpoint of the two + // halves. midpoint instead of (full_width + span) / 2 because that + // sum does not fit for a width near usize::MAX. The saturation covers + // a value within GUTTER_WIDTH_MIN of usize::MAX on a line of equal width. + let span = tab_size.saturating_add(GUTTER_WIDTH_MIN); + let balance = full_width.midpoint(span); + let offset = balance - balance % tab_size; + + // If either bound would go negative, the half line does not fit. + let half_width = min( + offset.saturating_sub(GUTTER_WIDTH_MIN), + full_width.saturating_sub(offset), + ); + + // If it does not fit, one column spans the whole line. + if half_width == 0 { + (0, full_width) + } else { + (half_width, offset) } } } @@ -223,7 +246,6 @@ fn process_half_line( } } - // gnu sdiff do not tabulate the hole empty right line, instead, just keep the line empty if !is_right { // we always sum + 1 or + GUTTER_WIDTH_MIN cause we want to expand // up to the third column of the gutter column if the gutter is gutter white space, @@ -312,8 +334,6 @@ pub fn diff( output: &mut T, params: &Params, ) -> Vec { - // ^ The left file ^ The right file - let mut left_lines: Vec<&[u8]> = from_file.split_inclusive(|&c| c == b'\n').collect(); let mut right_lines: Vec<&[u8]> = to_file.split_inclusive(|&c| c == b'\n').collect(); let config = Config::new(params.width, params.tabsize, params.expand_tabs); @@ -367,6 +387,136 @@ mod tests { use super::*; + mod layout { + use super::*; + use test_case::test_case; + + #[track_caller] + fn assert_layout( + full_width: usize, + tab_size: usize, + half_width: usize, + column_two_offset: usize, + separator_pos: usize, + ) { + let config = Config::new(full_width, tab_size, false); + + assert_eq!(config.sdiff_half_width, half_width, "half width"); + assert_eq!( + config.sdiff_column_two_offset, column_two_offset, + "column two offset" + ); + assert_eq!(config.separator_pos, separator_pos, "separator pos"); + } + + #[test] + fn default_width_and_tab_size() { + assert_layout(130, 8, 61, 64, 62); + } + + #[test] + fn common_widths() { + assert_layout(80, 8, 37, 40, 38); + assert_layout(10, 7, 3, 7, 4); + } + + #[test] + fn tab_size_wider_than_the_gutter_still_leaves_two_columns() { + assert_layout(10, 8, 2, 8, 4); + } + + #[test] + fn tab_size_equal_to_the_width_leaves_one_column() { + assert_layout(10, 10, 0, 10, 4); + } + + #[test] + fn tab_size_wider_than_the_width_leaves_one_column() { + assert_layout(10, 11, 0, 10, 4); + } + + #[test] + fn smallest_width_the_cli_accepts() { + assert_layout(1, 8, 0, 1, 0); + } + + #[test] + fn column_two_offset_past_the_right_edge() { + assert_layout(5, 8, 0, 5, 2); + } + + #[test] + fn huge_tab_size() { + assert_layout(130, usize::MAX, 0, 130, 64); + assert_layout(130, usize::MAX / 2, 0, 130, 64); + assert_layout(130, 9223372036854775805, 0, 130, 64); + } + + #[test] + fn huge_width() { + let half = usize::MAX / 2 - 2; + let offset = usize::MAX / 2 + 1; + assert_layout(usize::MAX, 8, half, offset, (half + offset - 1) >> 1); + } + + #[test] + fn huge_width_and_tab_size() { + assert_layout(usize::MAX, usize::MAX, 0, usize::MAX, usize::MAX >> 1); + } + + #[test] + fn zero_width() { + assert_layout(0, 8, 0, 0, 0); + } + + #[test] + fn zero_tab_size_behaves_like_one() { + assert_layout(130, 0, 63, 67, 64); + assert_layout(130, 1, 63, 67, 64); + } + + #[track_caller] + fn assert_renders(from: &[u8], to: &[u8], width: usize, tabsize: usize) { + for expand_tabs in [false, true] { + let params = Params { + width, + tabsize, + expand_tabs, + ..Default::default() + }; + let mut output = vec![]; + + diff(from, to, &mut output, ¶ms); + } + } + + #[test_case(0 ; "zero")] + #[test_case(1 ; "one")] + #[test_case(2 ; "two")] + #[test_case(7 ; "odd")] + #[test_case(8 ; "default")] + #[test_case(1000 ; "wider than any line")] + #[test_case(usize::MAX / 2 ; "half of usize")] + #[test_case(usize::MAX - 1 ; "one below usize max")] + #[test_case(usize::MAX ; "usize max")] + fn extreme_tab_size_renders(tabsize: usize) { + for width in [0, 1, 2, 3, 5, 10, 40, 130, 1000, 65535] { + assert_renders(b"a\tb\n", b"a\tc\n", width, tabsize); + } + } + + #[test_case(b"aaa\tbbb\n", b"aaa\tccc\n" ; "tabs on both sides")] + #[test_case(b"\t\t\n", b"\n" ; "tabs against an empty line")] + #[test_case("\u{4f60}\u{597d}\t\u{1f600}\n".as_bytes(), b"a\n" ; "wide and multibyte")] + fn every_small_width_and_tab_size_renders(from: &[u8], to: &[u8]) { + for width in 0..96 { + for tabsize in 0..96 { + assert_renders(from, to, width, tabsize); + } + } + } + } + mod format_tabs_and_spaces { use super::*; @@ -1214,6 +1364,7 @@ mod tests { #[test] fn test_full_width_40_tab_8() { + // Expanded, so the layout uses a tab stop on every column. let config = create_config(40, 8, true); assert_eq!(config.sdiff_half_width, 16); assert_eq!(config.sdiff_column_two_offset, 24); From 6e21ff1d1c5eec97cc16f5dd5de27f15754fabda Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Wed, 12 Aug 2026 23:43:52 -0300 Subject: [PATCH 03/18] fix(side-diff): honour --expand-tabs in the column layout `Config::new` took `expanded` but ignored it when sizing the columns. The manual's "Preserving Tab Stop Alignment" section explains why it matters: column two has to start on a tab stop only so that tabs in the right column keep their position relative to the stops. With `--expand-tabs` there are no tabs left in the output, so there is no grid to preserve and every column is a stop. Compared against the output of `diff` from GNU diffutils 3.10. The separator column now matches it for `-y -t` at widths 40, 80 and 130, and for `-y -t --tabsize=4 --width=100`: columns 19, 39, 64 and 49. At the default width it sat on column 62 before, two columns off. The tab size still decides how far an expanded tab reaches, so it stays in the field and only `layout` sees the 1. Feeding the 1 into the field would shrink every expanded tab to a single space. The widened half line makes a crash reachable. A tab size near `usize::MAX` used to yield an empty half line, so `process_half_line` returned before drawing anything; with `-t` the line now fits and three expressions of the form `current_width + tab_size - (current_width % tab_size)` overflowed. `format_tabs_and_spaces` and the tab arm now measure the step to the next stop against the room that is left instead of summing absolute columns, which the surrounding `current_width <= max_width` already bounds. `test_full_width_40_tab_8` used `expanded = true` and expected the widths computed while ignoring it. The separator lands on column 19 either way, so only the half width and the offset change. The new tests were checked by mutation. Dropping the ternary breaks `expanded_tabs_widen_the_half_line`, `expanded_tabs_lay_out_as_a_stop_on_every_column` and `test_full_width_40_tab_8`; applying it to the field instead breaks `expanded_tabs_keep_the_real_tab_size_for_rendering` and `expanded_tabs_reach_the_next_real_tab_stop`. --- src/side_diff.rs | 135 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 18 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index e4fbf9d..584d981 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -76,7 +76,8 @@ impl Config { pub fn new(full_width: usize, tab_size: usize, expanded: bool) -> Self { let tab_size = tab_size.max(1); - let (half_width, column_two_offset) = Self::layout(full_width, tab_size); + let (half_width, column_two_offset) = + Self::layout(full_width, if expanded { 1 } else { tab_size }); Self { expanded, @@ -143,10 +144,15 @@ fn format_tabs_and_spaces( return Ok(()); } - while current + (tab_size - current % tab_size) <= to { - let next_tab = current + (tab_size - current % tab_size); + loop { + let advance = tab_size - current % tab_size; + + if advance > to - current { + break; + } + buf.write_all(b"\t")?; - current = next_tab; + current += advance; } while current < to { @@ -215,17 +221,18 @@ fn process_half_line( match char { b"\t" => { - if expanded && (current_width + tab_size - (current_width % tab_size)) <= max_width - { - let mut spaces = tab_size - (current_width % tab_size); - while spaces > 0 { - buf.write_all(b" ")?; - current_width += 1; - spaces -= 1; + let advance = tab_size - current_width % tab_size; + + if advance <= max_width - current_width { + if expanded { + for _ in 0..advance { + buf.write_all(b" ")?; + } + } else { + buf.write_all(b"\t")?; } - } else if current_width + tab_size - (current_width % tab_size) <= max_width { - buf.write_all(b"\t")?; - current_width += tab_size - (current_width % tab_size); + + current_width += advance; } } b"\n" => { @@ -399,7 +406,44 @@ mod tests { column_two_offset: usize, separator_pos: usize, ) { - let config = Config::new(full_width, tab_size, false); + assert_config( + full_width, + tab_size, + false, + half_width, + column_two_offset, + separator_pos, + ); + } + + #[track_caller] + fn assert_layout_expanded( + full_width: usize, + tab_size: usize, + half_width: usize, + column_two_offset: usize, + separator_pos: usize, + ) { + assert_config( + full_width, + tab_size, + true, + half_width, + column_two_offset, + separator_pos, + ); + } + + #[track_caller] + fn assert_config( + full_width: usize, + tab_size: usize, + expanded: bool, + half_width: usize, + column_two_offset: usize, + separator_pos: usize, + ) { + let config = Config::new(full_width, tab_size, expanded); assert_eq!(config.sdiff_half_width, half_width, "half width"); assert_eq!( @@ -414,6 +458,61 @@ mod tests { assert_layout(130, 8, 61, 64, 62); } + #[test] + fn expanded_tabs_lay_out_as_a_stop_on_every_column() { + assert_layout(130, 1, 63, 67, 64); + assert_layout_expanded(130, 8, 63, 67, 64); + assert_layout_expanded(130, usize::MAX, 63, 67, 64); + } + + #[test] + fn expanded_tabs_widen_the_half_line() { + assert_layout(130, 8, 61, 64, 62); + assert_layout_expanded(130, 8, 63, 67, 64); + + assert_layout(40, 8, 16, 24, 19); + assert_layout_expanded(40, 8, 18, 22, 19); + } + + #[test] + fn expanded_tabs_keep_the_real_tab_size_for_rendering() { + assert_eq!(Config::new(130, 8, true).tab_size, 8); + assert_eq!(Config::new(130, 8, false).tab_size, 8); + } + + #[test] + fn expanded_tabs_reach_the_next_real_tab_stop() { + let params = Params { + width: 40, + tabsize: 8, + expand_tabs: true, + ..Default::default() + }; + let mut output = vec![]; + + diff(b"a\tb\n", b"a\tc\n", &mut output, ¶ms); + + assert!(!output.contains(&b'\t'), "expanded output still has tabs"); + assert!( + output.starts_with(b"a b"), + "tab did not reach column 8" + ); + } + + #[test] + fn unexpanded_tabs_stay_tabs() { + let params = Params { + width: 40, + tabsize: 8, + ..Default::default() + }; + let mut output = vec![]; + + diff(b"a\tb\n", b"a\tc\n", &mut output, ¶ms); + + assert!(output.starts_with(b"a\tb")); + } + #[test] fn common_widths() { assert_layout(80, 8, 37, 40, 38); @@ -1366,9 +1465,9 @@ mod tests { fn test_full_width_40_tab_8() { // Expanded, so the layout uses a tab stop on every column. let config = create_config(40, 8, true); - assert_eq!(config.sdiff_half_width, 16); - assert_eq!(config.sdiff_column_two_offset, 24); - assert_eq!(config.separator_pos, 19); // (16 +24 -1) /2 = 19.5 + assert_eq!(config.sdiff_half_width, 18); + assert_eq!(config.sdiff_column_two_offset, 22); + assert_eq!(config.separator_pos, 19); // (18 + 22 - 1) / 2 = 19.5 } #[test] From de42d46d766aac99f191fd06e26b03aaad853790 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Wed, 12 Aug 2026 23:44:09 -0300 Subject: [PATCH 04/18] test(fuzz): fuzz width and tabsize in the side-by-side target `width` and `tabsize` were commented out of `fuzz_side`, along with a `width == 0 || tabsize == 0` early return that was commented out too, because the column arithmetic could not take arbitrary values. It can now, so both are fed to the target and neither guard is needed. They are `u16` rather than `usize`: the layout handles the whole range, but a width near `usize::MAX` asks the renderer for petabytes of padding, which would only produce timeouts. --- fuzz/fuzz_targets/fuzz_side.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_side.rs b/fuzz/fuzz_targets/fuzz_side.rs index 45580ef..95c4cee 100644 --- a/fuzz/fuzz_targets/fuzz_side.rs +++ b/fuzz/fuzz_targets/fuzz_side.rs @@ -8,16 +8,12 @@ use diffutilslib::params::Params; use std::fs::{self, File}; use std::io::Write; -fuzz_target!(|x: (Vec, Vec, /* usize, usize */ bool)| { - let (original, new, /* width, tabsize, */ expand) = x; - - // if width == 0 || tabsize == 0 { - // return; - // } +fuzz_target!(|x: (Vec, Vec, u16, u16, bool)| { + let (original, new, width, tabsize, expand) = x; let params = Params { - // width, - // tabsize, + width: width as usize, + tabsize: tabsize as usize, expand_tabs: expand, ..Default::default() }; From 1f4ed9f8747ef42a07787e7fe95b1d646b84b116 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Thu, 13 Aug 2026 00:31:05 -0300 Subject: [PATCH 05/18] test(side-diff): drop the comment on the expanded layout expectation The assertions state the widths already, and the surrounding test name carries the tab size. --- src/side_diff.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index 584d981..2a208e8 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -1463,7 +1463,6 @@ mod tests { #[test] fn test_full_width_40_tab_8() { - // Expanded, so the layout uses a tab stop on every column. let config = create_config(40, 8, true); assert_eq!(config.sdiff_half_width, 18); assert_eq!(config.sdiff_column_two_offset, 22); From 2c45eda55b207b7fd9fdc433e63d7120dfad58a2 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Thu, 13 Aug 2026 00:43:18 -0300 Subject: [PATCH 06/18] test(side-diff): cover the carriage return padding at the widest line A carriage return pads all the way to column two, the only caller that reaches the far end of the line. That walk used to add absolute columns and overflowed with `--width` and `--tabsize` at the top of the range, which aborts under `-C overflow-checks=on`. Both values sit at the maximum on purpose: a smaller tab size walks to that end one stop at a time, and a smaller width never reaches the sum that overflowed. Expansion stays off, since it pads with spaces one column at a time and would not finish at this width. --- src/side_diff.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/side_diff.rs b/src/side_diff.rs index 2a208e8..7e60e8a 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -604,6 +604,19 @@ mod tests { } } + #[test] + fn carriage_return_at_the_widest_line_and_tab_size() { + let params = Params { + width: usize::MAX, + tabsize: usize::MAX, + expand_tabs: false, + ..Default::default() + }; + let mut output = vec![]; + + diff(b"a\rb\n", b"c\n", &mut output, ¶ms); + } + #[test_case(b"aaa\tbbb\n", b"aaa\tccc\n" ; "tabs on both sides")] #[test_case(b"\t\t\n", b"\n" ; "tabs against an empty line")] #[test_case("\u{4f60}\u{597d}\t\u{1f600}\n".as_bytes(), b"a\n" ; "wide and multibyte")] From dd1a9517f317a4ee78e808fede3cce80e0574ba5 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Thu, 13 Aug 2026 00:53:07 -0300 Subject: [PATCH 07/18] test(fuzz): fuzz the whole tab size range in the side-by-side target A tab size wider than the line collapses the layout to a single column and draws nothing, so the whole `usize` range costs no more than a small one. Capping it at `u16` left out the values above `isize::MAX`, which are the ones the old signed arithmetic turned negative. The width stays a `u16`, since the padding it asks for is written one column at a time and the cost scales with it. --- fuzz/fuzz_targets/fuzz_side.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_side.rs b/fuzz/fuzz_targets/fuzz_side.rs index 95c4cee..69d62aa 100644 --- a/fuzz/fuzz_targets/fuzz_side.rs +++ b/fuzz/fuzz_targets/fuzz_side.rs @@ -8,12 +8,17 @@ use diffutilslib::params::Params; use std::fs::{self, File}; use std::io::Write; -fuzz_target!(|x: (Vec, Vec, u16, u16, bool)| { +// We can't fuzz with width equals to usize, otherwise we +// would could have 2⁢⁴ - 1 of padding columns, which means +// exabytes nescessary for this. u32 also doesn't have a +// great perfomance here, with almost 537 MB being nescessary +// and 57 seconds of execution. +fuzz_target!(|x: (Vec, Vec, u16, usize, bool)| { let (original, new, width, tabsize, expand) = x; let params = Params { width: width as usize, - tabsize: tabsize as usize, + tabsize, expand_tabs: expand, ..Default::default() }; From d13692016ce6625358eb9cb281d930ff5fffdd9c Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Thu, 13 Aug 2026 00:54:49 -0300 Subject: [PATCH 08/18] style(fuzz): write the exponent in the width comment as ASCII Superscript digits do not survive every terminal or editor the file gets read in. --- fuzz/fuzz_targets/fuzz_side.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/fuzz_targets/fuzz_side.rs b/fuzz/fuzz_targets/fuzz_side.rs index 69d62aa..3fddb94 100644 --- a/fuzz/fuzz_targets/fuzz_side.rs +++ b/fuzz/fuzz_targets/fuzz_side.rs @@ -9,7 +9,7 @@ use std::fs::{self, File}; use std::io::Write; // We can't fuzz with width equals to usize, otherwise we -// would could have 2⁢⁴ - 1 of padding columns, which means +// would could have 2^64 - 1 of padding columns, which means // exabytes nescessary for this. u32 also doesn't have a // great perfomance here, with almost 537 MB being nescessary // and 57 seconds of execution. From 1c1fb0c4f2ab19f9f0b73b74d24837d4da24e26a Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:10:25 -0300 Subject: [PATCH 09/18] test(side-diff): replace test-case with plain loops Drop the test-case dev-dependency. Only two layout tests used it, so `extreme_tab_size_renders` and `every_small_width_and_tab_size_renders` now iterate over the same inputs in a loop inside a single `#[test]`. The inputs are unchanged. The cases no longer show up as separately named tests. --- Cargo.lock | 34 ---------------------------------- Cargo.toml | 1 - src/side_diff.rs | 48 ++++++++++++++++++++++++++++-------------------- 3 files changed, 28 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b287de2..af7c96e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,7 +274,6 @@ dependencies = [ "regex", "same-file", "tempfile", - "test-case", "unicode-width", ] @@ -777,39 +776,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "test-case" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" -dependencies = [ - "test-case-macros", -] - -[[package]] -name = "test-case-core" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "test-case-macros" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "test-case-core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index a23e9bc..ded812c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ pretty_assertions = "1.4.0" predicates = "3.1.0" rand = "0.10.0" tempfile = "3.26.0" -test-case = "3.3.1" [profile.release] lto = "thin" diff --git a/src/side_diff.rs b/src/side_diff.rs index 7e60e8a..da35770 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -396,7 +396,6 @@ mod tests { mod layout { use super::*; - use test_case::test_case; #[track_caller] fn assert_layout( @@ -589,18 +588,22 @@ mod tests { } } - #[test_case(0 ; "zero")] - #[test_case(1 ; "one")] - #[test_case(2 ; "two")] - #[test_case(7 ; "odd")] - #[test_case(8 ; "default")] - #[test_case(1000 ; "wider than any line")] - #[test_case(usize::MAX / 2 ; "half of usize")] - #[test_case(usize::MAX - 1 ; "one below usize max")] - #[test_case(usize::MAX ; "usize max")] - fn extreme_tab_size_renders(tabsize: usize) { - for width in [0, 1, 2, 3, 5, 10, 40, 130, 1000, 65535] { - assert_renders(b"a\tb\n", b"a\tc\n", width, tabsize); + #[test] + fn extreme_tab_size_renders() { + for tabsize in [ + 0, + 1, + 2, + 7, + 8, + 1000, + usize::MAX / 2, + usize::MAX - 1, + usize::MAX, + ] { + for width in [0, 1, 2, 3, 5, 10, 40, 130, 1000, 65535] { + assert_renders(b"a\tb\n", b"a\tc\n", width, tabsize); + } } } @@ -617,13 +620,18 @@ mod tests { diff(b"a\rb\n", b"c\n", &mut output, ¶ms); } - #[test_case(b"aaa\tbbb\n", b"aaa\tccc\n" ; "tabs on both sides")] - #[test_case(b"\t\t\n", b"\n" ; "tabs against an empty line")] - #[test_case("\u{4f60}\u{597d}\t\u{1f600}\n".as_bytes(), b"a\n" ; "wide and multibyte")] - fn every_small_width_and_tab_size_renders(from: &[u8], to: &[u8]) { - for width in 0..96 { - for tabsize in 0..96 { - assert_renders(from, to, width, tabsize); + #[test] + fn every_small_width_and_tab_size_renders() { + let cases: [(&[u8], &[u8]); 3] = [ + (b"aaa\tbbb\n", b"aaa\tccc\n"), + (b"\t\t\n", b"\n"), + ("\u{4f60}\u{597d}\t\u{1f600}\n".as_bytes(), b"a\n"), + ]; + for (from, to) in cases { + for width in 0..96 { + for tabsize in 0..96 { + assert_renders(from, to, width, tabsize); + } } } } From 326eaee6f2fb2f122b76ad374125ca92d0e24fdd Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:11:44 -0300 Subject: [PATCH 10/18] style(fuzz): shorten the width comment in the side-by-side target Cut the comment from five lines to two. The rewrite also drops the misspelled words that cspell would flag. --- fuzz/fuzz_targets/fuzz_side.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/fuzz/fuzz_targets/fuzz_side.rs b/fuzz/fuzz_targets/fuzz_side.rs index 3fddb94..8493fc9 100644 --- a/fuzz/fuzz_targets/fuzz_side.rs +++ b/fuzz/fuzz_targets/fuzz_side.rs @@ -8,11 +8,8 @@ use diffutilslib::params::Params; use std::fs::{self, File}; use std::io::Write; -// We can't fuzz with width equals to usize, otherwise we -// would could have 2^64 - 1 of padding columns, which means -// exabytes nescessary for this. u32 also doesn't have a -// great perfomance here, with almost 537 MB being nescessary -// and 57 seconds of execution. +// The width is a u16 because the output is padded up to it. A usize +// width needs exabytes, and a u32 one took 537 MB and 57 seconds. fuzz_target!(|x: (Vec, Vec, u16, usize, bool)| { let (original, new, width, tabsize, expand) = x; From 6a270f2d70763c29059e90748f99047702e126db Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:13:58 -0300 Subject: [PATCH 11/18] style(side-diff): restore the comment on the unpadded right line fc18aa5 deleted the comment above the `if !is_right` block in `process_half_line` without changing the code it describes. Put it back verbatim, since it still explains why only the left half is padded. --- src/side_diff.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/side_diff.rs b/src/side_diff.rs index da35770..3e357d5 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -253,6 +253,7 @@ fn process_half_line( } } + // gnu sdiff do not tabulate the hole empty right line, instead, just keep the line empty if !is_right { // we always sum + 1 or + GUTTER_WIDTH_MIN cause we want to expand // up to the third column of the gutter column if the gutter is gutter white space, From 06445df68fbee8369618e415ea2e9f62ad33e855 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:15:05 -0300 Subject: [PATCH 12/18] style(side-diff): restore the left and right file notes on diff fc18aa5 deleted the comment under the `diff` signature that marked `from_file` as the left file and `to_file` as the right one. Bring the notes back as trailing comments on the two parameters, since the original caret line no longer lines up with a signature that puts one parameter per line. --- src/side_diff.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index 3e357d5..c6bc55b 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -337,8 +337,8 @@ fn push_output( } pub fn diff( - from_file: &[u8], - to_file: &[u8], + from_file: &[u8], // The left file + to_file: &[u8], // The right file output: &mut T, params: &Params, ) -> Vec { From 5b26b671cca975786533b94e2ac1bb15399d9677 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:17:14 -0300 Subject: [PATCH 13/18] test(side-diff): drop the duplicated width 130 layout assertions `expanded_tabs_widen_the_half_line` repeated two assertions that other tests already make: the plain layout for width 130 and tab size 8 is `default_width_and_tab_size`, and the expanded one is in `expanded_tabs_lay_out_as_a_stop_on_every_column`. The width 40 pair stays. No other layout test covers it. --- src/side_diff.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index c6bc55b..cb816c7 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -467,9 +467,6 @@ mod tests { #[test] fn expanded_tabs_widen_the_half_line() { - assert_layout(130, 8, 61, 64, 62); - assert_layout_expanded(130, 8, 63, 67, 64); - assert_layout(40, 8, 16, 24, 19); assert_layout_expanded(40, 8, 18, 22, 19); } From 36b3d9bbfba5c475576d501b702453f9357cc70d Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:21:39 -0300 Subject: [PATCH 14/18] test(side-diff): assert on the rendered output The render sweeps only proved that `diff` does not panic. They now also check that the output has no tab when `expand_tabs` is set, for every width and tab size they cover. Add `changed_line_output` and `unchanged_line_output`, which compare the whole output for three cases at tab size 8: - a changed line at width 130 - a changed line at width 40 with `expand_tabs` - an unchanged line at width 130 The expected lines match what GNU diff 3.10 prints for a deleted, an added and an unchanged line at the same widths. --- src/side_diff.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/side_diff.rs b/src/side_diff.rs index cb816c7..333fd29 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -583,9 +583,60 @@ mod tests { let mut output = vec![]; diff(from, to, &mut output, ¶ms); + + if expand_tabs { + assert!( + !output.contains(&b'\t'), + "tab in expanded output, width {width}, tab size {tabsize}" + ); + } } } + #[track_caller] + fn assert_output(from: &[u8], to: &[u8], width: usize, expand_tabs: bool, expected: &str) { + let params = Params { + width, + tabsize: 8, + expand_tabs, + ..Default::default() + }; + let mut output = vec![]; + + diff(from, to, &mut output, ¶ms); + + assert_eq!(String::from_utf8_lossy(&output), expected); + } + + #[test] + fn changed_line_output() { + assert_output( + b"a\tb\n", + b"a\tc\n", + 130, + false, + "a\tb\t\t\t\t\t\t <\n\t\t\t\t\t\t\t >\ta\tc\n", + ); + assert_output( + b"a\tb\n", + b"a\tc\n", + 40, + true, + "a b <\n > a c\n", + ); + } + + #[test] + fn unchanged_line_output() { + assert_output( + b"same\n", + b"same\n", + 130, + false, + "same\t\t\t\t\t\t\t\tsame\n", + ); + } + #[test] fn extreme_tab_size_renders() { for tabsize in [ From 9b5ab11f20fc448cfc5910934021ee9ec5aeee4c Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:24:33 -0300 Subject: [PATCH 15/18] test(side-diff): assert the output at the widest line and tab size `carriage_return_at_the_widest_line_and_tab_size` only checked that `diff` does not abort. It now also compares the rendered output. --- src/side_diff.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/side_diff.rs b/src/side_diff.rs index 333fd29..05360ba 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -667,6 +667,8 @@ mod tests { let mut output = vec![]; diff(b"a\rb\n", b"c\n", &mut output, ¶ms); + + assert_eq!(String::from_utf8_lossy(&output), " <\n >\t\n"); } #[test] From c7353cee1591d1880ea588c3994aeabc88ae7848 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Mon, 5 Oct 2026 22:34:38 -0300 Subject: [PATCH 16/18] test(side-diff): adapt two tests to the gutter marker fix from main The merge brought in the fix for #269, which pads the gutter up to `separator_pos` before the marker. Two tests no longer held. - `carriage_return_at_the_widest_line_and_tab_size` ran `diff` with the width and the tab size both at `usize::MAX`. That padding is now 2^63 spaces, so the test never finished. It becomes `padding_to_the_end_of_the_widest_line_and_tab_size` and calls `format_tabs_and_spaces(0, usize::MAX, ...)` directly, which is the walk that used to overflow, and expects a single tab. - `sdiff_gutter_marker_column` expected the right column at 24 for `--width=40 --expand-tabs`, the offset of the layout that ignored `--expand-tabs`. This branch lays it out at column 22, which is where GNU diff 3.10 puts it. --- src/side_diff.rs | 13 ++++--------- tests/integration.rs | 5 +---- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index ef84ecb..074f7a1 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -659,18 +659,13 @@ mod tests { } #[test] - fn carriage_return_at_the_widest_line_and_tab_size() { - let params = Params { - width: usize::MAX, - tabsize: usize::MAX, - expand_tabs: false, - ..Default::default() - }; + fn padding_to_the_end_of_the_widest_line_and_tab_size() { + let config = Config::new(usize::MAX, usize::MAX, false); let mut output = vec![]; - diff(b"a\rb\n", b"c\n", &mut output, ¶ms); + format_tabs_and_spaces(0, usize::MAX, &config, &mut output).unwrap(); - assert_eq!(String::from_utf8_lossy(&output), " <\n >\t\n"); + assert_eq!(output, b"\t"); } #[test] diff --git a/tests/integration.rs b/tests/integration.rs index ad5efd9..416691e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -361,10 +361,7 @@ mod diff { .arg(file1.path()) .arg(file2.path()); let output = cmd.output().unwrap().stdout; - assert_eq!( - output, - b"aa <\n > bb\n" - ); + assert_eq!(output, b"aa <\n > bb\n"); // Default (expand_tabs: false): padding goes through the tab path. let mut cmd = cargo_bin_cmd!("diffutils"); From 7c6985725961f280ba5064e27787405fec903f35 Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Tue, 6 Oct 2026 00:10:58 -0300 Subject: [PATCH 17/18] Trigger CI From ecf4ab1d52fba4cfc9eb60b0048cf06c276b091d Mon Sep 17 00:00:00 2001 From: Sami Daniel Date: Tue, 6 Oct 2026 00:31:11 -0300 Subject: [PATCH 18/18] fix(side-diff): lay out each row from the column the text ended at The left line was padded up to a fixed column and the right one written after it. That caused three faults: - the right half of an unchanged row started at half_width + 3 instead of column two, so it was misplaced at any width other than 130 and with --expand-tabs - a blank line common to both files was padded instead of left empty - a tab reaching the end of a half was dropped and the text after it was still printed, instead of the row being cut there Replace process_half_line with print_half_line, which prints only the text of a half and returns the column where the output stopped, and make push_output pad from that column to the marker and to column two. print_half_line keeps two positions, the column the text has reached and the column the output has, so that what follows a cut tab is cut too. A carriage return goes back to the start of the half, a backspace moves one column back, and control characters and bytes that are not a character take no column. Closes #300. --- src/side_diff.rs | 602 ++++++++++++++++------------------------------- 1 file changed, 198 insertions(+), 404 deletions(-) diff --git a/src/side_diff.rs b/src/side_diff.rs index 074f7a1..e19e0de 100644 --- a/src/side_diff.rs +++ b/src/side_diff.rs @@ -6,7 +6,7 @@ use core::cmp::min; use diff::Result; use std::{io::Write, vec}; -use unicode_width::UnicodeWidthStr; +use unicode_width::UnicodeWidthChar; use crate::params::Params; @@ -31,8 +31,9 @@ impl<'a> From<&'a [u8]> for CharIter<'a> { } impl<'a> Iterator for CharIter<'a> { - // (bytes for the next char, visible width) - type Item = (&'a [u8], usize); + // (bytes for the next char, visible width). The width is None when the + // bytes are not a character. + type Item = (&'a [u8], Option); fn next(&mut self) -> Option { let max = self.current.len().min(4); @@ -61,12 +62,14 @@ impl<'a> Iterator for CharIter<'a> { .current .get(view.len()..) .unwrap_or(&self.current[0..0]); - Some((view, UnicodeWidthStr::width(c))) + // A control character has no width, it does not move the column. + let width = c.chars().next().and_then(UnicodeWidthChar::width); + Some((view, Some(width.unwrap_or(0)))) } Err(_) => { // We did not find an utf-8 char within the next 4 bytes, return the single byte. self.current = &self.current[1..]; - Some((&view[..1], 1)) + Some((&view[..1], None)) } } } @@ -163,116 +166,96 @@ fn format_tabs_and_spaces( Ok(()) } -fn process_half_line( - s: &[u8], - max_width: usize, - is_right: bool, - white_space_gutter: bool, +// Print the text of one half of a row, cut at out_bound columns, and return +// the column where the output stopped. indent is the column the half starts +// at, a carriage return goes back to it. +fn print_half_line( + line: &[u8], + indent: usize, + out_bound: usize, config: &Config, buf: &mut T, -) -> std::io::Result<()> { - if s.is_empty() { - if !is_right { - format_tabs_and_spaces( - 0, - max_width - + if white_space_gutter { - GUTTER_WIDTH_MIN - } else { - 1 - }, - config, - buf, - )?; - } - - return Ok(()); - } - - if max_width > config.sdiff_half_width { - return Ok(()); - } - - if max_width > config.sdiff_column_two_offset && !is_right { - return Ok(()); - } - +) -> std::io::Result { let expanded = config.expanded; let tab_size = config.tab_size; - let sdiff_column_two_offset = config.sdiff_column_two_offset; - let mut current_width = 0; - let iter = CharIter::from(s); - - // the encoding will probably be compatible with utf8, so we can take advantage - // of that to get the size of the columns and iterate without breaking the encoding of anything. - // It seems like a good trade, since there is still a fallback in case it is not utf8. - // But I think it would be better if we used some lib that would allow us to handle this - // in the best way possible, in order to avoid overhead (currently 2 for loops are needed). - // There is a library called mcel (mcel.h) that is used in GNU diff, but the documentation - // about it is very scarce, nor is its use documented on the internet. In fact, from my - // research I didn't even find any information about it in the GNU lib's own documentation. - - for c in iter { - let (char, c_width) = c; - - if current_width + c_width > max_width { - break; - } + // in_position is the column the text has reached, out_position the one + // the output has. They part once something is cut. + let mut in_position: usize = 0; + let mut out_position: usize = 0; + + for (char, width) in CharIter::from(line) { match char { b"\t" => { - let advance = tab_size - current_width % tab_size; + let spaces = tab_size - in_position % tab_size; + + if in_position == out_position { + let tabstop = out_position.saturating_add(spaces); - if advance <= max_width - current_width { if expanded { - for _ in 0..advance { + while out_position < min(tabstop, out_bound) { buf.write_all(b" ")?; + out_position += 1; } - } else { + } else if tabstop < out_bound { + out_position = tabstop; buf.write_all(b"\t")?; } - - current_width += advance; } + + in_position = in_position.saturating_add(spaces); } b"\n" => { break; } b"\r" => { buf.write_all(b"\r")?; - format_tabs_and_spaces(0, sdiff_column_two_offset, config, buf)?; - current_width = 0; + format_tabs_and_spaces(0, indent, config, buf)?; + in_position = 0; + out_position = 0; } - b"\0" | b"\x07" | b"\x0C" | b"\x0B" => { - buf.write_all(char)?; + b"\x08" => { + if in_position != 0 { + in_position -= 1; + + if in_position < out_bound { + if out_position <= in_position { + // make up for a tab that was cut + while out_position < in_position { + buf.write_all(b" ")?; + out_position += 1; + } + } else { + out_position = in_position; + buf.write_all(char)?; + } + } + } } - _ => { - buf.write_all(char)?; - current_width += c_width; + b"\0" | b"\x0C" | b"\x0B" => { + if in_position < out_bound { + buf.write_all(char)?; + } } - } - } + _ => match width { + Some(width) => { + in_position = in_position.saturating_add(width); - // gnu sdiff do not tabulate the hole empty right line, instead, just keep the line empty - if !is_right { - // we always sum + 1 or + GUTTER_WIDTH_MIN cause we want to expand - // up to the third column of the gutter column if the gutter is gutter white space, - // otherwise we can expand to only the first column of the gutter middle column, cause - // the next is the sep char - format_tabs_and_spaces( - current_width, - max_width - + if white_space_gutter { - GUTTER_WIDTH_MIN - } else { - 1 - }, - config, - buf, - )?; + if in_position <= out_bound { + out_position = in_position; + buf.write_all(char)?; + } + } + None => { + if in_position < out_bound { + buf.write_all(char)?; + } + } + }, + } } - Ok(()) + Ok(out_position) } fn push_output( @@ -287,7 +270,6 @@ fn push_output( return Ok(()); } - let white_space_gutter = symbol == b' '; let half_width = config.sdiff_half_width; let column_two_offset = config.sdiff_column_two_offset; let separator_pos = config.separator_pos; @@ -303,33 +285,26 @@ fn push_output( // put_new_line = put_new_line || (right_ln.last() == Some(&b'\n')); // } - process_half_line( - left_ln, - half_width, - false, - white_space_gutter, - config, - output, - )?; - if symbol != b' ' { - // the diff always want to put all tabs possible in the usable are, - // even in the middle space between the gutters if possible. + // Every padding starts at the column the output is at, so a short or a + // blank left line never leaves the right one out of place. + let mut column = 0; + + if !left_ln.is_empty() { + column = print_half_line(left_ln, 0, half_width, config, output)?; + } - // Pad to the middle of the gutter so the marker doesn't drift on wider gutters (#269). - format_tabs_and_spaces(half_width + 1, separator_pos, config, output)?; + if symbol != b' ' { + // The marker sits at the middle of the gutter. + format_tabs_and_spaces(column, separator_pos, config, output)?; output.write_all(&[symbol])?; - if !right_ln.is_empty() { - format_tabs_and_spaces(separator_pos + 1, column_two_offset, config, output)?; - } + column = separator_pos + 1; + } + + // A blank right line is not padded, the row ends where it is. + if !right_ln.is_empty() && right_ln[0] != b'\n' { + format_tabs_and_spaces(column, column_two_offset, config, output)?; + print_half_line(right_ln, column_two_offset, half_width, config, output)?; } - process_half_line( - right_ln, - half_width, - true, - white_space_gutter, - config, - output, - )?; if put_new_line { writeln!(output)?; @@ -628,6 +603,53 @@ mod tests { ); } + #[test] + fn common_line_starts_at_column_two() { + assert_output( + b"a\nb\nc\n", + b"a\nB\nc\n", + 40, + false, + "a\t\t\ta\nb\t\t <\n\t\t >\tB\nc\t\t\tc\n", + ); + assert_output( + b"a\nb\nc\n", + b"a\nB\nc\n", + 40, + true, + "a a\nb <\n > B\nc c\n", + ); + } + + #[test] + fn common_blank_line_is_left_empty() { + assert_output( + b"a\n\nc\n", + b"a\n\nc\n", + 130, + false, + "a\t\t\t\t\t\t\t\ta\n\nc\t\t\t\t\t\t\t\tc\n", + ); + assert_output( + b"a\n\nc\n", + b"a\n\nc\n", + 40, + true, + "a a\n\nc c\n", + ); + } + + #[test] + fn tab_reaching_the_end_of_the_right_half_is_cut() { + assert_output( + b" \tspaces\n", + b"\t\t tabs\n", + 40, + false, + " \tspaces\t <\n\t\t >\t\t\n", + ); + } + #[test] fn unchanged_line_output() { assert_output( @@ -782,355 +804,127 @@ mod tests { } } - mod process_half_line { + mod print_half_line { use super::*; - fn create_test_config(expanded: bool, tab_size: usize) -> Config { - Config { - sdiff_half_width: 30, - sdiff_column_two_offset: 60, - tab_size, + #[track_caller] + fn assert_half_line( + line: &[u8], + indent: usize, + out_bound: usize, + expanded: bool, + expected: &[u8], + expected_column: usize, + ) { + let config = Config { + sdiff_half_width: out_bound, + sdiff_column_two_offset: indent, + tab_size: 8, expanded, - separator_pos: 15, - } - } - - #[test] - fn test_empty_line_left_expanded_false() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"", 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf.len(), 5); - assert_eq!(buf, vec![b'\t', b'\t', b' ', b' ', b' ']); - } - - #[test] - fn test_tabs_unexpanded() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"\tabc", 8, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, vec![b'\t', b'a', b'b', b'c', b'\t', b' ']); - } - - #[test] - fn test_utf8_multibyte() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = "πŸ˜‰πŸ˜‰πŸ˜‰".as_bytes(); - process_half_line(s, 3, false, false, &config, &mut buf).unwrap(); - let mut r = vec![]; - r.write_all("πŸ˜‰\t".as_bytes()).unwrap(); - assert_eq!(buf, r) - } - - #[test] - fn test_newline_handling() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"abc\ndef", 5, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, vec![b'a', b'b', b'c', b'\t', b' ', b' ']); - } - - #[test] - fn test_carriage_return() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"\rxyz", 5, true, false, &config, &mut buf).unwrap(); - let mut r = vec![b'\r']; - r.extend(vec![b'\t'; 15]); - r.extend(vec![b'x', b'y', b'z']); - assert_eq!(buf, r); - } - - #[test] - fn test_exact_width_fit() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"abcd", 4, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf.len(), 5); - assert_eq!(buf, b"abcd ".to_vec()); - } - - #[test] - fn test_non_utf8_bytes() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - // ISO-8859-1 - process_half_line( - &[0x63, 0x61, 0x66, 0xE9], - 5, - false, - false, - &config, - &mut buf, - ) - .unwrap(); - assert_eq!(&buf, &[0x63, 0x61, 0x66, 0xE9, b' ', b' ']); - assert!(String::from_utf8(buf).is_err()); - } - - #[test] - fn test_non_utf8_bytes_ignore_padding_bytes() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - - let utf32le_bytes = [ - 0x63, 0x00, 0x00, 0x00, // 'c' - 0x61, 0x00, 0x00, 0x00, // 'a' - 0x66, 0x00, 0x00, 0x00, // 'f' - 0xE9, 0x00, 0x00, 0x00, // 'Γ©' - ]; - // utf8 little endiand 32 bits (or 4 bytes per char) - process_half_line(&utf32le_bytes, 6, false, false, &config, &mut buf).unwrap(); - let mut r = utf32le_bytes.to_vec(); - r.extend(vec![b' '; 3]); - assert_eq!(buf, r); - } - - #[test] - fn test_non_utf8_non_preserve_ascii_bytes_cut() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - - let gb18030 = b"\x63\x61\x66\xA8\x80"; // some random chinese encoding - // ^ Γ© char, start multi byte - process_half_line(gb18030, 4, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"\x63\x61\x66\xA8 "); // break the encoding of 'Γ©' letter - } - - #[test] - fn test_right_line_padding() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - process_half_line(b"xyz", 5, true, true, &config, &mut buf).unwrap(); - assert_eq!(buf.len(), 3); - } - - #[test] - fn test_mixed_tabs_spaces() { - let config = create_test_config(false, DEF_TAB_SIZE); + separator_pos: 0, + }; let mut buf = vec![]; - process_half_line(b"\t \t", 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, vec![b'\t', b' ', b' ', b'\t', b' ', b' ', b' ']); - } + let column = print_half_line(line, indent, out_bound, &config, &mut buf).unwrap(); - #[test] - fn test_overflow_multibyte() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = "ζ—₯本θͺž".as_bytes(); - process_half_line(s, 5, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, "ζ—₯本 ".as_bytes()); + assert_eq!(buf, expected); + assert_eq!(column, expected_column); } #[test] - fn test_white_space_gutter() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc"; - process_half_line(s, 3, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abc\t "); + fn test_text_that_fits() { + assert_half_line(b"abc", 0, 10, false, b"abc", 3); } #[test] - fn test_expanded_true() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abc ") + fn test_empty_line() { + assert_half_line(b"", 0, 10, false, b"", 0); } #[test] - fn test_expanded_true_with_gutter() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc"; - process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abc ") + fn test_stops_at_the_newline() { + assert_half_line(b"abc\ndef", 0, 10, false, b"abc", 3); } #[test] - fn test_width0_chars() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc\0\x0B\x07\x0C"; - process_half_line(s, 4, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abc\0\x0B\x07\x0C\t ") + fn test_cut_at_the_bound() { + assert_half_line(b"abcdef", 0, 4, false, b"abcd", 4); } #[test] - fn test_left_empty_white_space_gutter() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b""; - process_half_line(s, 9, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b"\t\t\t"); + fn test_zero_bound() { + assert_half_line(b"abc", 0, 0, false, b"", 0); } #[test] - fn test_s_size_eq_max_width_p1() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abcdefghij"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abcdefghij "); + fn test_tab_inside_the_bound() { + assert_half_line(b"a\tb", 0, 16, false, b"a\tb", 9); } #[test] - fn test_mixed_tabs_and_spaces_inversion() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b" \t \t "; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b" \t \t "); + fn test_tab_reaching_the_bound_is_cut() { + // the text after the tab is past the bound, even without the tab + assert_half_line(b"a\tb", 0, 8, false, b"a", 1); + assert_half_line(b"\tab", 0, 8, false, b"", 0); } #[test] - fn test_expanded_with_tabs() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b" \t \t "; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b" "); + fn test_only_the_first_cut_tab_counts() { + assert_half_line(b"a\t\t\tb", 0, 12, false, b"a\t", 8); } #[test] - fn test_expanded_with_tabs_and_space_gutter() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b" \t \t "; - process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b" "); + fn test_expanded_tab() { + assert_half_line(b"a\tb", 0, 16, true, b"a b", 9); } #[test] - fn test_zero_width_unicode_chars() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = "\u{200B}".as_bytes(); - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, "\u{200B}\t\t ".as_bytes()); + fn test_expanded_tab_stops_at_the_bound() { + assert_half_line(b"a\tb", 0, 5, true, b"a ", 5); } #[test] - fn test_multiple_carriage_returns() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"\r\r"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - let mut r = vec![b'\r']; - r.extend(vec![b'\t'; 15]); - r.push(b'\r'); - r.extend(vec![b'\t'; 15]); - r.extend(vec![b'\t'; 2]); - r.extend(vec![b' '; 3]); - assert_eq!(buf, r); + fn test_wide_characters() { + assert_half_line("ζ—₯本θͺž".as_bytes(), 0, 5, false, "ζ—₯本".as_bytes(), 4); } #[test] - fn test_multiple_carriage_returns_is_right_true() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"\r\r"; - process_half_line(s, 10, true, false, &config, &mut buf).unwrap(); - let mut r = vec![b'\r']; - r.extend(vec![b'\t'; 15]); - r.push(b'\r'); - r.extend(vec![b'\t'; 15]); - assert_eq!(buf, r); + fn test_zero_width_character_at_the_bound() { + let line = "abc\u{301}d".as_bytes(); + assert_half_line(line, 0, 3, false, "abc\u{301}".as_bytes(), 3); } #[test] - fn test_mixed_invalid_utf8_with_valid() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc\xFF\xFEdef"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert!(String::from_utf8(s.to_vec()).is_err()); - assert_eq!(buf, b"abc\xFF\xFEdef "); + fn test_bytes_that_are_not_a_character_take_no_column() { + assert_half_line(b"caf\xE9!", 0, 4, false, b"caf\xE9!", 4); + assert_half_line(b"cafe\xE9", 0, 4, false, b"cafe", 4); } #[test] - fn test_max_width_zero() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"foo bar"; - process_half_line(s, 0, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, vec![b' ']); + fn test_control_characters_take_no_column() { + assert_half_line(b"a\0\x0C\x0B\x07b", 0, 2, false, b"a\0\x0C\x0B\x07b", 2); } #[test] - fn test_line_only_with_tabs() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"\t\t\t"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, vec![b'\t', b'\t', b' ', b' ', b' ']) + fn test_carriage_return_goes_back_to_the_indent() { + assert_half_line(b"ab\rcd", 0, 10, false, b"ab\rcd", 2); + assert_half_line(b"ab\rcd", 16, 10, false, b"ab\r\t\tcd", 2); + assert_half_line(b"ab\rcd", 3, 10, true, b"ab\r cd", 2); } #[test] - fn test_tabs_expanded() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"\t\t\t"; - process_half_line(s, 12, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b" ".repeat(13)); - } - - #[test] - fn test_mixed_tabs() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"a\tb\tc\t"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"a\tb\tc "); + fn test_carriage_return_starts_the_bound_over() { + assert_half_line(b"abcdef\rghi", 0, 4, false, b"abcd\rghi", 3); } #[test] - fn test_mixed_tabs_with_gutter() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"a\tb\tc\t"; - process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b"a\tb\tc\t "); - } - - #[test] - fn test_mixed_tabs_expanded() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"a\tb\tc\t"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"a b c "); + fn test_backspace() { + assert_half_line(b"ab\x08c", 0, 10, false, b"ab\x08c", 2); + assert_half_line(b"\x08a", 0, 10, false, b"a", 1); } #[test] - fn test_mixed_tabs_expanded_with_gutter() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"a\tb\tc\t"; - process_half_line(s, 10, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b"a b c "); - } - - #[test] - fn test_break_if_invalid_max_width() { - let config = create_test_config(true, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"a\tb\tc\t"; - process_half_line(s, 61, false, true, &config, &mut buf).unwrap(); - assert_eq!(buf, b""); - assert_eq!(buf.len(), 0); - } - - #[test] - fn test_new_line() { - let config = create_test_config(false, DEF_TAB_SIZE); - let mut buf = vec![]; - let s = b"abc"; - process_half_line(s, 10, false, false, &config, &mut buf).unwrap(); - assert_eq!(buf, b"abc\t\t "); + fn test_backspace_after_a_cut_tab_pads_with_spaces() { + assert_half_line(b"a\t\x08b", 0, 8, false, b"a b", 8); } }