diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f9091..fad332b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to similar are documented here. ## 0.5.0 * Add `DiffOp::apply_to_hook` to apply a captured op to a diff hook. +* Added virtual newline handling to `iter_changes`. ## 0.4.0 diff --git a/Cargo.toml b/Cargo.toml index d448886..293f687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ unicode = ["text", "unicode-segmentation"] [dev-dependencies] insta = "1.5.2" +console = "0.14.0" [dependencies] unicode-segmentation = { version = "1.7.1", optional = true } diff --git a/examples/terminal.rs b/examples/terminal.rs new file mode 100644 index 0000000..670f111 --- /dev/null +++ b/examples/terminal.rs @@ -0,0 +1,24 @@ +use console::Style; +use similar::text::{ChangeTag, TextDiff}; + +fn main() { + let diff = TextDiff::from_lines( + "Hello World\nThis is the second line.\nThis is the third.", + "Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more", + ); + + for op in diff.ops() { + for change in diff.iter_changes(op) { + let (sign, style) = match change.tag() { + ChangeTag::Delete => ("-", Style::new().red()), + ChangeTag::Insert => ("+", Style::new().green()), + ChangeTag::Equal => (" ", Style::new()), + }; + print!( + "{}{}", + style.apply_to(sign).bold(), + style.apply_to(change.value()) + ); + } + } +} diff --git a/src/snapshots/similar__text__virtual_newlines.snap b/src/snapshots/similar__text__virtual_newlines.snap new file mode 100644 index 0000000..59f6e60 --- /dev/null +++ b/src/snapshots/similar__text__virtual_newlines.snap @@ -0,0 +1,38 @@ +--- +source: src/text.rs +expression: "&changes" +--- +[ + Change { + tag: Equal, + old_index: Some( + 0, + ), + new_index: Some( + 0, + ), + value: "a\n", + }, + Change { + tag: Delete, + old_index: Some( + 1, + ), + new_index: None, + value: "b", + }, + Change { + tag: Equal, + old_index: None, + new_index: None, + value: "\n", + }, + Change { + tag: Insert, + old_index: None, + new_index: Some( + 1, + ), + value: "c\n", + }, +] diff --git a/src/text.rs b/src/text.rs index ba75b8a..c949c61 100644 --- a/src/text.rs +++ b/src/text.rs @@ -231,8 +231,30 @@ impl<'s> Change<'s> { pub fn value(&self) -> &'s str { self.value } + + /// Returns `true` for virtual changes. + /// + /// Virtual changes are changes that do not exist in either diff but are + /// necessary for a consistent user experience. This currently only + /// applies to changes related to newline handling. If lines are passed + /// to the [`TextDiff`] the [`TextDiff::newline_terminated`] flag is set + /// in which case newlines of the input are included in the changes. However + /// if the trailing newline is missing it would mess up processing greatly. + /// Because of this a trailing virtual newline is automatically added for a + /// more consistent user experience. This virtual newline can be detected + /// by explicitly checking for this flag. + pub fn is_virtual(&self) -> bool { + self.old_index.is_none() && self.new_index.is_none() + } } +const VIRTUAL_NEWLINE_CHANGE: Change<'static> = Change { + tag: ChangeTag::Equal, + old_index: None, + new_index: None, + value: "\n", +}; + impl ChangeTag { /// Returns the unified sign of this change. /// @@ -356,13 +378,34 @@ 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(); let mut old_index = old_range.start; let mut new_index = new_range.start; let mut old_slices = &self.old_slices()[op.old_range()]; let mut new_slices = &self.new_slices()[op.new_range()]; + // figure out if a virtual newline has to be inserted + let mut virtual_newline = if newline_terminated { + let last_element = match tag { + DiffTag::Equal | DiffTag::Delete | DiffTag::Replace => old_slices.last(), + DiffTag::Insert => new_slices.last(), + }; + if !last_element.map_or(false, |x| x.ends_with(&['\r', '\n'][..])) { + Some(VIRTUAL_NEWLINE_CHANGE) + } else { + None + } + } else { + None + }; + std::iter::from_fn(move || match tag { DiffTag::Equal => { if let Some((&first, rest)) = old_slices.split_first() { @@ -376,7 +419,7 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { value: first, }) } else { - None + virtual_newline.take() } } DiffTag::Delete => { @@ -390,7 +433,7 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { value: first, }) } else { - None + virtual_newline.take() } } DiffTag::Insert => { @@ -404,7 +447,7 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { value: first, }) } else { - None + virtual_newline.take() } } DiffTag::Replace => { @@ -417,9 +460,16 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { new_index: None, value: first, }) + } else if let Some(virtual_newline) = virtual_newline.take() { + Some(virtual_newline) } else if let Some((&first, rest)) = new_slices.split_first() { new_slices = rest; new_index += 1; + // check for another virtual newline + if newline_terminated && rest.is_empty() && !first.ends_with(&['\r', '\n'][..]) + { + virtual_newline = Some(VIRTUAL_NEWLINE_CHANGE); + } Some(Change { tag: ChangeTag::Insert, old_index: None, @@ -785,6 +835,18 @@ fn test_line_ops() { insta::assert_debug_snapshot!(&changes); } +#[test] +fn test_virtual_newlines() { + let diff = TextDiff::from_lines("a\nb", "a\nc\n"); + assert_eq!(diff.newline_terminated(), true); + let changes = diff + .ops() + .iter() + .flat_map(|op| diff.iter_changes(op)) + .collect::>(); + insta::assert_debug_snapshot!(&changes); +} + #[test] fn test_char_diff() { let diff = TextDiff::from_chars("Hello World", "Hallo Welt");