Repository: dandavison/delta
Pull request #831 (2021-12-06 23:17:44)

Fix more side-by-side line number miscounts
I found more cases where the line number count is off, now these must be increased _and_ decreased afterwards.

And regarding DeltaTest, do bikeshed to your hearts content!

dandavison: `DeltaTest` looks fantastic! I don't think I have any bikeshedding to do there, at least on a first look.

Have you thought much about how to test that ANSI sequences in the output are correct? In 114ae670223520657208501a3245a3b4261c1093 I added a subcommand that converts ANSI sequences into a human-readable form:

```
git log -p --color=always | delta --parse-ansi | less -RS
```

<table><tr><td><img width=600px src="https://user-images.githubusercontent.com/52205/144940283-c0b55f8d-96ed-4abe-b3b1-795faab451ac.png" alt="image" /></td></tr></table>

I was wondering whether it might work to write test assertions using the same ASCII scheme for representing the colors. (Obviously the parenthesized format is something I just made up on the spot and isn't parseable; open to suggestions.)
th1000s: > ANSI sequences in human-readable form

Nice feature and a a good idea, now `DeltaTest` also has `.explain_ansi()` to turn the ANSI-gibberish into something more readable. This also works with `.inspect()` so the result can just be pasted into `.expect()` when developing and testing. The format looks fine to me as it does not have to be machine readable.

And as planned, the bugfix slips by undetected!

Patch:
diff --git a/src/ansi/mod.rs b/src/ansi/mod.rs
index f45c13e..fe27bbe 100644
--- a/src/ansi/mod.rs
+++ b/src/ansi/mod.rs
@@ -155,6 +155,25 @@ fn strip_ansi_codes_from_strings_iterator<'a>(
         .join("")
 }
 
