From b601164a60d024c7dab1977b6dd761f6753480b4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 31 Jan 2021 18:22:37 +0100 Subject: [PATCH] Added initial support for inline diff highlighting --- Cargo.toml | 4 + examples/terminal-inline.rs | 30 +++++++ examples/terminal.rs | 6 +- src/text/inline.rs | 165 ++++++++++++++++++++++++++++++++++++ src/text/mod.rs | 88 +++++++++---------- src/text/udiff.rs | 2 +- 6 files changed, 245 insertions(+), 50 deletions(-) create mode 100644 examples/terminal-inline.rs create mode 100644 src/text/inline.rs diff --git a/Cargo.toml b/Cargo.toml index 33a11cd..d665014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,10 @@ unicode-segmentation = { version = "1.7.1", optional = true } name = "terminal" required-features = ["text"] +[[example]] +name = "terminal-inline" +required-features = ["text"] + [[example]] name = "udiff" required-features = ["text"] diff --git a/examples/terminal-inline.rs b/examples/terminal-inline.rs new file mode 100644 index 0000000..99f4cc4 --- /dev/null +++ b/examples/terminal-inline.rs @@ -0,0 +1,30 @@ +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_inline_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(),); + for &(emphasized, value) in change.values() { + if emphasized { + print!("{}", style.apply_to(value).underlined()); + } else { + print!("{}", style.apply_to(value)); + } + } + if change.is_missing_newline() { + println!(); + } + } + } +} diff --git a/examples/terminal.rs b/examples/terminal.rs index 670f111..a0a4761 100644 --- a/examples/terminal.rs +++ b/examples/terminal.rs @@ -14,11 +14,7 @@ fn main() { ChangeTag::Insert => ("+", Style::new().green()), ChangeTag::Equal => (" ", Style::new()), }; - print!( - "{}{}", - style.apply_to(sign).bold(), - style.apply_to(change.value()) - ); + print!("{}{}", style.apply_to(sign).bold(), style.apply_to(change),); } } } diff --git a/src/text/inline.rs b/src/text/inline.rs new file mode 100644 index 0000000..d5cbf68 --- /dev/null +++ b/src/text/inline.rs @@ -0,0 +1,165 @@ +use std::iter; + +use crate::algorithms::{DiffOp, DiffTag}; +use crate::text::{Change, ChangeTag, TextDiff}; + +use super::split_chars; + +use std::ops::Range; + +struct MultiIndex<'a, 's> { + seq: &'a [&'s str], + value: &'s str, +} + +impl<'a, 's> MultiIndex<'a, 's> { + pub fn new(seq: &'a [&'s str], value: &'s str) -> MultiIndex<'a, 's> { + MultiIndex { seq, value } + } + + pub fn get_slice(&self, rng: Range) -> &'s str { + let mut start = 0; + for &sseq in &self.seq[..rng.start] { + start += sseq.len(); + } + let mut end = start; + for &sseq in &self.seq[rng.start..rng.end] { + end += sseq.len(); + } + &self.value[start..end] + } +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Ord, PartialOrd)] +pub struct InlineChange<'s> { + tag: ChangeTag, + old_index: Option, + new_index: Option, + values: Vec<(bool, &'s str)>, + missing_newline: bool, +} + +impl<'s> InlineChange<'s> { + /// Returns the change tag. + pub fn tag(&self) -> ChangeTag { + self.tag + } + + /// Returns the old index if available. + pub fn old_index(&self) -> Option { + self.old_index + } + + /// Returns the new index if available. + pub fn new_index(&self) -> Option { + self.new_index + } + + /// Returns the changed values. + pub fn values(&self) -> &[(bool, &'s str)] { + &self.values + } + + /// Returns `true` if this change needs to be followed up by a + /// missing newline. + pub fn is_missing_newline(&self) -> bool { + self.missing_newline + } +} + +impl<'s> From> for InlineChange<'s> { + fn from(change: Change<'s>) -> InlineChange<'s> { + InlineChange { + tag: change.tag(), + old_index: change.old_index(), + new_index: change.old_index(), + values: vec![(false, change.value())], + missing_newline: change.is_missing_newline(), + } + } +} + +pub(crate) fn iter_inline_changes<'diff>( + diff: &'diff TextDiff, + op: &DiffOp, +) -> impl Iterator> { + let mut change_iter = diff.iter_changes(op).peekable(); + let mut skip_next = false; + let newline_terminated = diff.newline_terminated; + + iter::from_fn(move || { + if skip_next { + change_iter.next(); + skip_next = false; + } + if let Some(change) = change_iter.next() { + let next_change = change_iter.peek(); + match (change.tag, next_change.map(|x| x.tag())) { + (ChangeTag::Delete, Some(ChangeTag::Insert)) => { + let old_value = change.value(); + let new_value = next_change.unwrap().value(); + let old_chars = split_chars(&old_value).collect::>(); + let new_chars = split_chars(&new_value).collect::>(); + let old_mindex = MultiIndex::new(&old_chars, old_value); + let new_mindex = MultiIndex::new(&new_chars, new_value); + let inline_diff = TextDiff::from_slices(&old_chars, &new_chars); + + if inline_diff.ratio() < 0.5 { + return Some(None.into_iter().chain(Some(change.into()).into_iter())); + } + + // skip the next element as we handle it here + skip_next = true; + + let mut old_values = vec![]; + let mut new_values = vec![]; + for op in inline_diff.ops() { + match op.tag() { + DiffTag::Equal => { + old_values.push((false, old_mindex.get_slice(op.old_range()))); + new_values.push((false, old_mindex.get_slice(op.old_range()))); + } + DiffTag::Delete => { + old_values.push((true, old_mindex.get_slice(op.old_range()))); + } + DiffTag::Insert => { + new_values.push((true, new_mindex.get_slice(op.new_range()))); + } + DiffTag::Replace => { + old_values.push((true, old_mindex.get_slice(op.old_range()))); + new_values.push((true, new_mindex.get_slice(op.new_range()))); + } + } + } + + Some( + Some(InlineChange { + tag: ChangeTag::Delete, + old_index: change.old_index(), + new_index: change.new_index(), + values: old_values, + missing_newline: newline_terminated + && !old_value.ends_with(&['\r', '\n'][..]), + }) + .into_iter() + .chain( + Some(InlineChange { + tag: ChangeTag::Insert, + old_index: change.old_index(), + new_index: change.new_index(), + values: new_values, + missing_newline: newline_terminated + && !new_value.ends_with(&['\r', '\n'][..]), + }) + .into_iter(), + ), + ) + } + _ => Some(None.into_iter().chain(Some(change.into()).into_iter())), + } + } else { + None + } + }) + .flatten() +} diff --git a/src/text/mod.rs b/src/text/mod.rs index c300c55..34efff0 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -54,8 +54,12 @@ use std::borrow::Cow; use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap}; +use std::fmt; +mod inline; mod udiff; + +pub use self::inline::*; pub use self::udiff::*; use crate::algorithms::{capture_diff_slices, group_diff_ops, Algorithm, DiffOp, DiffTag}; @@ -209,6 +213,18 @@ pub struct Change<'s> { old_index: Option, new_index: Option, value: &'s str, + missing_newline: bool, +} + +impl<'s> fmt::Display for Change<'s> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}{}", + self.value(), + if self.missing_newline { "\n" } else { "" } + ) + } } impl<'s> Change<'s> { @@ -232,29 +248,16 @@ impl<'s> Change<'s> { self.value } - /// Returns `true` for virtual changes. + /// Returns `true` if this change needs to be followed up by a + /// missing newline. /// - /// 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() + /// 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 { + self.missing_newline } } -const VIRTUAL_NEWLINE_CHANGE: Change<'static> = Change { - tag: ChangeTag::Equal, - old_index: None, - new_index: None, - value: "\n", -}; - impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { /// Configures a text differ before diffing. pub fn configure() -> TextDiffConfig { @@ -374,21 +377,6 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { 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() { @@ -400,9 +388,12 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { old_index: Some(old_index - 1), new_index: Some(new_index - 1), value: first, + missing_newline: newline_terminated + && rest.is_empty() + && !first.ends_with(&['\r', '\n'][..]), }) } else { - virtual_newline.take() + None } } DiffTag::Delete => { @@ -414,9 +405,12 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { old_index: Some(old_index - 1), new_index: None, value: first, + missing_newline: newline_terminated + && rest.is_empty() + && !first.ends_with(&['\r', '\n'][..]), }) } else { - virtual_newline.take() + None } } DiffTag::Insert => { @@ -428,9 +422,12 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { old_index: None, new_index: Some(new_index - 1), value: first, + missing_newline: newline_terminated + && rest.is_empty() + && !first.ends_with(&['\r', '\n'][..]), }) } else { - virtual_newline.take() + None } } DiffTag::Replace => { @@ -442,22 +439,21 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { old_index: Some(old_index - 1), new_index: None, value: first, + missing_newline: newline_terminated + && rest.is_empty() + && !first.ends_with(&['\r', '\n'][..]), }) - } 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, new_index: Some(new_index - 1), value: first, + missing_newline: newline_terminated + && rest.is_empty() + && !first.ends_with(&['\r', '\n'][..]), }) } else { None @@ -466,6 +462,10 @@ impl<'old, 'new, 'bufs> TextDiff<'old, 'new, 'bufs> { }) } + pub fn iter_inline_changes(&self, op: &DiffOp) -> impl Iterator { + iter_inline_changes(self, op) + } + /// Returns the captured diff ops. pub fn ops(&self) -> &[DiffOp] { &self.ops diff --git a/src/text/udiff.rs b/src/text/udiff.rs index 9112391..a457b4f 100644 --- a/src/text/udiff.rs +++ b/src/text/udiff.rs @@ -192,7 +192,7 @@ impl<'diff, 'old, 'new, 'bufs> fmt::Display for UnifiedDiffHunk<'diff, 'old, 'ne ChangeTag::Delete => '-', ChangeTag::Insert => '+', }, - change.value(), + change, nl )?; }