diff --git a/src/text/inline.rs b/src/text/inline.rs index 83de49d..1a8d38d 100644 --- a/src/text/inline.rs +++ b/src/text/inline.rs @@ -1,4 +1,4 @@ -use std::iter; +use std::{fmt, iter}; use crate::algorithms::{Algorithm, DiffOp, DiffTag}; use crate::text::{Change, ChangeTag, TextDiff}; @@ -30,6 +30,9 @@ impl<'a, 's> MultiIndex<'a, 's> { } } +/// Represents the expanded textual change with inline highlights. +/// +/// This is like [`Change`] but with inline highlight info. #[derive(Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] pub struct InlineChange<'s> { tag: ChangeTag, @@ -74,11 +77,28 @@ impl<'s> From> for InlineChange<'s> { old_index: change.old_index(), new_index: change.old_index(), values: vec![(false, change.value())], - missing_newline: change.is_missing_newline(), + missing_newline: change.missing_newline(), } } } +impl<'s> fmt::Display for InlineChange<'s> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for &(emphasized, value) in &self.values { + let marker = match (emphasized, self.tag) { + (false, _) | (true, ChangeTag::Equal) => "", + (true, ChangeTag::Delete) => "-", + (true, ChangeTag::Insert) => "+", + }; + write!(f, "{}{}{}", marker, value, marker)?; + } + if self.missing_newline { + writeln!(f)?; + } + Ok(()) + } +} + pub(crate) fn iter_inline_changes<'diff>( diff: &'diff TextDiff, op: &DiffOp, diff --git a/src/text/mod.rs b/src/text/mod.rs index 34efff0..d373416 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -50,6 +50,24 @@ //! //! Because the [`TextDiff::grouped_ops`] method can isolate clusters of changes //! this even works for very long files if paired with this method. +//! +//! ## Trailing Newlines +//! +//! When working with line diffs (and unified diffs in general) there are two +//! "philosophies" to look at lines. One is to diff lines without their newline +//! character, the other is to diff with the newline character. Typically the +//! latter is done because text files do not _have_ to end in a newline character. +//! As a result there is a difference between `foo\n` and `foo` as far as diffs +//! are concerned. +//! +//! In similar this is handled on the [`Change`] or [`InlineChange`] level. If +//! a diff was created via [`TextDiff::from_lines`] the text diffing system is +//! instructed to check if there are missing newlines encountered. If that is +//! the case the [`Change`] object will return true from the +//! [`Change::missing_newline`] method so the caller knows to handle this by +//! either rendering a virtual newline at that position or to indicate it in +//! different ways. For instance the unified diff code will render the special +//! `\ No newline at end of file` marker. #![cfg(feature = "text")] use std::borrow::Cow; use std::cmp::Reverse; @@ -253,7 +271,7 @@ impl<'s> Change<'s> { /// /// The [`std::fmt::Display`] implementation of [`Change`] will automatically /// insert a newline after the value if this is true. - pub fn is_missing_newline(&self) -> bool { + pub fn missing_newline(&self) -> bool { self.missing_newline } } @@ -364,11 +382,6 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { /// ways in which a change could be encoded (insert/delete vs replace), look /// up the value from the appropriate slice and also handle correct index /// handling. - /// - /// In addition it has some custom handling to insert "virtual" newlines - /// for diffs where [`TextDiff::newline_terminated`] is `true` but the - /// diff does not end in newlines in the right places. For more information - /// see [`Change::is_virtual`]. pub fn iter_changes(&self, op: &DiffOp) -> impl Iterator { let newline_terminated = self.newline_terminated; let (tag, old_range, new_range) = op.as_tag_tuple(); diff --git a/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint-2.snap b/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint-2.snap new file mode 100644 index 0000000..574e77a --- /dev/null +++ b/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint-2.snap @@ -0,0 +1,10 @@ +--- +source: src/text/udiff.rs +expression: "&diff.unified_diff().missing_newline_hint(false).header(\"a.txt\",\n \"b.txt\").to_string()" +--- +--- a.txt ++++ b.txt +@@ -0 +0 @@ +-a ++b + diff --git a/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint.snap b/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint.snap new file mode 100644 index 0000000..0502549 --- /dev/null +++ b/src/text/snapshots/similar__text__udiff__unified_diff_newline_hint.snap @@ -0,0 +1,11 @@ +--- +source: src/text/udiff.rs +expression: "&diff.unified_diff().header(\"a.txt\", \"b.txt\").to_string()" +--- +--- a.txt ++++ b.txt +@@ -0 +0 @@ +-a ++b +\ No newline at end of file + diff --git a/src/text/udiff.rs b/src/text/udiff.rs index a457b4f..8c58a89 100644 --- a/src/text/udiff.rs +++ b/src/text/udiff.rs @@ -81,6 +81,7 @@ impl fmt::Display for UnifiedHunkHeader { pub struct UnifiedDiff<'diff, 'old, 'new, 'bufs> { diff: &'diff TextDiff<'old, 'new, 'bufs>, context_radius: usize, + missing_newline_hint: bool, header: Option<(String, String)>, } @@ -90,6 +91,7 @@ impl<'diff, 'old, 'new, 'bufs> UnifiedDiff<'diff, 'old, 'new, 'bufs> { UnifiedDiff { diff, context_radius: 3, + missing_newline_hint: true, header: None, } } @@ -114,14 +116,25 @@ impl<'diff, 'old, 'new, 'bufs> UnifiedDiff<'diff, 'old, 'new, 'bufs> { self } + /// Controls the missing newline hint. + /// + /// By default a special `\ No newline at end of file` marker is added to + /// the output when a file is not terminated with a final newline. This can + /// be disabled with this flag. + pub fn missing_newline_hint(&mut self, yes: bool) -> &mut Self { + self.missing_newline_hint = yes; + self + } + /// Iterates over all hunks as configured. pub fn iter_hunks(&self) -> impl Iterator> { let diff = self.diff; + let missing_newline_hint = self.missing_newline_hint; self.diff .grouped_ops(self.context_radius) .into_iter() .filter(|ops| !ops.is_empty()) - .map(move |ops| UnifiedDiffHunk::new(ops, diff)) + .map(move |ops| UnifiedDiffHunk::new(ops, diff, missing_newline_hint)) } fn header_opt(&mut self, header: Option<(&str, &str)>) -> &mut Self { @@ -138,6 +151,7 @@ impl<'diff, 'old, 'new, 'bufs> UnifiedDiff<'diff, 'old, 'new, 'bufs> { pub struct UnifiedDiffHunk<'diff, 'old, 'new, 'bufs> { diff: &'diff TextDiff<'old, 'new, 'bufs>, ops: Vec, + missing_newline_hint: bool, } impl<'diff, 'old, 'new, 'bufs> UnifiedDiffHunk<'diff, 'old, 'new, 'bufs> { @@ -145,8 +159,13 @@ impl<'diff, 'old, 'new, 'bufs> UnifiedDiffHunk<'diff, 'old, 'new, 'bufs> { pub fn new( ops: Vec, diff: &'diff TextDiff<'old, 'new, 'bufs>, + missing_newline_hint: bool, ) -> UnifiedDiffHunk<'diff, 'old, 'new, 'bufs> { - UnifiedDiffHunk { diff, ops } + UnifiedDiffHunk { + diff, + ops, + missing_newline_hint, + } } /// Returns the header for the hunk. @@ -159,6 +178,11 @@ impl<'diff, 'old, 'new, 'bufs> UnifiedDiffHunk<'diff, 'old, 'new, 'bufs> { &self.ops } + /// Returns the value of the `missing_newline_hint` flag. + pub fn missing_newline_hint(&self) -> bool { + self.missing_newline_hint + } + /// Iterates over all changes in a hunk. pub fn iter_changes(&self) -> impl Iterator> + '_ { // unclear why this needs Box::new here. It seems to infer some really @@ -192,9 +216,16 @@ impl<'diff, 'old, 'new, 'bufs> fmt::Display for UnifiedDiffHunk<'diff, 'old, 'ne ChangeTag::Delete => '-', ChangeTag::Insert => '+', }, - change, + change.value(), nl )?; + if change.missing_newline() { + if self.missing_newline_hint { + writeln!(f, "\n\\ No newline at end of file")?; + } else { + writeln!(f, "")?; + } + } } Ok(()) } @@ -247,3 +278,14 @@ fn test_empty_unified_diff() { let diff = TextDiff::from_lines("abc", "abc"); assert_eq!(diff.unified_diff().header("a.txt", "b.txt").to_string(), ""); } + +#[test] +fn test_unified_diff_newline_hint() { + let diff = TextDiff::from_lines("a\n", "b"); + insta::assert_snapshot!(&diff.unified_diff().header("a.txt", "b.txt").to_string()); + insta::assert_snapshot!(&diff + .unified_diff() + .missing_newline_hint(false) + .header("a.txt", "b.txt") + .to_string()); +}