+pub fn explain_ansi(line: &str, colorful: bool) -> String {
+    use crate::style::Style;
+
+    parse_style_sections(line)
+        .into_iter()
+        .map(|(ansi_term_style, s)| {
+            let style = Style {
+                ansi_term_style,
+                ..Style::default()
+            };
+            if colorful {
+                format!("({}){}", style.to_painted_string(), style.paint(s))
+            } else {
+                format!("({}){}", style.to_string(), s)
+            }
+        })
+        .collect()
+}
+
 #[cfg(test)]
 mod tests {
     use crate::ansi::ansi_preserving_index;
diff --git a/src/features/line_numbers.rs b/src/features/line_numbers.rs
index 59218f7..35674f9 100644
--- a/src/features/line_numbers.rs
+++ b/src/features/line_numbers.rs
@@ -322,7 +322,7 @@ pub mod tests {
     use crate::ansi::strip_ansi_codes;
     use crate::features::side_by_side::ansifill::ODD_PAD_CHAR;
     use crate::format::FormatStringData;
-    use crate::tests::integration_test_utils::{make_config_from_args, run_delta};
+    use crate::tests::integration_test_utils::{make_config_from_args, run_delta, DeltaTest};
 
     use super::*;
 
@@ -630,7 +630,7 @@ pub mod tests {
 
     #[test]
     fn test_two_minus_lines() {
-        let config = make_config_from_args(&[
+        DeltaTest::with(&[
             "--line-numbers",
             "--line-numbers-left-format",
             "{nm:^4}⋮",
@@ -644,17 +644,19 @@ pub mod tests {
             "0 3",
             "--line-numbers-plus-style",
             "0 4",
-        ]);
-        let output = run_delta(TWO_MINUS_LINES_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let (line_1, line_2) = (lines.next().unwrap(), lines.next().unwrap());
-        assert_eq!(strip_ansi_codes(line_1), " 1  ⋮    │a = 1");
-        assert_eq!(strip_ansi_codes(line_2), " 2  ⋮    │b = 23456");
+        ])
+        .with_input(TWO_MINUS_LINES_DIFF)
+        .expect(
+            r#"
+             #indent_mark
+              1  ⋮    │a = 1
+              2  ⋮    │b = 23456"#,
+        );
     }
 
     #[test]
     fn test_two_plus_lines() {
-        let config = make_config_from_args(&[
+        DeltaTest::with(&[
             "--line-numbers",
             "--line-numbers-left-format",
             "{nm:^4}⋮",
@@ -668,12 +670,14 @@ pub mod tests {
             "0 3",
             "--line-numbers-plus-style",
             "0 4",
-        ]);
-        let output = run_delta(TWO_PLUS_LINES_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let (line_1, line_2) = (lines.next().unwrap(), lines.next().unwrap());
-        assert_eq!(strip_ansi_codes(line_1), "    ⋮ 1  │a = 1");
-        assert_eq!(strip_ansi_codes(line_2), "    ⋮ 2  │b = 234567");
+        ])
+        .with_input(TWO_PLUS_LINES_DIFF)
+        .expect(
+            r#"
+             #indent_mark
+                 ⋮ 1  │a = 1
+                 ⋮ 2  │b = 234567"#,
+        );
     }
 
     #[test]
@@ -775,21 +779,90 @@ pub mod tests {
 
     #[test]
     fn test_line_numbers_continue_correctly() {
-        let config = make_config_from_args(&[
+        DeltaTest::with(&["--side-by-side", "--width", "44", "--line-fill-method=ansi"])
+            .with_input(DIFF_PLUS_MINUS_WITH_1_CONTEXT_DIFF)
+            .expect(
+                r#"
+                │ 1  │abc             │ 1  │abc
+                │ 2  │a = left side   │ 2  │a = right side
+                │ 3  │xyz             │ 3  │xyz"#,
+            );
+    }
+
+    #[test]
+    fn test_line_numbers_continue_correctly_after_wrapping() {
+        DeltaTest::with(&[
             "--side-by-side",
             "--width",
-            "40",
-            "--line-fill-method=spaces",
-        ]);
-
-        let output = run_delta(HUNK_PLUS_MINUS_WITH_1_CONTEXT_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
+            "32",
+            "--line-fill-method=ansi",
+            "--wrap-left-symbol",
+            "@",
+            "--wrap-right-symbol",
+            "@",
+        ])
+        .with_input(DIFF_PLUS_MINUS_WITH_1_CONTEXT_DIFF)
+        .expect(
+            r#"
+            │ 1  │abc       │ 1  │abc
+            │ 2  │a = left @│ 2  │a = right@
+            │    │side      │    │ side
+            │ 3  │xyz       │ 3  │xyz"#,
+        );
 
-        // closure to help with `cargo fmt`-ing:
-        let mut next_line = || strip_ansi_codes(lines.next().unwrap());
-        assert_eq!("│ 1  │same          │ 1  │same", next_line());
-        assert_eq!("│ 2  │a = left      │ 2  │a = right     ", next_line());
-        assert_eq!("│ 3  │also same     │ 3  │also same", next_line());
+        let cfg = &[
+            "--side-by-side",
+            "--width",
+            "42",
+            "--line-fill-method=ansi",
+            "--wrap-left-symbol",
+            "@",
+            "--wrap-right-symbol",
+            "@",
+        ];
+
+        DeltaTest::with(cfg)
+            .with_input(DIFF_WITH_LONGER_MINUS_1_CONTEXT)
+            .expect(
+                r#"
+                │ 1  │abc            │ 1  │abc
+                │ 2  │a = one side   │ 2  │a = one longer@
+                │    │               │    │ side
+                │ 3  │xyz            │ 3  │xyz"#,
+            );
+
+        DeltaTest::with(cfg)
+            .with_input(DIFF_WITH_LONGER_PLUS_1_CONTEXT)
+            .expect(
+                r#"
+                │ 1  │abc            │ 1  │abc
+                │ 2  │a = one longer@│ 2  │a = one side
+                │    │ side          │    │
+                │ 3  │xyz            │ 3  │xyz"#,
+            );
+
+        DeltaTest::with(cfg)
+            .with_input(DIFF_MISMATCH_LONGER_MINUS_1_CONTEXT)
+            .expect(
+                r#"
+                │ 1  │abc            │ 1  │abc
+                │ 2  │a = left side @│    │
+                │    │which is longer│    │
+                │    │               │ 2  │a = other one
+                │ 3  │xyz            │ 3  │xyz"#,
+            );
+
+        DeltaTest::with(cfg)
+            .with_input(DIFF_MISMATCH_LONGER_PLUS_1_CONTEXT)
+            .expect(
+                r#"
+                │ 1  │abc            │ 1  │abc
+                │ 2  │a = other one  │    │
+                │    │               │ 2  │a = right side@
+                │    │               │    │ which is long@
+                │    │               │    │er
+                │ 3  │xyz            │ 3  │xyz"#,
+            );
     }
 
     pub const TWO_MINUS_LINES_DIFF: &str = "\
@@ -861,12 +934,48 @@ index 223ca50..367a6f6 100644
 +bb = 2
 ";
 
-    const HUNK_PLUS_MINUS_WITH_1_CONTEXT_DIFF: &str = "\
+    const DIFF_PLUS_MINUS_WITH_1_CONTEXT_DIFF: &str = "\
+--- a/a.py
++++ b/b.py
+@@ -1,3 +1,3 @@
+ abc
+-a = left side
++a = right side
+ xyz";
+
+    const DIFF_WITH_LONGER_MINUS_1_CONTEXT: &str = "\
+--- a/a.py
++++ b/b.py
+@@ -1,3 +1,3 @@
+ abc
+-a = one side
++a = one longer side
+ xyz";
+
+    const DIFF_WITH_LONGER_PLUS_1_CONTEXT: &str = "\
+--- a/a.py
++++ b/b.py
+@@ -1,3 +1,3 @@
+ abc
+-a = one longer side
++a = one side
+ xyz";
+
+    const DIFF_MISMATCH_LONGER_MINUS_1_CONTEXT: &str = "\
+--- a/a.py
++++ b/b.py
+@@ -1,3 +1,3 @@
+ abc
+-a = left side which is longer
++a = other one
+ xyz";
+
+    const DIFF_MISMATCH_LONGER_PLUS_1_CONTEXT: &str = "\
 --- a/a.py
 +++ b/b.py
 @@ -1,3 +1,3 @@
- same
--a = left
-+a = right
- also same";
+ abc
+-a = other one
++a = right side which is longer
+ xyz";
 }
diff --git a/src/features/side_by_side.rs b/src/features/side_by_side.rs
index 568142d..a9dfb6a 100644
--- a/src/features/side_by_side.rs
+++ b/src/features/side_by_side.rs
@@ -174,41 +174,52 @@ pub fn paint_minus_and_plus_lines_side_by_side(
     };
 
     for (minus_line_index, plus_line_index) in line_alignment {
+        let left_state = match minus_line_index {
+            Some(i) => &line_states[Left][i],
+            None => &State::HunkMinus(DiffType::Unified, None),
+        };
         output_buffer.push_str(&paint_left_panel_minus_line(
             minus_line_index,
             &syntax_sections[Left],
             &diff_sections[Left],
             &lines_have_homolog[Left],
-            match minus_line_index {
-                Some(i) => &line_states[Left][i],
-                None => &State::HunkMinus(DiffType::Unified, None),
-            },
+            left_state,
             &mut Some(line_numbers_data),
             bg_should_fill[Left],
             config,
         ));
 
-        // HACK: The left line number is not getting incremented in `linenumbers_and_styles()`
-        // when the alignment matches a minus with a plus line, so fix that here.
-        // This information should be passed down into `paint_line()` to set `increment` to true.
-        if minus_line_index.is_some() && plus_line_index.is_some() {
-            line_numbers_data.line_number[Left] += 1;
-        }
-
+        let right_state = match plus_line_index {
+            Some(i) => &line_states[Right][i],
+            None => &State::HunkPlus(DiffType::Unified, None),
+        };
         output_buffer.push_str(&paint_right_panel_plus_line(
             plus_line_index,
             &syntax_sections[Right],
             &diff_sections[Right],
             &lines_have_homolog[Right],
-            match plus_line_index {
-                Some(i) => &line_states[Right][i],
-                None => &State::HunkPlus(DiffType::Unified, None),
-            },
+            right_state,
             &mut Some(line_numbers_data),
             bg_should_fill[Right],
             config,
         ));
         output_buffer.push('\n');
+
+        // HACK: The left line number is not getting incremented in `linenumbers_and_styles()`
+        // when the alignment matches a minus with a plus line, so fix that here and take
+        // wrapped lines into account.
+        // Similarly an increment happens when it should not, so undo that.
+        // TODO: Pass this information down into `paint_line()` to set `increment` accordingly.
+        match (left_state, right_state, minus_line_index, plus_line_index) {
+            (State::HunkMinusWrapped, State::HunkPlus(_, _), Some(_), None) => {
+                line_numbers_data.line_number[Left] =
+                    line_numbers_data.line_number[Left].saturating_sub(1)
+            }
+            // Duplicating the logic from `linenumbers_and_styles()` a bit:
+            (State::HunkMinusWrapped | State::HunkPlusWrapped, _, _, _) => {}
+            (_, _, Some(_), Some(_)) => line_numbers_data.line_number[Left] += 1,
+            _ => {}
+        }
     }
 }
 
@@ -582,50 +593,79 @@ pub mod ansifill {
 pub mod tests {
     use crate::ansi::strip_ansi_codes;
     use crate::features::line_numbers::tests::*;
-    use crate::tests::integration_test_utils::{make_config_from_args, run_delta};
+    use crate::tests::integration_test_utils::{make_config_from_args, run_delta, DeltaTest};
 
     #[test]
     fn test_two_minus_lines() {
-        let config = make_config_from_args(&["--side-by-side", "--width", "40"]);
-        let output = run_delta(TWO_MINUS_LINES_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let (line_1, line_2) = (lines.next().unwrap(), lines.next().unwrap());
-        assert_eq!("│ 1  │a = 1         │    │", strip_ansi_codes(line_1));
-        assert_eq!("│ 2  │b = 23456     │    │", strip_ansi_codes(line_2));
+        DeltaTest::with(&["--side-by-side", "--width", "40"])
+            .with_input(TWO_MINUS_LINES_DIFF)
+            .expect(
+                r#"
+                │ 1  │a = 1         │    │
+                │ 2  │b = 23456     │    │"#,
+            );
     }
 
     #[test]
     fn test_two_minus_lines_truncated() {
-        let mut config = make_config_from_args(&[
+        DeltaTest::with(&[
             "--side-by-side",
             "--wrap-max-lines",
             "0",
             "--width",
             "28",
             "--line-fill-method=spaces",
-        ]);
-        config.truncation_symbol = ">".into();
-        let output = run_delta(TWO_MINUS_LINES_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let (line_1, line_2) = (lines.next().unwrap(), lines.next().unwrap());
-        assert_eq!("│ 1  │a = 1   │    │", strip_ansi_codes(line_1));
-        assert_eq!("│ 2  │b = 234>│    │", strip_ansi_codes(line_2));
+        ])
+        .set_cfg(|cfg| cfg.truncation_symbol = ">".into())
+        .with_input(TWO_MINUS_LINES_DIFF)
+        .expect(
+            r#"
+            │ 1  │a = 1   │    │
+            │ 2  │b = 234>│    │"#,
+        );
     }
 
     #[test]
     fn test_two_plus_lines() {
-        let config = make_config_from_args(&[
+        DeltaTest::with(&[
             "--side-by-side",
             "--width",
             "41",
             "--line-fill-method=spaces",
-        ]);
-        let output = run_delta(TWO_PLUS_LINES_DIFF, &config);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let (line_1, line_2) = (lines.next().unwrap(), lines.next().unwrap());
-        let sac = strip_ansi_codes; // alias to help with `cargo fmt`-ing:
-        assert_eq!("│    │              │ 1  │a = 1         ", sac(line_1));
-        assert_eq!("│    │              │ 2  │b = 234567    ", sac(line_2));
+        ])
+        .with_input(TWO_PLUS_LINES_DIFF)
+        .expect(
+            r#"
+            │    │              │ 1  │a = 1         
+            │    │              │ 2  │b = 234567    "#,
+        );
+    }
+
+    #[test]
+    fn test_two_plus_lines_spaces_and_ansi() {
+        DeltaTest::with(&[
+            "--side-by-side",
+            "--width",
+            "41",
+            "--line-fill-method=spaces",
+        ])
+        .with_input(TWO_PLUS_LINES_DIFF)
+        .explain_ansi()
+        .expect(r#"
+        (blue)│(88)    (blue)│(normal)              (blue)│(28) 1  (blue)│(231 22)a (203)=(231) (141)1(normal 22)         (normal)
+        (blue)│(88)    (blue)│(normal)              (blue)│(28) 2  (blue)│(231 22)b (203)=(231) (141)234567(normal 22)    (normal)"#);
+
+        DeltaTest::with(&[
+            "--side-by-side",
+            "--width",
+            "41",
+            "--line-fill-method=ansi",
+        ])
+        .with_input(TWO_PLUS_LINES_DIFF)
+        .explain_ansi()
+        .expect(r#"
+        (blue)│(88)    (blue)│(normal)              (blue) │(28) 1  (blue)│(231 22)a (203)=(231) (141)1(normal)
+        (blue)│(88)    (blue)│(normal)              (blue) │(28) 2  (blue)│(231 22)b (203)=(231) (141)234567(normal)"#);
     }
 
     #[test]
@@ -661,17 +701,17 @@ pub mod tests {
 
     #[test]
     fn test_one_minus_one_plus_line() {
-        let config = make_config_from_args(&[
+        DeltaTest::with(&[
             "--side-by-side",
             "--width",
             "40",
             "--line-fill-method=spaces",
-        ]);
-        let output = run_delta(ONE_MINUS_ONE_PLUS_LINE_DIFF, &config);
-        let output = strip_ansi_codes(&output);
-        let mut lines = output.lines().skip(crate::config::HEADER_LEN);
-        let mut lnu = move || lines.next().unwrap(); // for cargo fmt
-        assert_eq!("│ 1  │a = 1         │ 1  │a = 1", lnu());
-        assert_eq!("│ 2  │b = 2         │ 2  │bb = 2        ", lnu());
+        ])
+        .with_input(ONE_MINUS_ONE_PLUS_LINE_DIFF)
+        .expect(
+            r#"
+            │ 1  │a = 1         │ 1  │a = 1
+            │ 2  │b = 2         │ 2  │bb = 2        "#,
+        );
     }
 }
diff --git a/src/subcommands/parse_ansi.rs b/src/subcommands/parse_ansi.rs
index 51bebe5..eb62383 100644
--- a/src/subcommands/parse_ansi.rs
+++ b/src/subcommands/parse_ansi.rs
@@ -2,19 +2,16 @@ use std::io::{self, BufRead};
 
 #[cfg(not(tarpaulin_include))]
 pub fn parse_ansi() -> std::io::Result<()> {
-    use crate::{ansi, style::Style};
+    use crate::ansi;
 
     for line in io::stdin().lock().lines() {
-        for (ansi_term_style, s) in ansi::parse_style_sections(
-            &line.unwrap_or_else(|line| panic!("Invalid utf-8: {:?}", line)),
-        ) {
-            let style = Style {
-                ansi_term_style,
-                ..Style::default()
-            };
-            print!("({}){}", style.to_painted_string(), style.paint(s));
-        }
-        println!();
+        println!(
+            "{}",
+            ansi::explain_ansi(
+                &line.unwrap_or_else(|line| panic!("Invalid utf-8: {:?}", line)),
+                true
+            )
+        );
     }
     Ok(())
 }
diff --git a/src/utils/process.rs b/src/utils/process.rs
index 6579680..9dcee8d 100644
--- a/src/utils/process.rs
+++ b/src/utils/process.rs
@@ -30,7 +30,7 @@ pub fn calling_process() -> Option<Cow<'static, CallingProcess>> {
     }
     #[cfg(test)]
     {
-        determine_calling_process().map(|proc| Cow::Owned(proc))
+        determine_calling_process().map(Cow::Owned)
     }
 }
 
@@ -487,10 +487,10 @@ pub mod tests {
     pub struct FakeParentArgs {}
     impl FakeParentArgs {
         pub fn once(args: &str) -> Self {
-            Self::new(args, |v| TlsState::Once(v), "once")
+            Self::new(args, TlsState::Once, "once")
         }
         pub fn for_scope(args: &str) -> Self {
-            Self::new(args, |v| TlsState::Scope(v), "for_scope")
+            Self::new(args, TlsState::Scope, "for_scope")
         }
         fn new<F>(args: &str, initial: F, from_: &str) -> Self
         where
@@ -665,7 +665,7 @@ pub mod tests {
             MockProcInfo {
                 delta_pid: processes.last().map(|p| p.0).unwrap_or(1),
                 info: processes
-                    .into_iter()
+                    .iter()
                     .map(|(pid, start_time, cmd, ppid)| {
                         let cmd_vec = cmd.split(' ').map(str::to_owned).collect();
                         (*pid, FakeProc::new(*pid, *start_time, cmd_vec, *ppid))
@@ -700,21 +700,21 @@ pub mod tests {
     #[test]
     fn test_process_testing() {
         {
-            let _args = FakeParentArgs::once(&"git blame hello");
+            let _args = FakeParentArgs::once("git blame hello");
             assert_eq!(
                 calling_process_cmdline(ProcInfo::new(), guess_git_blame_filename_extension),
                 Some("hello".into())
             );
         }
         {
-            let _args = FakeParentArgs::once(&"git blame world.txt");
+            let _args = FakeParentArgs::once("git blame world.txt");
             assert_eq!(
                 calling_process_cmdline(ProcInfo::new(), guess_git_blame_filename_extension),
                 Some("txt".into())
             );
         }
         {
-            let _args = FakeParentArgs::for_scope(&"git blame hello world.txt");
+            let _args = FakeParentArgs::for_scope("git blame hello world.txt");
             assert_eq!(
                 calling_process_cmdline(ProcInfo::new(), guess_git_blame_filename_extension),
                 Some("txt".into())
@@ -730,7 +730,7 @@ pub mod tests {
     #[test]
     #[should_panic]
     fn test_process_testing_assert() {
-        let _args = FakeParentArgs::once(&"git blame do.not.panic");
+        let _args = FakeParentArgs::once("git blame do.not.panic");
         assert_eq!(
             calling_process_cmdline(ProcInfo::new(), guess_git_blame_filename_extension),
             Some("panic".into())
@@ -742,7 +742,7 @@ pub mod tests {
     #[test]
     #[should_panic]
     fn test_process_testing_assert_never_used() {
-        let _args = FakeParentArgs::once(&"never used");
+        let _args = FakeParentArgs::once("never used");
 
         // causes a panic while panicing, so can't test:
         // let _args = FakeParentArgs::for_scope(&"never used");
@@ -751,7 +751,7 @@ pub mod tests {
 
     #[test]
     fn test_process_testing_scope_can_remain_unused() {
-        let _args = FakeParentArgs::for_scope(&"never used");
+        let _args = FakeParentArgs::for_scope("never used");
     }
 
     #[test]
@@ -924,7 +924,7 @@ pub mod tests {
         ]);
         assert_eq!(
             calling_process_cmdline(parent, describe_calling_process),
-            Some(CallingProcess::GitGrep(empty_command_line.clone()))
+            Some(CallingProcess::GitGrep(empty_command_line))
         );
 
         for grep_command in &[
@@ -1003,7 +1003,7 @@ pub mod tests {
                 assert_eq!(cmd_line.short_options, set(&["-w"]));
                 assert_eq!(ext, Some(expected_extension.to_string()));
             } else {
-                assert!(false);
+                unreachable!();
             }
         }
     }
diff --git a/src/wrapping.rs b/src/wrapping.rs
index c66cdc8..e600ccc 100644
--- a/src/wrapping.rs
+++ b/src/wrapping.rs
@@ -551,11 +551,10 @@ mod tests {
 
     use super::wrap_line;
     use super::WrapConfig;
-    use crate::ansi::strip_ansi_codes;
     use crate::config::Config;
     use crate::paint::LineSections;
     use crate::style::Style;
-    use crate::tests::integration_test_utils::{make_config_from_args, run_delta};
+    use crate::tests::integration_test_utils::{make_config_from_args, DeltaTest};
 
     lazy_static! {
         static ref S1: Style = Style {
@@ -576,9 +575,9 @@ mod tests {
         static ref SD: Style = Style::default();
     }
 
-    const W: &str = &"+"; // wrap
-    const WR: &str = &"<"; // wrap-right
-    const RA: &str = &">"; // right-align
+    const W: &str = "+"; // wrap
+    const WR: &str = "<"; // wrap-right
+    const RA: &str = ">"; // right-align
 
     lazy_static! {
         static ref WRAP_DEFAULT_ARGS: Vec<&'static str> = vec![
@@ -607,7 +606,7 @@ mod tests {
     }
 
     fn mk_wrap_cfg(wrap_cfg: &WrapConfig) -> Config {
-        let mut cfg: Config = Config::from(make_config_from_args(&[]));
+        let mut cfg: Config = make_config_from_args(&[]);
         cfg.wrap_config = wrap_cfg.clone();
         cfg
     }
@@ -618,7 +617,7 @@ mod tests {
         <I as IntoIterator>::IntoIter: DoubleEndedIterator,
         S: Copy + Default + std::fmt::Debug,
     {
-        wrap_line(&cfg, line, line_width, &S::default(), &None)
+        wrap_line(cfg, line, line_width, &S::default(), &None)
     }
 
     #[test]
@@ -733,7 +732,7 @@ mod tests {
     }
 
     #[test]
-    fn test_wrap_line_newlines<'a>() {
+    fn test_wrap_line_newlines() {
         fn mk_input(len: usize) -> LineSections<'static, Style> {
             const IN: &str = "0123456789abcdefZ";
             let v = &[*S1, *S2];
@@ -786,10 +785,10 @@ mod tests {
             let line = mk_input_nl(9);
             let lines = wrap_test(&cfg, line, 3);
             let expected = mk_input_nl(9);
-            let line1 = mk_expected(&expected, 0, 2, Some((*SD, &W)));
-            let line2 = mk_expected(&expected, 2, 4, Some((*SD, &W)));
-            let line3 = mk_expected(&expected, 4, 6, Some((*SD, &W)));
-            let line4 = mk_expected(&expected, 6, 8, Some((*SD, &W)));
+            let line1 = mk_expected(&expected, 0, 2, Some((*SD, W)));
+            let line2 = mk_expected(&expected, 2, 4, Some((*SD, W)));
+            let line3 = mk_expected(&expected, 4, 6, Some((*SD, W)));
+            let line4 = mk_expected(&expected, 6, 8, Some((*SD, W)));
             let line5 = mk_expected(&expected, 8, 11, None);
             assert_eq!(lines, vec![line1, line2, line3, line4, line5]);
         }
@@ -798,10 +797,10 @@ mod tests {
             let line = mk_input_nl(10);
             let lines = wrap_test(&cfg, line, 3);
             let expected = mk_input_nl(10);
-            let line1 = mk_expected(&expected, 0, 2, Some((*SD, &W)));
-            let line2 = mk_expected(&expected, 2, 4, Some((*SD, &W)));
-            let line3 = mk_expected(&expected, 4, 6, Some((*SD, &W)));
-            let line4 = mk_expected(&expected, 6, 8, Some((*SD, &W)));
+            let line1 = mk_expected(&expected, 0, 2, Some((*SD, W)));
+            let line2 = mk_expected(&expected, 2, 4, Some((*SD, W)));
+            let line3 = mk_expected(&expected, 4, 6, Some((*SD, W)));
+            let line4 = mk_expected(&expected, 6, 8, Some((*SD, W)));
             let line5 = mk_expected(&expected, 8, 11, Some((*S2, "\n")));
             assert_eq!(lines, vec![line1, line2, line3, line4, line5]);
         }
@@ -845,8 +844,8 @@ mod tests {
         assert_eq!(
             lines,
             vec![
-                vec![(*S1, "abc"), (*SD, &W)],
-                vec![(*S2, "mnö̲"), (*SD, &W)],
+                vec![(*S1, "abc"), (*SD, W)],
+                vec![(*S2, "mnö̲"), (*SD, W)],
                 vec![(*S1, "xyz")]
             ]
         );
@@ -857,8 +856,8 @@ mod tests {
         assert_eq!(
             lines,
             vec![
-                vec![(*S1, "abc"), (*SD, &W)],
-                vec![(*S2, "deநி"), (*SD, &W)],
+                vec![(*S1, "abc"), (*SD, W)],
+                vec![(*S2, "deநி"), (*SD, W)],
                 vec![(*S1, "ghij")]
             ]
         );
@@ -904,7 +903,7 @@ index 223ca50..e69de29 100644
 
     #[test]
     fn test_wrap_with_unequal_hunk_zero_width() {
-        let mut config = make_config_from_args(&default_wrap_cfg_plus(&[
+        DeltaTest::with(&default_wrap_cfg_plus(&[
             "--side-by-side",
             "--line-numbers-left-format",
             "│L│",
@@ -914,26 +913,23 @@ index 223ca50..e69de29 100644
             "40",
             "--line-fill-method",
             "spaces",
-        ]));
-        config.truncation_symbol = ">".into();
-
-        let output = run_delta(HUNK_ZERO_DIFF, &config);
-        let output = strip_ansi_codes(&output);
-        let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-        let expected = vec![
-            "│L│abcdefghijklm+   │RRRR│abcdefghijklm+",
-            "│L│nopqrstuvwxzy+   │RRRR│nopqrstuvwxzy+",
-            "│L│ 0123456789 0+   │RRRR│ 0123456789 0+",
-            "│L│123456789 012+   │RRRR│123456789 012+",
-            "│L│3456789 01234567>│RRRR│3456789 01234>",
-            "│L│a = 1            │RRRR│a = 2         ",
-        ];
-        assert_eq!(lines, expected);
+        ]))
+        .set_cfg(|cfg| cfg.truncation_symbol = ">".into())
+        .with_input(HUNK_ZERO_DIFF)
+        .expect(
+            r#"
+            │L│abcdefghijklm+   │RRRR│abcdefghijklm+
+            │L│nopqrstuvwxzy+   │RRRR│nopqrstuvwxzy+
+            │L│ 0123456789 0+   │RRRR│ 0123456789 0+
+            │L│123456789 012+   │RRRR│123456789 012+
+            │L│3456789 01234567>│RRRR│3456789 01234>
+            │L│a = 1            │RRRR│a = 2         "#,
+        );
     }
 
     #[test]
     fn test_wrap_with_large_hunk_zero_line_numbers() {
-        let mut config = make_config_from_args(&default_wrap_cfg_plus(&[
+        DeltaTest::with(&default_wrap_cfg_plus(&[
             "--side-by-side",
             "--line-numbers-left-format",
             "│LLL│",
@@ -943,53 +939,49 @@ index 223ca50..e69de29 100644
             "60",
             "--line-fill-method",
             "ansi",
-        ]));
-        config.truncation_symbol = ">".into();
-
-        let output = run_delta(HUNK_ZERO_LARGE_LINENUMBERS_DIFF, &config);
-        let output = strip_ansi_codes(&output);
-        let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-        let expected = vec![
-            "│LLL│abcde+                   │WW   10   +- 101999 WW│abcde+",
-            "│LLL│fghij+                   │WW        +-        WW│fghij+",
-            "│LLL│klmno+                   │WW        +-        WW│klmno+",
-            "│LLL│pqrst+                   │WW        +-        WW│pqrst+",
-            "│LLL│uvwxzy 0123456789 012345>│WW        +-        WW│uvwxz>",
-            "│LLL│a = 1                    │WW        +- 102000 WW│a = 2",
-        ];
-        assert_eq!(lines, expected);
+        ]))
+        .set_cfg(|cfg| cfg.truncation_symbol = ">".into())
+        .with_input(HUNK_ZERO_LARGE_LINENUMBERS_DIFF)
+        .expect(
+            r#"
+            │LLL│abcde+                   │WW   10   +- 101999 WW│abcde+
+            │LLL│fghij+                   │WW        +-        WW│fghij+
+            │LLL│klmno+                   │WW        +-        WW│klmno+
+            │LLL│pqrst+                   │WW        +-        WW│pqrst+
+            │LLL│uvwxzy 0123456789 012345>│WW        +-        WW│uvwxz>
+            │LLL│a = 1                    │WW        +- 102000 WW│a = 2"#,
+        );
     }
 
     #[test]
     fn test_wrap_with_keep_markers() {
         use crate::features::side_by_side::ansifill::ODD_PAD_CHAR;
-        let mut config = make_config_from_args(&default_wrap_cfg_plus(&[
+        let t = DeltaTest::with(&default_wrap_cfg_plus(&[
             "--side-by-side",
             "--keep-plus-minus-markers",
             "--width",
             "45",
-        ]));
-        config.truncation_symbol = ">".into();
+        ]))
+        .set_cfg(|cfg| cfg.truncation_symbol = ">".into())
+        .with_input(HUNK_MP_DIFF);
+        let output = t.expect(
+            r#"
+            │ 4  │ abcdefghijklmn+ │ 15 │ abcdefghijklmn+
+            │    │ opqrstuvwxzy 0+ │    │ opqrstuvwxzy 0+
+            │    │ 123456789 0123+ │    │ 123456789 0123+
+            │    │ 456789 0123456+ │    │ 456789 0123456+
+            │    │ 789 0123456789> │    │ 789 0123456789>
+            │ 5  │-a = 0123456789+ │ 16 │+b = 0123456789+
+            │    │  0123456789 01+ │    │  0123456789 01+
+            │    │ 23456789 01234+ │    │ 23456789 01234+
+            │    │ 56789 01234567+ │    │ 56789 01234567+
+            │    │ 89              │    │ 89"#,
+            // this column here is^ where ODD_PAD_CHAR is inserted due to the odd 45 width
+        );
 
-        let output = run_delta(HUNK_MP_DIFF, &config);
-        let output = strip_ansi_codes(&output);
-        let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-        let expected = vec![
-            "│ 4  │ abcdefghijklmn+ │ 15 │ abcdefghijklmn+",
-            "│    │ opqrstuvwxzy 0+ │    │ opqrstuvwxzy 0+",
-            "│    │ 123456789 0123+ │    │ 123456789 0123+",
-            "│    │ 456789 0123456+ │    │ 456789 0123456+",
-            "│    │ 789 0123456789> │    │ 789 0123456789>",
-            "│ 5  │-a = 0123456789+ │ 16 │+b = 0123456789+",
-            "│    │  0123456789 01+ │    │  0123456789 01+",
-            "│    │ 23456789 01234+ │    │ 23456789 01234+",
-            "│    │ 56789 01234567+ │    │ 56789 01234567+",
-            "│    │ 89              │    │ 89",
-            // this is place where ^ ODD_PAD_CHAR is inserted due to the odd 45 width
-        ];
-        assert_eq!(lines, expected);
+        assert!(!output.is_empty());
 
-        for line in lines {
+        for line in output.lines().skip(crate::config::HEADER_LEN) {
             assert_eq!(line.chars().nth(22), Some(ODD_PAD_CHAR));
         }
     }
@@ -999,40 +991,36 @@ index 223ca50..e69de29 100644
         let config =
             make_config_from_args(&default_wrap_cfg_plus(&["--side-by-side", "--width", "55"]));
         {
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_SHORT, HUNK_ALIGN_DIFF_LONG
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2< │ 1  │.........1.........2+
+                │    │                >.... │    │.........3.........4+
+                │    │                      │    │.........5.........6"#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2< │ 1  │.........1.........2+",
-                "│    │                >.... │    │.........3.........4+",
-                "│    │                      │    │.........5.........6",
-                // place where ODD_PAD_CHAR ^ is inserted due to the odd 55 width
-            ];
-            assert_eq!(lines, expected);
+            // the place where ODD_PAD_CHAR^ is inserted due to the odd 55 width
         }
 
         {
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_LONG, HUNK_ALIGN_DIFF_SHORT
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2+ │ 1  │.........1.........2<
+                │    │.........3.........4+ │    │                >....
+                │    │.........5.........6  │    │"#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2+ │ 1  │.........1.........2<",
-                "│    │.........3.........4+ │    │                >....",
-                "│    │.........5.........6  │    │",
-            ];
-            assert_eq!(lines, expected);
         }
     }
 
@@ -1047,39 +1035,35 @@ index 223ca50..e69de29 100644
         ]));
 
         {
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_SHORT, HUNK_ALIGN_DIFF_LONG
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2....│ 1  │.........1.........2...+
+                │    │                        │    │......3.........4......+
+                │    │                        │    │...5.........6          "#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2....│ 1  │.........1.........2...+",
-                "│    │                        │    │......3.........4......+",
-                "│    │                        │    │...5.........6          ",
-            ];
-            assert_eq!(lines, expected);
         }
 
         {
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_LONG, HUNK_ALIGN_DIFF_SHORT
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2...+│ 1  │.........1.........2....
+                │    │......3.........4......+│    │
+                │    │...5.........6          │    │"#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2...+│ 1  │.........1.........2....",
-                "│    │......3.........4......+│    │",
-                "│    │...5.........6          │    │",
-            ];
-            assert_eq!(lines, expected);
         }
     }
 
@@ -1098,39 +1082,35 @@ index 223ca50..e69de29 100644
         config.truncation_symbol = ">".into();
 
         {
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_SHORT, HUNK_ALIGN_DIFF_LONG
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2....      │ 1  │.........1.........2.........+
+                │    │                              │    │3.........4.........5........+
+                │    │                              │    │.6                            "#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2....      │ 1  │.........1.........2.........+",
-                "│    │                              │    │3.........4.........5........+",
-                "│    │                              │    │.6                            ",
-            ];
-            assert_eq!(lines, expected);
         }
 
         {
             config.wrap_config.max_lines = 2;
-            let output = run_delta(
+            DeltaTest::with_config_and_input(
+                &config,
                 &format!(
                     "{}-{}+{}",
                     HUNK_ALIGN_DIFF_HEADER, HUNK_ALIGN_DIFF_SHORT, HUNK_ALIGN_DIFF_LONG
                 ),
-                &config,
+            )
+            .expect(
+                r#"
+                │ 1  │.........1.........2....      │ 1  │.........1.........2.........+
+                │    │                              │    │3.........4.........5........>"#,
             );
-            let output = strip_ansi_codes(&output);
-            let lines: Vec<_> = output.lines().skip(crate::config::HEADER_LEN).collect();
-            let expected = vec![
-                "│ 1  │.........1.........2....      │ 1  │.........1.........2.........+",
-                "│    │                              │    │3.........4.........5........>",
-            ];
-            assert_eq!(lines, expected);
         }
     }
 }


Test patch:
diff --git a/src/tests/integration_test_utils.rs b/src/tests/integration_test_utils.rs
index bdb2851..44dd3be 100644
--- a/src/tests/integration_test_utils.rs
+++ b/src/tests/integration_test_utils.rs
@@ -88,6 +88,111 @@ pub fn get_line_of_code_from_delta(
     line_of_code.to_string()
 }
 
+// Given an `expected` block as a raw string like: `r#"
+//     #indent_mark [optional]
+//     line1"#;`  // line 2 etc.
+// ignore the first newline and compare the following `lines()` to those produced
+// by `have`, `skip`-ping the first few. The leading spaces of the first line
+// are strippedfrom every following line (and verified) , unless the first line
+// marks the indentation level with `#indent_mark`.
+pub fn lines_match(expected: &str, have: &str, skip: Option<usize>) {
+    let mut exp = expected.lines().peekable();
+    assert!(exp.next() == Some(""), "first line must be empty");
+    let line1 = exp.peek().unwrap();
+    let indentation = line1.find(|c| c != ' ').unwrap_or(0);
+    let ignore_indent = &line1[indentation..] == "#indent_mark";
+    if ignore_indent {
+        let _indent_mark = exp.next();
+    }
+
+    let mut it = have.lines().skip(skip.unwrap_or(0));
+
+    for (i, expected) in exp.enumerate() {
+        if !ignore_indent {
+            let next_indentation = expected.find(|c| c != ' ').unwrap_or(0);
+            assert!(
+                indentation == next_indentation,
+                "The expected block has mixed indentation (use #indent_mark if that is on purpose)"
+            );
+        }
+        assert_eq!(
+            &expected[indentation..],
+            it.next().unwrap(),
+            "on line {} of input",
+            i + 1
+        );
+    }
+    assert_eq!(it.next(), None, "more input than expected");
+}
+
+pub struct DeltaTest {
+    config: config::Config,
+}
+
+impl DeltaTest {
+    pub fn with(args: &[&str]) -> Self {
+        Self {
+            config: make_config_from_args(args),
+        }
+    }
+
+    pub fn set_cfg<F>(mut self, f: F) -> Self
+    where
+        F: Fn(&mut config::Config),
+    {
+        f(&mut self.config);
+        self
+    }
+
+    pub fn with_config_and_input(config: &config::Config, input: &str) -> DeltaTestOutput {
+        DeltaTestOutput {
+            output: run_delta(input, &config),
+            explain_ansi_: false,
+        }
+    }
+
+    pub fn with_input(&self, input: &str) -> DeltaTestOutput {
+        DeltaTest::with_config_and_input(&self.config, input)
+    }
+}
+
+pub struct DeltaTestOutput {
+    output: String,
+    explain_ansi_: bool,
+}
+
+impl DeltaTestOutput {
+    pub fn inspect(self) -> Self {
+        if self.explain_ansi_ {
+            eprintln!("{}", ansi::explain_ansi(&self.output, true));
+        } else {
+            eprintln!("{}", &self.output);
+        }
+        self
+    }
+
+    pub fn explain_ansi(mut self) -> Self {
+        self.explain_ansi_ = true;
+        self
+    }
+
+    pub fn expect_skip(self, skip: usize, expected: &str) -> String {
+        let processed = if self.explain_ansi_ {
+            ansi::explain_ansi(&self.output, false)
+        } else {
+            ansi::strip_ansi_codes(&self.output)
+        };
+
+        lines_match(expected, &processed, Some(skip));
+
+        processed
+    }
+
+    pub fn expect(self, expected: &str) -> String {
+        self.expect_skip(crate::config::HEADER_LEN, expected)
+    }
+}
+
 pub fn run_delta(input: &str, config: &config::Config) -> String {
     let mut writer: Vec<u8> = Vec::new();
 
@@ -99,3 +204,117 @@ pub fn run_delta(input: &str, config: &config::Config) -> String {
     .unwrap();
     String::from_utf8(writer).unwrap()
 }
+
+pub mod tests {
+    use super::*;
+
+    #[test]
+    fn test_lines_match_ok() {
+        let expected = r#"
+        one
+        two
+        three"#;
+        lines_match(expected, "one\ntwo\nthree", None);
+
+        let expected = r#"
+        #indent_mark
+        one
+          2
+        three"#;
+        lines_match(expected, "one\n  2\nthree", None);
+
+        let expected = r#"
+            #indent_mark
+             1 
+              2  
+               3"#;
+        lines_match(expected, " 1 \n  2  \n   3", None);
+
+        let expected = r#"
+        #indent_mark
+         1 
+ignored!  2  
+           3"#;
+        lines_match(expected, " 1 \n  2  \n   3", None);
+        let expected = "\none\ntwo\nthree";
+        lines_match(expected, "one\ntwo\nthree", None);
+    }
+
+    #[test]
+    #[should_panic]
+    fn test_lines_match_no_nl() {
+        let expected = r#"bad
+        lines"#;
+        lines_match(expected, "bad\nlines", None);
+    }
+
+    #[test]
+    #[should_panic]
+    fn test_lines_match_iter_not_consumed() {
+        let expected = r#"
+        one
+        two
+        three"#;
+        lines_match(expected, "one\ntwo\nthree\nFOUR", None);
+    }
+
+    #[test]
+    #[should_panic]
+    fn test_lines_match_no_indent_mark_1() {
+        let expected = r#"
+        ok
+          wrong_indent
+        "#;
+        lines_match(expected, "ok", None);
+    }
+
+    #[test]
+    #[should_panic]
+    fn test_lines_match_no_indent_mark_2() {
+        let expected = r#"
+        ok
+       wrong_indent
+        "#;
+        lines_match(expected, "ok", None);
+    }
+
+    #[test]
+    fn test_delta_test() {
+        let input = "@@ -1,1 +1,1 @@ fn foo() {\n-1\n+2\n";
+        DeltaTest::with(&["--raw"])
+            .set_cfg(|c| c.pager = None)
+            .set_cfg(|c| c.line_numbers = true)
+            .with_input(input)
+            .inspect()
+            .expect_skip(
+                0,
+                r#"
+                 #indent_mark
+                 @@ -1,1 +1,1 @@ fn foo() {
+                  1  ⋮    │-1
+                     ⋮ 1  │+2"#,
+            );
+
+        DeltaTest::with(&[])
+            .with_input(input)
+            .inspect()
+            .expect_skip(
+                4,
+                r#"
+                1
+                2"#,
+            );
+
+        DeltaTest::with(&["--raw"])
+            .with_input(input)
+            .explain_ansi()
+            .inspect()
+            .expect_skip(
+                0,
+                "\n\
+                (normal)@@ -1,1 +1,1 @@ fn foo() {\n\
+                (red)-1(normal)\n\
+                (green)+2(normal)",
+            );
+    }
+}