similar/
udiff.rs

1//! This module provides unified diff functionality.
2//!
3//! It is available for as long as the `text` feature is enabled which
4//! is enabled by default:
5//!
6//! ```rust
7//! use similar::TextDiff;
8//! # let old_text = "";
9//! # let new_text = "";
10//! let text_diff = TextDiff::from_lines(old_text, new_text);
11//! print!("{}", text_diff
12//!     .unified_diff()
13//!     .context_radius(10)
14//!     .header("old_file", "new_file"));
15//! ```
16//!
17//! # Unicode vs Bytes
18//!
19//! The [`UnifiedDiff`] type supports both unicode and byte diffs for all
20//! types compatible with [`DiffableStr`].  You can pick between the two
21//! versions by using the [`Display`](std::fmt::Display) implementation or
22//! [`UnifiedDiff`] or [`UnifiedDiff::to_writer`].
23//!
24//! The former uses [`DiffableStr::to_string_lossy`], the latter uses
25//! [`DiffableStr::as_bytes`] for each line.
26#[cfg(feature = "text")]
27use std::{fmt, io};
28
29use crate::iter::AllChangesIter;
30use crate::text::{DiffableStr, TextDiff};
31use crate::types::{Algorithm, DiffOp};
32
33struct MissingNewlineHint(bool);
34
35impl fmt::Display for MissingNewlineHint {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        if self.0 {
38            write!(f, "\n\\ No newline at end of file")?;
39        }
40        Ok(())
41    }
42}
43
44#[derive(Copy, Clone, Debug)]
45struct UnifiedDiffHunkRange(usize, usize);
46
47impl UnifiedDiffHunkRange {
48    fn start(&self) -> usize {
49        self.0
50    }
51
52    fn end(&self) -> usize {
53        self.1
54    }
55}
56
57impl fmt::Display for UnifiedDiffHunkRange {
58    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59        let mut beginning = self.start() + 1;
60        let len = self.end().saturating_sub(self.start());
61        if len == 1 {
62            write!(f, "{}", beginning)
63        } else {
64            if len == 0 {
65                // empty ranges begin at line just before the range
66                beginning -= 1;
67            }
68            write!(f, "{},{}", beginning, len)
69        }
70    }
71}
72
73/// Unified diff hunk header formatter.
74pub struct UnifiedHunkHeader {
75    old_range: UnifiedDiffHunkRange,
76    new_range: UnifiedDiffHunkRange,
77}
78
79impl UnifiedHunkHeader {
80    /// Creates a hunk header from a (non empty) slice of diff ops.
81    pub fn new(ops: &[DiffOp]) -> UnifiedHunkHeader {
82        let first = ops[0];
83        let last = ops[ops.len() - 1];
84        let old_start = first.old_range().start;
85        let new_start = first.new_range().start;
86        let old_end = last.old_range().end;
87        let new_end = last.new_range().end;
88        UnifiedHunkHeader {
89            old_range: UnifiedDiffHunkRange(old_start, old_end),
90            new_range: UnifiedDiffHunkRange(new_start, new_end),
91        }
92    }
93}
94
95impl fmt::Display for UnifiedHunkHeader {
96    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
97        write!(f, "@@ -{} +{} @@", &self.old_range, &self.new_range)
98    }
99}
100
101/// Unified diff formatter.
102///
103/// ```rust
104/// use similar::TextDiff;
105/// # let old_text = "";
106/// # let new_text = "";
107/// let text_diff = TextDiff::from_lines(old_text, new_text);
108/// print!("{}", text_diff
109///     .unified_diff()
110///     .context_radius(10)
111///     .header("old_file", "new_file"));
112/// ```
113///
114/// ## Unicode vs Bytes
115///
116/// The [`UnifiedDiff`] type supports both unicode and byte diffs for all
117/// types compatible with [`DiffableStr`].  You can pick between the two
118/// versions by using [`UnifiedDiff.to_string`] or [`UnifiedDiff.to_writer`].
119/// The former uses [`DiffableStr::to_string_lossy`], the latter uses
120/// [`DiffableStr::as_bytes`] for each line.
121pub struct UnifiedDiff<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized> {
122    diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
123    context_radius: usize,
124    missing_newline_hint: bool,
125    header: Option<(String, String)>,
126}
127
128impl<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized> UnifiedDiff<'diff, 'old, 'new, 'bufs, T> {
129    /// Creates a formatter from a text diff object.
130    pub fn from_text_diff(diff: &'diff TextDiff<'old, 'new, 'bufs, T>) -> Self {
131        UnifiedDiff {
132            diff,
133            context_radius: 3,
134            missing_newline_hint: true,
135            header: None,
136        }
137    }
138
139    /// Changes the context radius.
140    ///
141    /// The context radius is the number of lines between changes that should
142    /// be emitted.  This defaults to `3`.
143    pub fn context_radius(&mut self, n: usize) -> &mut Self {
144        self.context_radius = n;
145        self
146    }
147
148    /// Sets a header to the diff.
149    ///
150    /// `a` and `b` are the file names that are added to the top of the unified
151    /// file format.  The names are accepted verbatim which lets you encode
152    /// a timestamp into it when separated by a tab (`\t`).  For more information,
153    /// see [the unified diff format specification](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/diff.html#tag_20_34_10_07).
154    pub fn header(&mut self, a: &str, b: &str) -> &mut Self {
155        self.header = Some((a.to_string(), b.to_string()));
156        self
157    }
158
159    /// Controls the missing newline hint.
160    ///
161    /// By default a special `\ No newline at end of file` marker is added to
162    /// the output when a file is not terminated with a final newline.  This can
163    /// be disabled with this flag.
164    pub fn missing_newline_hint(&mut self, yes: bool) -> &mut Self {
165        self.missing_newline_hint = yes;
166        self
167    }
168
169    /// Iterates over all hunks as configured.
170    pub fn iter_hunks(&self) -> impl Iterator<Item = UnifiedDiffHunk<'diff, 'old, 'new, 'bufs, T>> {
171        let diff = self.diff;
172        let missing_newline_hint = self.missing_newline_hint;
173        self.diff
174            .grouped_ops(self.context_radius)
175            .into_iter()
176            .filter(|ops| !ops.is_empty())
177            .map(move |ops| UnifiedDiffHunk::new(ops, diff, missing_newline_hint))
178    }
179
180    /// Write the unified diff as bytes to the output stream.
181    pub fn to_writer<W: io::Write>(&self, mut w: W) -> Result<(), io::Error>
182    where
183        'diff: 'old + 'new + 'bufs,
184    {
185        let mut header = self.header.as_ref();
186        for hunk in self.iter_hunks() {
187            if let Some((old_file, new_file)) = header.take() {
188                writeln!(w, "--- {}", old_file)?;
189                writeln!(w, "+++ {}", new_file)?;
190            }
191            write!(w, "{}", hunk)?;
192        }
193        Ok(())
194    }
195
196    fn header_opt(&mut self, header: Option<(&str, &str)>) -> &mut Self {
197        if let Some((a, b)) = header {
198            self.header(a, b);
199        }
200        self
201    }
202}
203
204/// Unified diff hunk formatter.
205///
206/// The `Display` this renders out a single unified diff's hunk.
207pub struct UnifiedDiffHunk<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized> {
208    diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
209    ops: Vec<DiffOp>,
210    missing_newline_hint: bool,
211}
212
213impl<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized>
214    UnifiedDiffHunk<'diff, 'old, 'new, 'bufs, T>
215{
216    /// Creates a new hunk for some operations.
217    pub fn new(
218        ops: Vec<DiffOp>,
219        diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
220        missing_newline_hint: bool,
221    ) -> UnifiedDiffHunk<'diff, 'old, 'new, 'bufs, T> {
222        UnifiedDiffHunk {
223            diff,
224            ops,
225            missing_newline_hint,
226        }
227    }
228
229    /// Returns the header for the hunk.
230    pub fn header(&self) -> UnifiedHunkHeader {
231        UnifiedHunkHeader::new(&self.ops)
232    }
233
234    /// Returns all operations in the hunk.
235    pub fn ops(&self) -> &[DiffOp] {
236        &self.ops
237    }
238
239    /// Returns the value of the `missing_newline_hint` flag.
240    pub fn missing_newline_hint(&self) -> bool {
241        self.missing_newline_hint
242    }
243
244    /// Iterates over all changes in a hunk.
245    pub fn iter_changes<'x, 'slf>(&'slf self) -> AllChangesIter<'slf, 'x, T>
246    where
247        'x: 'slf + 'old + 'new,
248        'old: 'x,
249        'new: 'x,
250    {
251        AllChangesIter::new(self.diff.old_slices(), self.diff.new_slices(), self.ops())
252    }
253
254    /// Write the hunk as bytes to the output stream.
255    pub fn to_writer<W: io::Write>(&self, mut w: W) -> Result<(), io::Error>
256    where
257        'diff: 'old + 'new + 'bufs,
258    {
259        for (idx, change) in self.iter_changes().enumerate() {
260            if idx == 0 {
261                writeln!(w, "{}", self.header())?;
262            }
263            write!(w, "{}", change.tag())?;
264            w.write_all(change.value().as_bytes())?;
265            if !self.diff.newline_terminated() {
266                writeln!(w)?;
267            }
268            if self.diff.newline_terminated() && change.missing_newline() {
269                writeln!(w, "{}", MissingNewlineHint(self.missing_newline_hint))?;
270            }
271        }
272        Ok(())
273    }
274}
275
276impl<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized> fmt::Display
277    for UnifiedDiffHunk<'diff, 'old, 'new, 'bufs, T>
278where
279    'diff: 'old + 'new + 'bufs,
280{
281    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
282        for (idx, change) in self.iter_changes().enumerate() {
283            if idx == 0 {
284                writeln!(f, "{}", self.header())?;
285            }
286            write!(f, "{}{}", change.tag(), change.to_string_lossy())?;
287            if !self.diff.newline_terminated() {
288                writeln!(f)?;
289            }
290            if self.diff.newline_terminated() && change.missing_newline() {
291                writeln!(f, "{}", MissingNewlineHint(self.missing_newline_hint))?;
292            }
293        }
294        Ok(())
295    }
296}
297
298impl<'diff, 'old, 'new, 'bufs, T: DiffableStr + ?Sized> fmt::Display
299    for UnifiedDiff<'diff, 'old, 'new, 'bufs, T>
300where
301    'diff: 'old + 'new + 'bufs,
302{
303    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
304        let mut header = self.header.as_ref();
305        for hunk in self.iter_hunks() {
306            if let Some((old_file, new_file)) = header.take() {
307                writeln!(f, "--- {}", old_file)?;
308                writeln!(f, "+++ {}", new_file)?;
309            }
310            write!(f, "{}", hunk)?;
311        }
312        Ok(())
313    }
314}
315
316/// Quick way to get a unified diff as string.
317///
318/// `n` configures [`UnifiedDiff::context_radius`] and
319/// `header` configures [`UnifiedDiff::header`] when not `None`.
320pub fn unified_diff(
321    alg: Algorithm,
322    old: &str,
323    new: &str,
324    n: usize,
325    header: Option<(&str, &str)>,
326) -> String {
327    TextDiff::configure()
328        .algorithm(alg)
329        .diff_lines(old, new)
330        .unified_diff()
331        .context_radius(n)
332        .header_opt(header)
333        .to_string()
334}
335
336#[test]
337fn test_unified_diff() {
338    let diff = TextDiff::from_lines(
339        "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\ns\nt\nu\nv\nw\nx\ny\nz\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ",
340        "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\nq\nr\nS\nt\nu\nv\nw\nx\ny\nz\nA\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\no\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ",
341    );
342    insta::assert_snapshot!(&diff.unified_diff().header("a.txt", "b.txt").to_string());
343}
344#[test]
345fn test_empty_unified_diff() {
346    let diff = TextDiff::from_lines("abc", "abc");
347    assert_eq!(diff.unified_diff().header("a.txt", "b.txt").to_string(), "");
348}
349
350#[test]
351fn test_unified_diff_newline_hint() {
352    let diff = TextDiff::from_lines("a\n", "b");
353    insta::assert_snapshot!(&diff.unified_diff().header("a.txt", "b.txt").to_string());
354    insta::assert_snapshot!(&diff
355        .unified_diff()
356        .missing_newline_hint(false)
357        .header("a.txt", "b.txt")
358        .to_string());
359}