Skip to main content

rich_ext/diff/
git.rs

1//! `git diff` output: a parser and a review-style renderer.
2//!
3//! [`parse_unified`] reads `git diff` (and plain `diff -u`) output: `diff
4//! --git` headers, `index` lines, new, deleted, renamed and copied files,
5//! mode changes, binary files, `---`/`+++`, hunks and `\ No newline at end of
6//! file`. [`PatchView`] renders a [`Patch`] with a file tree summary,
7//! syntax-highlighted hunks, inline [`Annotation`]s and links from a
8//! [`LinkProvider`].
9//!
10//! Paths are decoded from git's C-style quoting (`"b/\033[2J"`), so a
11//! [`FilePatch`] holds them exactly as named, control characters included.
12//! [`PatchView`] shows paths, modes and hunk sections with controls made
13//! visible (`␛[2J`) and percent-encodes controls in link targets; line
14//! *content* is shown as given, so sanitize untrusted patch text before
15//! parsing if its content must be inert too.
16
17use std::collections::BTreeMap;
18use std::fmt;
19
20use rich::cells::cell_len;
21use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Text};
22
23use super::render::{self, Block, Kind, Options, Row};
24use super::source::{highlight_lines, language_for_path};
25use super::{Layout, style};
26use crate::diagnostic::Level;
27use crate::hyperlink::Hyperlinker;
28
29/// What happened to a file.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub enum FileStatus {
32    Added,
33    Deleted,
34    Modified,
35    Renamed,
36    Copied,
37    /// A modified binary file (an added, deleted or renamed binary keeps that
38    /// status and sets [`FilePatch::binary`]).
39    Binary,
40}
41
42impl FileStatus {
43    fn label(self) -> &'static str {
44        match self {
45            FileStatus::Added => "added",
46            FileStatus::Deleted => "deleted",
47            FileStatus::Modified => "modified",
48            FileStatus::Renamed => "renamed",
49            FileStatus::Copied => "copied",
50            FileStatus::Binary => "binary",
51        }
52    }
53}
54
55/// A line of a hunk.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub enum LineKind {
58    Context,
59    Added,
60    Removed,
61}
62
63/// One line of a hunk, with its line numbers on each side.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct PatchLine {
66    pub kind: LineKind,
67    /// The text, without the leading marker or newline.
68    pub text: String,
69    pub old_line: Option<usize>,
70    pub new_line: Option<usize>,
71    /// Followed by `\ No newline at end of file`.
72    pub no_newline: bool,
73}
74
75/// A hunk: `@@ -old_start,old_len +new_start,new_len @@ section`.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct PatchHunk {
78    pub old_start: usize,
79    pub old_len: usize,
80    pub new_start: usize,
81    pub new_len: usize,
82    /// The text after the closing `@@` (often a function name).
83    pub section: String,
84    pub lines: Vec<PatchLine>,
85}
86
87impl PatchHunk {
88    /// The `@@ … @@` header, with the section text.
89    ///
90    /// The numbers are written as the file gave them (`-5,0`, `-5`, `-5,3`),
91    /// with no arithmetic, so a hand-built hunk with absurd numbers still
92    /// formats. The section is raw input: sanitize it before printing it
93    /// yourself ([`PatchView`] does).
94    pub fn header(&self) -> String {
95        let part = |start: usize, len: usize| match len {
96            1 => start.to_string(),
97            n => format!("{start},{n}"),
98        };
99        let mut header = format!(
100            "@@ -{} +{} @@",
101            part(self.old_start, self.old_len),
102            part(self.new_start, self.new_len)
103        );
104        if !self.section.is_empty() {
105            header.push(' ');
106            header.push_str(&self.section);
107        }
108        header
109    }
110}
111
112/// One file's changes.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct FilePatch {
115    /// The old path, `None` for an added file.
116    pub old_path: Option<String>,
117    /// The new path, `None` for a deleted file.
118    pub new_path: Option<String>,
119    pub status: FileStatus,
120    pub binary: bool,
121    pub old_mode: Option<String>,
122    pub new_mode: Option<String>,
123    /// `similarity index` for renames and copies, in percent.
124    pub similarity: Option<u8>,
125    pub hunks: Vec<PatchHunk>,
126    pub additions: usize,
127    pub deletions: usize,
128}
129
130impl FilePatch {
131    fn empty() -> Self {
132        FilePatch {
133            old_path: None,
134            new_path: None,
135            status: FileStatus::Modified,
136            binary: false,
137            old_mode: None,
138            new_mode: None,
139            similarity: None,
140            hunks: Vec::new(),
141            additions: 0,
142            deletions: 0,
143        }
144    }
145    /// The path to show: the new path, else the old one.
146    pub fn path(&self) -> &str {
147        self.new_path
148            .as_deref()
149            .or(self.old_path.as_deref())
150            .unwrap_or("")
151    }
152    /// Whether the mode changed.
153    pub fn mode_changed(&self) -> bool {
154        self.old_mode.is_some() && self.new_mode.is_some() && self.old_mode != self.new_mode
155    }
156}
157
158/// A parsed patch.
159#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Patch {
161    pub files: Vec<FilePatch>,
162}
163
164impl Patch {
165    /// Total `(additions, deletions)`.
166    pub fn stats(&self) -> (usize, usize) {
167        self.files
168            .iter()
169            .fold((0, 0), |(a, d), f| (a + f.additions, d + f.deletions))
170    }
171}
172
173/// Why a patch did not parse.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ParseError {
176    /// The 1-based input line.
177    pub line: usize,
178    pub message: String,
179}
180
181impl fmt::Display for ParseError {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(f, "line {}: {}", self.line, self.message)
184    }
185}
186impl std::error::Error for ParseError {}
187
188/// Undo git's C-style quoting of a path (`"a\tb"`).
189fn unquote(path: &str) -> String {
190    let Some(inner) = path.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else {
191        return path.to_string();
192    };
193    let mut bytes = Vec::new();
194    let mut chars = inner.bytes().peekable();
195    while let Some(b) = chars.next() {
196        if b != b'\\' {
197            bytes.push(b);
198            continue;
199        }
200        match chars.next() {
201            Some(b'n') => bytes.push(b'\n'),
202            Some(b't') => bytes.push(b'\t'),
203            Some(b'r') => bytes.push(b'\r'),
204            Some(b'a') => bytes.push(7),
205            Some(b'b') => bytes.push(8),
206            Some(b'f') => bytes.push(12),
207            Some(b'v') => bytes.push(11),
208            Some(d @ b'0'..=b'7') => {
209                let mut value = u32::from(d - b'0');
210                for _ in 0..2 {
211                    match chars.peek() {
212                        Some(&o @ b'0'..=b'7') => {
213                            value = value * 8 + u32::from(o - b'0');
214                            chars.next();
215                        }
216                        _ => break,
217                    }
218                }
219                bytes.push(value as u8);
220            }
221            Some(other) => bytes.push(other),
222            None => bytes.push(b'\\'),
223        }
224    }
225    String::from_utf8_lossy(&bytes).into_owned()
226}
227
228/// A `---`/`+++` path: `/dev/null` is none, `a/`/`b/` prefixes and a
229/// trailing tab-separated timestamp are dropped.
230fn header_path(raw: &str, prefix: &str) -> Option<String> {
231    let raw = raw.split('\t').next().unwrap_or(raw).trim_end();
232    let path = unquote(raw);
233    if path == "/dev/null" {
234        return None;
235    }
236    Some(
237        path.strip_prefix(prefix)
238            .map(str::to_string)
239            .unwrap_or(path),
240    )
241}
242
243/// The two paths of `diff --git a/x b/y`.
244fn git_header_paths(rest: &str) -> (Option<String>, Option<String>) {
245    if rest.starts_with('"') {
246        // Quoted: split after the closing quote of the first path.
247        let mut escaped = false;
248        for (i, c) in rest.char_indices().skip(1) {
249            match c {
250                '\\' if !escaped => escaped = true,
251                '"' if !escaped => {
252                    let (a, b) = rest.split_at(i + 1);
253                    return (header_path(a, "a/"), header_path(b.trim_start(), "b/"));
254                }
255                _ => escaped = false,
256            }
257        }
258        return (None, None);
259    }
260    // Unquoted and space-ambiguous: prefer the split where both sides name
261    // the same file, as git does.
262    let candidates: Vec<usize> = rest.match_indices(" b/").map(|(i, _)| i).collect();
263    for &i in &candidates {
264        let (a, b) = (&rest[..i], &rest[i + 1..]);
265        if a.strip_prefix("a/") == b.strip_prefix("b/") {
266            return (header_path(a, "a/"), header_path(b, "b/"));
267        }
268    }
269    match candidates.first() {
270        Some(&i) => (
271            header_path(&rest[..i], "a/"),
272            header_path(&rest[i + 1..], "b/"),
273        ),
274        None => match rest.split_once(' ') {
275            Some((a, b)) => (Some(a.to_string()), Some(b.to_string())),
276            None => (None, None),
277        },
278    }
279}
280
281fn parse_range(s: &str) -> Option<(usize, usize)> {
282    match s.split_once(',') {
283        Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
284        None => Some((s.parse().ok()?, 1)),
285    }
286}
287
288/// A hunk's `start,len` pair, rejected when it cannot describe lines of a
289/// file: a non-empty range must start at line 1 or later, and its last line
290/// must be a representable number.
291fn valid_range((start, len): (usize, usize)) -> Option<(usize, usize)> {
292    if start == 0 && len > 0 {
293        return None;
294    }
295    start.checked_add(len)?;
296    Some((start, len))
297}
298
299fn parse_hunk_header(line: &str) -> Option<PatchHunk> {
300    let rest = line.strip_prefix("@@ -")?;
301    let (ranges, section) = rest.split_once(" @@")?;
302    let (old, new) = ranges.split_once(" +")?;
303    let (old_start, old_len) = valid_range(parse_range(old)?)?;
304    let (new_start, new_len) = valid_range(parse_range(new)?)?;
305    Some(PatchHunk {
306        old_start,
307        old_len,
308        new_start,
309        new_len,
310        section: section.trim().to_string(),
311        lines: Vec::new(),
312    })
313}
314
315/// Parse `git diff` (or `diff -u`) output. Text outside any file, such as a
316/// commit message, is skipped.
317pub fn parse_unified(input: &str) -> Result<Patch, ParseError> {
318    let mut patch = Patch::default();
319    let mut file: Option<FilePatch> = None;
320    // (hunk, old lines left, new lines left, next old, next new)
321    let mut hunk: Option<(PatchHunk, usize, usize, usize, usize)> = None;
322    let mut in_binary_literal = false;
323
324    fn finish_hunk(
325        file: &mut Option<FilePatch>,
326        hunk: &mut Option<(PatchHunk, usize, usize, usize, usize)>,
327    ) {
328        if let (Some(f), Some((h, ..))) = (file.as_mut(), hunk.take()) {
329            f.hunks.push(h);
330        }
331    }
332    fn finish_file(patch: &mut Patch, file: &mut Option<FilePatch>) {
333        if let Some(mut f) = file.take() {
334            if f.status == FileStatus::Modified && f.binary {
335                f.status = FileStatus::Binary;
336            }
337            patch.files.push(f);
338        }
339    }
340
341    for (index, line) in input.lines().enumerate() {
342        let number = index + 1;
343        // Inside a hunk: consume body lines while counts remain.
344        if let Some((h, old_left, new_left, next_old, next_new)) = hunk.as_mut() {
345            if *old_left > 0 || *new_left > 0 {
346                let (kind, text) = match line.as_bytes().first() {
347                    Some(b' ') => (LineKind::Context, &line[1..]),
348                    Some(b'-') => (LineKind::Removed, &line[1..]),
349                    Some(b'+') => (LineKind::Added, &line[1..]),
350                    Some(b'\\') => {
351                        if let Some(last) = h.lines.last_mut() {
352                            last.no_newline = true;
353                        }
354                        continue;
355                    }
356                    // Some tools strip the space of an empty context line.
357                    None if *old_left > 0 && *new_left > 0 => (LineKind::Context, ""),
358                    _ => {
359                        return Err(ParseError {
360                            line: number,
361                            message: format!(
362                                "hunk ended early: expected {old_left} more old and {new_left} more new lines"
363                            ),
364                        });
365                    }
366                };
367                let (old_line, new_line) = match kind {
368                    LineKind::Context => {
369                        if *old_left == 0 || *new_left == 0 {
370                            return Err(ParseError {
371                                line: number,
372                                message: "context line past the end of the hunk".into(),
373                            });
374                        }
375                        *old_left -= 1;
376                        *new_left -= 1;
377                        *next_old += 1;
378                        *next_new += 1;
379                        (Some(*next_old - 1), Some(*next_new - 1))
380                    }
381                    LineKind::Removed => {
382                        if *old_left == 0 {
383                            return Err(ParseError {
384                                line: number,
385                                message: "removed line past the end of the hunk".into(),
386                            });
387                        }
388                        *old_left -= 1;
389                        *next_old += 1;
390                        (Some(*next_old - 1), None)
391                    }
392                    LineKind::Added => {
393                        if *new_left == 0 {
394                            return Err(ParseError {
395                                line: number,
396                                message: "added line past the end of the hunk".into(),
397                            });
398                        }
399                        *new_left -= 1;
400                        *next_new += 1;
401                        (None, Some(*next_new - 1))
402                    }
403                };
404                if let Some(f) = file.as_mut() {
405                    match kind {
406                        LineKind::Added => f.additions += 1,
407                        LineKind::Removed => f.deletions += 1,
408                        LineKind::Context => {}
409                    }
410                }
411                h.lines.push(PatchLine {
412                    kind,
413                    text: text.to_string(),
414                    old_line,
415                    new_line,
416                    no_newline: false,
417                });
418                continue;
419            }
420            if let Some(rest) = line.strip_prefix('\\') {
421                let _ = rest;
422                if let Some(last) = h.lines.last_mut() {
423                    last.no_newline = true;
424                }
425                continue;
426            }
427            finish_hunk(&mut file, &mut hunk);
428        }
429        if in_binary_literal {
430            if line.starts_with("diff --git ") {
431                in_binary_literal = false;
432            } else {
433                continue;
434            }
435        }
436        if let Some(rest) = line.strip_prefix("diff --git ") {
437            finish_file(&mut patch, &mut file);
438            let (old, new) = git_header_paths(rest);
439            let mut f = FilePatch::empty();
440            f.old_path = old;
441            f.new_path = new;
442            file = Some(f);
443            continue;
444        }
445        if line.starts_with("@@ ") {
446            let Some(f) = file.as_mut() else {
447                return Err(ParseError {
448                    line: number,
449                    message: "hunk outside a file".into(),
450                });
451            };
452            let Some(h) = parse_hunk_header(line) else {
453                return Err(ParseError {
454                    line: number,
455                    message: format!("malformed hunk header {line:?}"),
456                });
457            };
458            let _ = f;
459            let (ol, nl, os, ns) = (h.old_len, h.new_len, h.old_start.max(1), h.new_start.max(1));
460            hunk = Some((h, ol, nl, os, ns));
461            continue;
462        }
463        if let Some(rest) = line.strip_prefix("--- ") {
464            // A `diff -u` file without a `diff --git` header, or the paths
465            // of the current git file.
466            let starts_new = match &file {
467                None => true,
468                Some(f) => !f.hunks.is_empty(),
469            };
470            if starts_new {
471                finish_file(&mut patch, &mut file);
472                file = Some(FilePatch::empty());
473            }
474            if let Some(f) = file.as_mut() {
475                f.old_path = header_path(rest, "a/");
476                if f.old_path.is_none() {
477                    f.status = FileStatus::Added;
478                }
479            }
480            continue;
481        }
482        let Some(f) = file.as_mut() else {
483            continue;
484        };
485        if let Some(rest) = line.strip_prefix("+++ ") {
486            f.new_path = header_path(rest, "b/");
487            if f.new_path.is_none() {
488                f.status = FileStatus::Deleted;
489            }
490        } else if let Some(mode) = line.strip_prefix("new file mode ") {
491            f.status = FileStatus::Added;
492            f.new_mode = Some(mode.to_string());
493            f.old_path = None;
494        } else if let Some(mode) = line.strip_prefix("deleted file mode ") {
495            f.status = FileStatus::Deleted;
496            f.old_mode = Some(mode.to_string());
497            f.new_path = None;
498        } else if let Some(mode) = line.strip_prefix("old mode ") {
499            f.old_mode = Some(mode.to_string());
500        } else if let Some(mode) = line.strip_prefix("new mode ") {
501            f.new_mode = Some(mode.to_string());
502        } else if let Some(path) = line.strip_prefix("rename from ") {
503            f.status = FileStatus::Renamed;
504            f.old_path = Some(unquote(path));
505        } else if let Some(path) = line.strip_prefix("rename to ") {
506            f.status = FileStatus::Renamed;
507            f.new_path = Some(unquote(path));
508        } else if let Some(path) = line.strip_prefix("copy from ") {
509            f.status = FileStatus::Copied;
510            f.old_path = Some(unquote(path));
511        } else if let Some(path) = line.strip_prefix("copy to ") {
512            f.status = FileStatus::Copied;
513            f.new_path = Some(unquote(path));
514        } else if let Some(value) = line.strip_prefix("similarity index ") {
515            f.similarity = value.trim_end_matches('%').parse().ok();
516        } else if let Some(rest) = line.strip_prefix("index ") {
517            // `index abc..def 100644`: the trailing mode is both sides'.
518            if let Some((_, mode)) = rest.split_once(' ') {
519                f.old_mode.get_or_insert_with(|| mode.to_string());
520                f.new_mode.get_or_insert_with(|| mode.to_string());
521            }
522        } else if line.starts_with("Binary files ") && line.ends_with(" differ") {
523            f.binary = true;
524        } else if line == "GIT binary patch" {
525            f.binary = true;
526            in_binary_literal = true;
527        }
528    }
529    if let Some((h, old_left, new_left, ..)) = &hunk
530        && (*old_left > 0 || *new_left > 0)
531    {
532        return Err(ParseError {
533            line: input.lines().count(),
534            message: format!(
535                "hunk {} ended early: expected {old_left} more old and {new_left} more new lines",
536                shown(&h.header())
537            ),
538        });
539    }
540    finish_hunk(&mut file, &mut hunk);
541    finish_file(&mut patch, &mut file);
542    Ok(patch)
543}
544
545/// A note attached to a line of the new side, shown under it.
546#[derive(Clone, Debug, PartialEq, Eq)]
547pub struct Annotation {
548    pub path: String,
549    /// The 1-based line on the new side.
550    pub line: usize,
551    pub level: Level,
552    pub message: String,
553}
554
555impl Annotation {
556    pub fn new(
557        path: impl Into<String>,
558        line: usize,
559        level: Level,
560        message: impl Into<String>,
561    ) -> Self {
562        Annotation {
563            path: path.into(),
564            line,
565            level,
566            message: message.into(),
567        }
568    }
569}
570
571/// Where file and line links point.
572pub trait LinkProvider: Send + Sync {
573    /// The URL for a file.
574    fn file_url(&self, path: &str) -> Option<String>;
575    /// The URL for a line of a file's new version.
576    fn line_url(&self, path: &str, line: usize) -> Option<String>;
577}
578
579/// Links from URL templates. `{path}` and `{line}` are filled in, and so is
580/// every `{name}` given through [`var`](Self::var):
581///
582/// ```
583/// use rich_ext::diff::git::{LinkProvider, TemplateLinks};
584///
585/// let links = TemplateLinks::new("https://github.com/{owner}/{repo}/blob/{rev}/{path}#L{line}")
586///     .file_template("https://github.com/{owner}/{repo}/blob/{rev}/{path}")
587///     .var("owner", "octo")
588///     .var("repo", "demo")
589///     .var("rev", "main");
590/// assert_eq!(
591///     links.line_url("src/lib.rs", 7).as_deref(),
592///     Some("https://github.com/octo/demo/blob/main/src/lib.rs#L7")
593/// );
594/// ```
595#[derive(Clone, Debug, Default)]
596pub struct TemplateLinks {
597    line: String,
598    file: Option<String>,
599    vars: BTreeMap<String, String>,
600}
601
602impl TemplateLinks {
603    /// Links lines through `line_template`. Files get no link until
604    /// [`file_template`](Self::file_template) is set.
605    pub fn new(line_template: impl Into<String>) -> Self {
606        TemplateLinks {
607            line: line_template.into(),
608            file: None,
609            vars: BTreeMap::new(),
610        }
611    }
612    /// The template for whole-file links.
613    #[must_use]
614    pub fn file_template(mut self, template: impl Into<String>) -> Self {
615        self.file = Some(template.into());
616        self
617    }
618    /// A value for `{name}` in the templates.
619    #[must_use]
620    pub fn var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
621        self.vars.insert(name.into(), value.into());
622        self
623    }
624    fn fill(&self, template: &str, path: &str, line: Option<usize>) -> String {
625        let mut out = template.replace("{path}", &encode_path(path));
626        if let Some(line) = line {
627            out = out.replace("{line}", &line.to_string());
628        }
629        for (name, value) in &self.vars {
630            out = out.replace(&format!("{{{name}}}"), value);
631        }
632        out
633    }
634}
635
636/// `path` percent-encoded for a URL path: everything outside RFC 3986's
637/// path characters (unreserved, sub-delims, `:`, `@` and `/`) becomes `%XX`
638/// UTF-8 bytes. Controls in particular are encoded, so a file name cannot end
639/// an OSC 8 hyperlink early or smuggle an escape sequence into it.
640fn encode_path(path: &str) -> String {
641    let mut out = String::with_capacity(path.len());
642    for &b in path.as_bytes() {
643        let keep = b.is_ascii_alphanumeric()
644            || matches!(
645                b,
646                b'-' | b'.'
647                    | b'_'
648                    | b'~'
649                    | b'!'
650                    | b'$'
651                    | b'&'
652                    | b'\''
653                    | b'('
654                    | b')'
655                    | b'*'
656                    | b'+'
657                    | b','
658                    | b';'
659                    | b'='
660                    | b':'
661                    | b'@'
662                    | b'/'
663            );
664        if keep {
665            out.push(b as char);
666        } else {
667            out.push_str(&format!("%{b:02X}"));
668        }
669    }
670    out
671}
672
673/// A link target with any control character percent-encoded, whatever
674/// [`LinkProvider`] built it, so it cannot break out of an OSC 8 sequence.
675fn inert_url(url: String) -> String {
676    if !url.chars().any(char::is_control) {
677        return url;
678    }
679    let mut out = String::with_capacity(url.len() + 8);
680    for c in url.chars() {
681        if c.is_control() {
682            let mut buf = [0; 4];
683            for b in c.encode_utf8(&mut buf).bytes() {
684                out.push_str(&format!("%{b:02X}"));
685            }
686        } else {
687            out.push(c);
688        }
689    }
690    out
691}
692
693/// A path, mode or section for display: decoded escapes (git's `\033`)
694/// become visible text instead of terminal controls.
695fn shown(text: &str) -> String {
696    crate::sanitize::sanitize_single_line(text)
697}
698
699impl LinkProvider for TemplateLinks {
700    fn file_url(&self, path: &str) -> Option<String> {
701        self.file.as_ref().map(|t| self.fill(t, path, None))
702    }
703    fn line_url(&self, path: &str, line: usize) -> Option<String> {
704        Some(self.fill(&self.line, path, Some(line)))
705    }
706}
707
708/// Local `file://` or editor links through a [`Hyperlinker`].
709impl LinkProvider for Hyperlinker {
710    fn file_url(&self, path: &str) -> Option<String> {
711        Hyperlinker::file_url(self, path, None, None)
712    }
713    fn line_url(&self, path: &str, line: usize) -> Option<String> {
714        Hyperlinker::file_url(self, path, Some(line), None)
715    }
716}
717
718/// A review-style rendering of a [`Patch`]: a file tree with per-file
719/// counts, then each file's header and highlighted hunks, with annotations
720/// under their lines and a changed-line summary at the end.
721#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
722pub struct PatchView {
723    patch: Patch,
724    annotations: Vec<Annotation>,
725    links: Option<Box<dyn LinkProvider>>,
726    layout: Layout,
727    line_numbers: bool,
728    wrap: bool,
729    highlight: bool,
730    tree: bool,
731    emphasis: bool,
732}
733
734impl fmt::Debug for PatchView {
735    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
736        f.debug_struct("PatchView")
737            .field("files", &self.patch.files.len())
738            .field("annotations", &self.annotations.len())
739            .field("layout", &self.layout)
740            .finish_non_exhaustive()
741    }
742}
743
744impl PatchView {
745    pub fn new(patch: Patch) -> Self {
746        PatchView {
747            patch,
748            annotations: Vec::new(),
749            links: None,
750            layout: Layout::Unified,
751            line_numbers: true,
752            wrap: true,
753            highlight: true,
754            tree: true,
755            emphasis: true,
756        }
757    }
758    /// Show `annotation` under its line.
759    pub fn annotate(mut self, annotation: Annotation) -> Self {
760        self.annotations.push(annotation);
761        self
762    }
763    /// Show every annotation under its line.
764    pub fn annotations(mut self, annotations: impl IntoIterator<Item = Annotation>) -> Self {
765        self.annotations.extend(annotations);
766        self
767    }
768    /// Link paths and new-side line numbers.
769    pub fn links(mut self, provider: impl LinkProvider + 'static) -> Self {
770        self.links = Some(Box::new(provider));
771        self
772    }
773    pub fn layout(mut self, layout: Layout) -> Self {
774        self.layout = layout;
775        self
776    }
777    pub fn line_numbers(mut self, show: bool) -> Self {
778        self.line_numbers = show;
779        self
780    }
781    pub fn wrap(mut self, wrap: bool) -> Self {
782        self.wrap = wrap;
783        self
784    }
785    /// Syntax highlighting by path (default on).
786    pub fn highlight(mut self, on: bool) -> Self {
787        self.highlight = on;
788        self
789    }
790    /// The file tree summary (default on).
791    pub fn tree(mut self, show: bool) -> Self {
792        self.tree = show;
793        self
794    }
795    /// Word-level emphasis (default on).
796    pub fn emphasis(mut self, on: bool) -> Self {
797        self.emphasis = on;
798        self
799    }
800    /// The patch shown.
801    pub fn patch(&self) -> &Patch {
802        &self.patch
803    }
804
805    fn blocks(&self, file: &FilePatch, console: &Console) -> Vec<Block> {
806        let language = (self.highlight).then(|| language_for_path(file.path()));
807        let mut blocks = Vec::new();
808        for hunk in &file.hunks {
809            // Highlight each side of the hunk as one text.
810            let side = |keep: LineKind| {
811                let lines: Vec<&str> = hunk
812                    .lines
813                    .iter()
814                    .filter(|l| l.kind == LineKind::Context || l.kind == keep)
815                    .map(|l| l.text.as_str())
816                    .collect();
817                let mut code = lines.join("\n");
818                code.push('\n');
819                highlight_lines(&code, language.as_deref(), Some(console))
820            };
821            let (mut old, mut new) = (
822                side(LineKind::Removed).into_iter(),
823                side(LineKind::Added).into_iter(),
824            );
825            let mut block = Block {
826                header: Some(shown(&hunk.header())),
827                rows: Vec::new(),
828            };
829            for line in &hunk.lines {
830                let (kind, text) = match line.kind {
831                    LineKind::Context => {
832                        old.next();
833                        (Kind::Context, new.next())
834                    }
835                    LineKind::Removed => (Kind::Delete, old.next()),
836                    LineKind::Added => (Kind::Insert, new.next()),
837                };
838                let text = text.unwrap_or_else(|| Text::new(&line.text));
839                let mut row = Row::new(kind, line.old_line, line.new_line, text);
840                row.no_newline = line.no_newline;
841                if let (Some(n), Some(path), Some(links)) =
842                    (line.new_line, &file.new_path, &self.links)
843                {
844                    row.link = links.line_url(path, n).map(inert_url);
845                }
846                if let (Some(n), Some(path)) = (line.new_line, &file.new_path) {
847                    row.notes = self
848                        .annotations
849                        .iter()
850                        .filter(|a| a.line == n && &a.path == path)
851                        .map(|a| (a.level, a.message.clone()))
852                        .collect();
853                }
854                block.rows.push(row);
855            }
856            if self.emphasis {
857                render::emphasize(&mut block);
858            }
859            blocks.push(block);
860        }
861        blocks
862    }
863
864    /// `path`, shown inert, linked to the raw path's URL.
865    fn path_segment(&self, path: &str, style: Style) -> Segment {
866        let url = self
867            .links
868            .as_ref()
869            .and_then(|l| l.file_url(path))
870            .map(inert_url);
871        Segment::new(
872            shown(path),
873            Some(match url {
874                Some(url) => style.with_link(url),
875                None => style,
876            }),
877        )
878    }
879
880    fn counts(
881        &self,
882        console: &Console,
883        file: &FilePatch,
884        bar: usize,
885        scale: usize,
886    ) -> Vec<Segment> {
887        if file.binary {
888            return vec![Segment::new(
889                "binary",
890                Some(style(console, "diff.line_number")),
891            )];
892        }
893        let mut out = vec![
894            Segment::new(
895                format!("+{}", file.additions),
896                Some(style(console, "diff.added")),
897            ),
898            Segment::new(" ", None),
899            Segment::new(
900                format!("-{}", file.deletions),
901                Some(style(console, "diff.removed")),
902            ),
903        ];
904        let total = file.additions + file.deletions;
905        if bar > 0 && total > 0 {
906            // git --stat's bar, scaled to the busiest file.
907            let cells = (total * bar).div_ceil(scale.max(1)).clamp(1, bar);
908            let plus = (file.additions * cells).div_ceil(total).min(cells);
909            let plus = if file.deletions > 0 && plus == cells {
910                cells - 1
911            } else {
912                plus
913            };
914            out.push(Segment::new(" ", None));
915            out.push(Segment::new(
916                "+".repeat(plus),
917                Some(style(console, "diff.added")),
918            ));
919            out.push(Segment::new(
920                "-".repeat(cells - plus),
921                Some(style(console, "diff.removed")),
922            ));
923        }
924        out
925    }
926
927    fn tree_rows(&self, console: &Console, width: usize) -> Vec<Vec<Segment>> {
928        #[derive(Default)]
929        struct Dir<'a> {
930            dirs: BTreeMap<String, Dir<'a>>,
931            files: Vec<(String, &'a FilePatch)>,
932        }
933        let mut root = Dir::default();
934        for file in &self.patch.files {
935            let path = shown(file.path());
936            let mut parts: Vec<&str> = path.split('/').collect();
937            let name = parts.pop().unwrap_or(&path);
938            let mut dir = &mut root;
939            for part in parts {
940                dir = dir.dirs.entry(part.to_string()).or_default();
941            }
942            let mut label = name.to_string();
943            match file.status {
944                FileStatus::Renamed | FileStatus::Copied => {
945                    if let Some(old) = &file.old_path {
946                        label = format!("{name} ({} from {})", file.status.label(), shown(old));
947                    }
948                }
949                FileStatus::Added | FileStatus::Deleted => {
950                    label = format!("{name} ({})", file.status.label());
951                }
952                _ if file.mode_changed() => {
953                    label = format!(
954                        "{name} (mode {})",
955                        shown(file.new_mode.as_deref().unwrap_or(""))
956                    );
957                }
958                _ => {}
959            }
960            dir.files.push((label, file));
961        }
962        let (branch, last, pipe, blank) = if console.ascii_only() {
963            ("|-- ", "`-- ", "|   ", "    ")
964        } else {
965            ("├── ", "└── ", "│   ", "    ")
966        };
967        // (prefix, label, file)
968        let mut lines: Vec<(String, String, Option<&FilePatch>)> = Vec::new();
969        fn walk<'a>(
970            dir: &Dir<'a>,
971            prefix: &str,
972            glyphs: (&str, &str, &str, &str),
973            lines: &mut Vec<(String, String, Option<&'a FilePatch>)>,
974        ) {
975            let count = dir.dirs.len() + dir.files.len();
976            let mut i = 0;
977            for (name, sub) in &dir.dirs {
978                i += 1;
979                // Collapse single-child directory chains: `src/diff/`.
980                let mut label = format!("{name}/");
981                let mut sub = sub;
982                while sub.files.is_empty() && sub.dirs.len() == 1 {
983                    let (n, s) = sub.dirs.iter().next().expect("one child");
984                    label.push_str(&format!("{n}/"));
985                    sub = s;
986                }
987                let is_last = i == count;
988                lines.push((
989                    format!("{prefix}{}", if is_last { glyphs.1 } else { glyphs.0 }),
990                    label,
991                    None,
992                ));
993                let next = format!("{prefix}{}", if is_last { glyphs.3 } else { glyphs.2 });
994                walk(sub, &next, glyphs, lines);
995            }
996            for (label, file) in &dir.files {
997                i += 1;
998                let is_last = i == count;
999                lines.push((
1000                    format!("{prefix}{}", if is_last { glyphs.1 } else { glyphs.0 }),
1001                    label.clone(),
1002                    Some(file),
1003                ));
1004            }
1005        }
1006        walk(&root, "", (branch, last, pipe, blank), &mut lines);
1007        let name_width = lines
1008            .iter()
1009            .filter(|l| l.2.is_some())
1010            .map(|(p, l, _)| cell_len(p) + cell_len(l))
1011            .max()
1012            .unwrap_or(0);
1013        let scale = self
1014            .patch
1015            .files
1016            .iter()
1017            .map(|f| f.additions + f.deletions)
1018            .max()
1019            .unwrap_or(0);
1020        let bar = 10.min(scale);
1021        let dim = style(console, "diff.line_number");
1022        let mut out = Vec::new();
1023        for (prefix, label, file) in lines {
1024            let mut row = vec![Segment::new(prefix.clone(), Some(dim.clone()))];
1025            match file {
1026                None => row.push(Segment::new(label, Some(style(console, "diff.header")))),
1027                Some(file) => {
1028                    let st = match file.status {
1029                        FileStatus::Added => style(console, "diff.added"),
1030                        FileStatus::Deleted => style(console, "diff.removed"),
1031                        _ => Style::new(),
1032                    };
1033                    let pad = name_width.saturating_sub(cell_len(&prefix) + cell_len(&label));
1034                    // Linked to the file, labelled with its name and status.
1035                    let mut seg = self.path_segment(file.path(), st);
1036                    seg.text = label.into();
1037                    row.push(seg);
1038                    row.push(Segment::new(" ".repeat(pad + 2), None));
1039                    row.extend(self.counts(console, file, bar, scale));
1040                }
1041            }
1042            out.extend(
1043                crate::layout::fit_segments(&row, width, crate::layout::OverflowPolicy::Crop)
1044                    .into_iter()
1045                    .map(render::trim_end),
1046            );
1047        }
1048        out
1049    }
1050
1051    fn file_header(&self, console: &Console, file: &FilePatch, width: usize) -> Vec<Vec<Segment>> {
1052        let header = style(console, "diff.header");
1053        let mut row = vec![Segment::new(
1054            format!("{} ", file.status.label()),
1055            Some(style(console, "diff.hunk")),
1056        )];
1057        match (file.status, &file.old_path, &file.new_path) {
1058            (FileStatus::Renamed | FileStatus::Copied, Some(old), Some(new)) => {
1059                row.push(self.path_segment(old, header.clone()));
1060                row.push(Segment::new(" -> ", Some(header.clone())));
1061                row.push(self.path_segment(new, header.clone()));
1062                if let Some(similarity) = file.similarity {
1063                    row.push(Segment::new(
1064                        format!(" ({similarity}%)"),
1065                        Some(style(console, "diff.line_number")),
1066                    ));
1067                }
1068            }
1069            _ => row.push(self.path_segment(file.path(), header.clone())),
1070        }
1071        row.push(Segment::new("  ", None));
1072        row.extend(self.counts(console, file, 0, 0));
1073        let mut rows =
1074            crate::layout::fit_segments(&row, width, crate::layout::OverflowPolicy::Fold);
1075        if file.mode_changed() {
1076            rows.push(vec![Segment::new(
1077                format!(
1078                    "mode {} -> {}",
1079                    shown(file.old_mode.as_deref().unwrap_or("")),
1080                    shown(file.new_mode.as_deref().unwrap_or(""))
1081                ),
1082                Some(style(console, "diff.line_number")),
1083            )]);
1084        }
1085        if file.binary {
1086            rows.push(vec![Segment::new(
1087                "Binary file differs",
1088                Some(style(console, "diff.line_number")),
1089            )]);
1090        }
1091        rows.into_iter().map(render::trim_end).collect()
1092    }
1093}
1094
1095impl Renderable for PatchView {
1096    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1097        let width = options.max_width;
1098        if width == 0 || options.height == Some(0) {
1099            return Vec::new();
1100        }
1101        let mut rows: Vec<Vec<Segment>> = Vec::new();
1102        if self.tree && !self.patch.files.is_empty() {
1103            rows.extend(self.tree_rows(console, width));
1104            rows.push(Vec::new());
1105        }
1106        let opts = Options {
1107            layout: self.layout,
1108            line_numbers: self.line_numbers,
1109            wrap: self.wrap,
1110            titles: None,
1111        };
1112        for file in &self.patch.files {
1113            rows.extend(self.file_header(console, file, width));
1114            let blocks = self.blocks(file, console);
1115            rows.extend(render::render(console, &blocks, &opts, width));
1116            rows.push(Vec::new());
1117        }
1118        let (added, removed) = self.patch.stats();
1119        let n = self.patch.files.len();
1120        let summary = format!(
1121            "{n} file{} changed, {added} insertion{}(+), {removed} deletion{}(-)",
1122            if n == 1 { "" } else { "s" },
1123            if added == 1 { "" } else { "s" },
1124            if removed == 1 { "" } else { "s" },
1125        );
1126        rows.extend(render::banner(
1127            &summary,
1128            style(console, "diff.header"),
1129            width,
1130        ));
1131        render::join(rows, options.height)
1132    }
1133}