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::{style, Layout};
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        if *old_left > 0 || *new_left > 0 {
531            return Err(ParseError {
532                line: input.lines().count(),
533                message: format!(
534                    "hunk {} ended early: expected {old_left} more old and {new_left} more new lines",
535                    shown(&h.header())
536                ),
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    pub fn file_template(mut self, template: impl Into<String>) -> Self {
614        self.file = Some(template.into());
615        self
616    }
617    /// A value for `{name}` in the templates.
618    pub fn var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
619        self.vars.insert(name.into(), value.into());
620        self
621    }
622    fn fill(&self, template: &str, path: &str, line: Option<usize>) -> String {
623        let mut out = template.replace("{path}", &encode_path(path));
624        if let Some(line) = line {
625            out = out.replace("{line}", &line.to_string());
626        }
627        for (name, value) in &self.vars {
628            out = out.replace(&format!("{{{name}}}"), value);
629        }
630        out
631    }
632}
633
634/// `path` percent-encoded for a URL path: everything outside RFC 3986's
635/// path characters (unreserved, sub-delims, `:`, `@` and `/`) becomes `%XX`
636/// UTF-8 bytes. Controls in particular are encoded, so a file name cannot end
637/// an OSC 8 hyperlink early or smuggle an escape sequence into it.
638fn encode_path(path: &str) -> String {
639    let mut out = String::with_capacity(path.len());
640    for &b in path.as_bytes() {
641        let keep = b.is_ascii_alphanumeric()
642            || matches!(
643                b,
644                b'-' | b'.'
645                    | b'_'
646                    | 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            );
662        if keep {
663            out.push(b as char);
664        } else {
665            out.push_str(&format!("%{b:02X}"));
666        }
667    }
668    out
669}
670
671/// A link target with any control character percent-encoded, whatever
672/// [`LinkProvider`] built it, so it cannot break out of an OSC 8 sequence.
673fn inert_url(url: String) -> String {
674    if !url.chars().any(char::is_control) {
675        return url;
676    }
677    let mut out = String::with_capacity(url.len() + 8);
678    for c in url.chars() {
679        if c.is_control() {
680            let mut buf = [0; 4];
681            for b in c.encode_utf8(&mut buf).bytes() {
682                out.push_str(&format!("%{b:02X}"));
683            }
684        } else {
685            out.push(c);
686        }
687    }
688    out
689}
690
691/// A path, mode or section for display: decoded escapes (git's `\033`)
692/// become visible text instead of terminal controls.
693fn shown(text: &str) -> String {
694    crate::sanitize::sanitize_single_line(text)
695}
696
697impl LinkProvider for TemplateLinks {
698    fn file_url(&self, path: &str) -> Option<String> {
699        self.file.as_ref().map(|t| self.fill(t, path, None))
700    }
701    fn line_url(&self, path: &str, line: usize) -> Option<String> {
702        Some(self.fill(&self.line, path, Some(line)))
703    }
704}
705
706/// Local `file://` or editor links through a [`Hyperlinker`].
707impl LinkProvider for Hyperlinker {
708    fn file_url(&self, path: &str) -> Option<String> {
709        Hyperlinker::file_url(self, path, None, None)
710    }
711    fn line_url(&self, path: &str, line: usize) -> Option<String> {
712        Hyperlinker::file_url(self, path, Some(line), None)
713    }
714}
715
716/// A review-style rendering of a [`Patch`]: a file tree with per-file
717/// counts, then each file's header and highlighted hunks, with annotations
718/// under their lines and a changed-line summary at the end.
719pub struct PatchView {
720    patch: Patch,
721    annotations: Vec<Annotation>,
722    links: Option<Box<dyn LinkProvider>>,
723    layout: Layout,
724    line_numbers: bool,
725    wrap: bool,
726    highlight: bool,
727    tree: bool,
728    emphasis: bool,
729}
730
731impl fmt::Debug for PatchView {
732    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733        f.debug_struct("PatchView")
734            .field("files", &self.patch.files.len())
735            .field("annotations", &self.annotations.len())
736            .field("layout", &self.layout)
737            .finish_non_exhaustive()
738    }
739}
740
741impl PatchView {
742    pub fn new(patch: Patch) -> Self {
743        PatchView {
744            patch,
745            annotations: Vec::new(),
746            links: None,
747            layout: Layout::Unified,
748            line_numbers: true,
749            wrap: true,
750            highlight: true,
751            tree: true,
752            emphasis: true,
753        }
754    }
755    /// Show `annotation` under its line.
756    pub fn annotate(mut self, annotation: Annotation) -> Self {
757        self.annotations.push(annotation);
758        self
759    }
760    /// Show every annotation under its line.
761    pub fn annotations(mut self, annotations: impl IntoIterator<Item = Annotation>) -> Self {
762        self.annotations.extend(annotations);
763        self
764    }
765    /// Link paths and new-side line numbers.
766    pub fn links(mut self, provider: impl LinkProvider + 'static) -> Self {
767        self.links = Some(Box::new(provider));
768        self
769    }
770    pub fn layout(mut self, layout: Layout) -> Self {
771        self.layout = layout;
772        self
773    }
774    pub fn line_numbers(mut self, show: bool) -> Self {
775        self.line_numbers = show;
776        self
777    }
778    pub fn wrap(mut self, wrap: bool) -> Self {
779        self.wrap = wrap;
780        self
781    }
782    /// Syntax highlighting by path (default on).
783    pub fn highlight(mut self, on: bool) -> Self {
784        self.highlight = on;
785        self
786    }
787    /// The file tree summary (default on).
788    pub fn tree(mut self, show: bool) -> Self {
789        self.tree = show;
790        self
791    }
792    /// Word-level emphasis (default on).
793    pub fn emphasis(mut self, on: bool) -> Self {
794        self.emphasis = on;
795        self
796    }
797    /// The patch shown.
798    pub fn patch(&self) -> &Patch {
799        &self.patch
800    }
801
802    fn blocks(&self, file: &FilePatch, console: &Console) -> Vec<Block> {
803        let language = (self.highlight).then(|| language_for_path(file.path()));
804        let mut blocks = Vec::new();
805        for hunk in &file.hunks {
806            // Highlight each side of the hunk as one text.
807            let side = |keep: LineKind| {
808                let lines: Vec<&str> = hunk
809                    .lines
810                    .iter()
811                    .filter(|l| l.kind == LineKind::Context || l.kind == keep)
812                    .map(|l| l.text.as_str())
813                    .collect();
814                let mut code = lines.join("\n");
815                code.push('\n');
816                highlight_lines(&code, language.as_deref(), Some(console))
817            };
818            let (mut old, mut new) = (
819                side(LineKind::Removed).into_iter(),
820                side(LineKind::Added).into_iter(),
821            );
822            let mut block = Block {
823                header: Some(shown(&hunk.header())),
824                rows: Vec::new(),
825            };
826            for line in &hunk.lines {
827                let (kind, text) = match line.kind {
828                    LineKind::Context => {
829                        old.next();
830                        (Kind::Context, new.next())
831                    }
832                    LineKind::Removed => (Kind::Delete, old.next()),
833                    LineKind::Added => (Kind::Insert, new.next()),
834                };
835                let text = text.unwrap_or_else(|| Text::new(&line.text));
836                let mut row = Row::new(kind, line.old_line, line.new_line, text);
837                row.no_newline = line.no_newline;
838                if let (Some(n), Some(path), Some(links)) =
839                    (line.new_line, &file.new_path, &self.links)
840                {
841                    row.link = links.line_url(path, n).map(inert_url);
842                }
843                if let (Some(n), Some(path)) = (line.new_line, &file.new_path) {
844                    row.notes = self
845                        .annotations
846                        .iter()
847                        .filter(|a| a.line == n && &a.path == path)
848                        .map(|a| (a.level, a.message.clone()))
849                        .collect();
850                }
851                block.rows.push(row);
852            }
853            if self.emphasis {
854                render::emphasize(&mut block);
855            }
856            blocks.push(block);
857        }
858        blocks
859    }
860
861    /// `path`, shown inert, linked to the raw path's URL.
862    fn path_segment(&self, path: &str, style: Style) -> Segment {
863        let url = self
864            .links
865            .as_ref()
866            .and_then(|l| l.file_url(path))
867            .map(inert_url);
868        Segment::new(
869            shown(path),
870            Some(match url {
871                Some(url) => style.with_link(url),
872                None => style,
873            }),
874        )
875    }
876
877    fn counts(
878        &self,
879        console: &Console,
880        file: &FilePatch,
881        bar: usize,
882        scale: usize,
883    ) -> Vec<Segment> {
884        if file.binary {
885            return vec![Segment::new(
886                "binary",
887                Some(style(console, "diff.line_number")),
888            )];
889        }
890        let mut out = vec![
891            Segment::new(
892                format!("+{}", file.additions),
893                Some(style(console, "diff.added")),
894            ),
895            Segment::new(" ", None),
896            Segment::new(
897                format!("-{}", file.deletions),
898                Some(style(console, "diff.removed")),
899            ),
900        ];
901        let total = file.additions + file.deletions;
902        if bar > 0 && total > 0 {
903            // git --stat's bar, scaled to the busiest file.
904            let cells = (total * bar).div_ceil(scale.max(1)).clamp(1, bar);
905            let plus = (file.additions * cells).div_ceil(total).min(cells);
906            let plus = if file.deletions > 0 && plus == cells {
907                cells - 1
908            } else {
909                plus
910            };
911            out.push(Segment::new(" ", None));
912            out.push(Segment::new(
913                "+".repeat(plus),
914                Some(style(console, "diff.added")),
915            ));
916            out.push(Segment::new(
917                "-".repeat(cells - plus),
918                Some(style(console, "diff.removed")),
919            ));
920        }
921        out
922    }
923
924    fn tree_rows(&self, console: &Console, width: usize) -> Vec<Vec<Segment>> {
925        #[derive(Default)]
926        struct Dir<'a> {
927            dirs: BTreeMap<String, Dir<'a>>,
928            files: Vec<(String, &'a FilePatch)>,
929        }
930        let mut root = Dir::default();
931        for file in &self.patch.files {
932            let path = shown(file.path());
933            let mut parts: Vec<&str> = path.split('/').collect();
934            let name = parts.pop().unwrap_or(&path);
935            let mut dir = &mut root;
936            for part in parts {
937                dir = dir.dirs.entry(part.to_string()).or_default();
938            }
939            let mut label = name.to_string();
940            match file.status {
941                FileStatus::Renamed | FileStatus::Copied => {
942                    if let Some(old) = &file.old_path {
943                        label = format!("{name} ({} from {})", file.status.label(), shown(old));
944                    }
945                }
946                FileStatus::Added | FileStatus::Deleted => {
947                    label = format!("{name} ({})", file.status.label());
948                }
949                _ if file.mode_changed() => {
950                    label = format!(
951                        "{name} (mode {})",
952                        shown(file.new_mode.as_deref().unwrap_or(""))
953                    );
954                }
955                _ => {}
956            }
957            dir.files.push((label, file));
958        }
959        let (branch, last, pipe, blank) = if console.ascii_only() {
960            ("|-- ", "`-- ", "|   ", "    ")
961        } else {
962            ("├── ", "└── ", "│   ", "    ")
963        };
964        // (prefix, label, file)
965        let mut lines: Vec<(String, String, Option<&FilePatch>)> = Vec::new();
966        fn walk<'a>(
967            dir: &Dir<'a>,
968            prefix: &str,
969            glyphs: (&str, &str, &str, &str),
970            lines: &mut Vec<(String, String, Option<&'a FilePatch>)>,
971        ) {
972            let count = dir.dirs.len() + dir.files.len();
973            let mut i = 0;
974            for (name, sub) in &dir.dirs {
975                i += 1;
976                // Collapse single-child directory chains: `src/diff/`.
977                let mut label = format!("{name}/");
978                let mut sub = sub;
979                while sub.files.is_empty() && sub.dirs.len() == 1 {
980                    let (n, s) = sub.dirs.iter().next().expect("one child");
981                    label.push_str(&format!("{n}/"));
982                    sub = s;
983                }
984                let is_last = i == count;
985                lines.push((
986                    format!("{prefix}{}", if is_last { glyphs.1 } else { glyphs.0 }),
987                    label,
988                    None,
989                ));
990                let next = format!("{prefix}{}", if is_last { glyphs.3 } else { glyphs.2 });
991                walk(sub, &next, glyphs, lines);
992            }
993            for (label, file) in &dir.files {
994                i += 1;
995                let is_last = i == count;
996                lines.push((
997                    format!("{prefix}{}", if is_last { glyphs.1 } else { glyphs.0 }),
998                    label.clone(),
999                    Some(file),
1000                ));
1001            }
1002        }
1003        walk(&root, "", (branch, last, pipe, blank), &mut lines);
1004        let name_width = lines
1005            .iter()
1006            .filter(|l| l.2.is_some())
1007            .map(|(p, l, _)| cell_len(p) + cell_len(l))
1008            .max()
1009            .unwrap_or(0);
1010        let scale = self
1011            .patch
1012            .files
1013            .iter()
1014            .map(|f| f.additions + f.deletions)
1015            .max()
1016            .unwrap_or(0);
1017        let bar = 10.min(scale);
1018        let dim = style(console, "diff.line_number");
1019        let mut out = Vec::new();
1020        for (prefix, label, file) in lines {
1021            let mut row = vec![Segment::new(prefix.clone(), Some(dim.clone()))];
1022            match file {
1023                None => row.push(Segment::new(label, Some(style(console, "diff.header")))),
1024                Some(file) => {
1025                    let st = match file.status {
1026                        FileStatus::Added => style(console, "diff.added"),
1027                        FileStatus::Deleted => style(console, "diff.removed"),
1028                        _ => Style::new(),
1029                    };
1030                    let pad = name_width.saturating_sub(cell_len(&prefix) + cell_len(&label));
1031                    // Linked to the file, labelled with its name and status.
1032                    let mut seg = self.path_segment(file.path(), st);
1033                    seg.text = label;
1034                    row.push(seg);
1035                    row.push(Segment::new(" ".repeat(pad + 2), None));
1036                    row.extend(self.counts(console, file, bar, scale));
1037                }
1038            }
1039            out.extend(
1040                crate::layout::fit_segments(&row, width, crate::layout::OverflowPolicy::Crop)
1041                    .into_iter()
1042                    .map(render::trim_end),
1043            );
1044        }
1045        out
1046    }
1047
1048    fn file_header(&self, console: &Console, file: &FilePatch, width: usize) -> Vec<Vec<Segment>> {
1049        let header = style(console, "diff.header");
1050        let mut row = vec![Segment::new(
1051            format!("{} ", file.status.label()),
1052            Some(style(console, "diff.hunk")),
1053        )];
1054        match (file.status, &file.old_path, &file.new_path) {
1055            (FileStatus::Renamed | FileStatus::Copied, Some(old), Some(new)) => {
1056                row.push(self.path_segment(old, header.clone()));
1057                row.push(Segment::new(" -> ", Some(header.clone())));
1058                row.push(self.path_segment(new, header.clone()));
1059                if let Some(similarity) = file.similarity {
1060                    row.push(Segment::new(
1061                        format!(" ({similarity}%)"),
1062                        Some(style(console, "diff.line_number")),
1063                    ));
1064                }
1065            }
1066            _ => row.push(self.path_segment(file.path(), header.clone())),
1067        }
1068        row.push(Segment::new("  ", None));
1069        row.extend(self.counts(console, file, 0, 0));
1070        let mut rows =
1071            crate::layout::fit_segments(&row, width, crate::layout::OverflowPolicy::Fold);
1072        if file.mode_changed() {
1073            rows.push(vec![Segment::new(
1074                format!(
1075                    "mode {} -> {}",
1076                    shown(file.old_mode.as_deref().unwrap_or("")),
1077                    shown(file.new_mode.as_deref().unwrap_or(""))
1078                ),
1079                Some(style(console, "diff.line_number")),
1080            )]);
1081        }
1082        if file.binary {
1083            rows.push(vec![Segment::new(
1084                "Binary file differs",
1085                Some(style(console, "diff.line_number")),
1086            )]);
1087        }
1088        rows.into_iter().map(render::trim_end).collect()
1089    }
1090}
1091
1092impl Renderable for PatchView {
1093    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1094        let width = options.max_width;
1095        if width == 0 || options.height == Some(0) {
1096            return Vec::new();
1097        }
1098        let mut rows: Vec<Vec<Segment>> = Vec::new();
1099        if self.tree && !self.patch.files.is_empty() {
1100            rows.extend(self.tree_rows(console, width));
1101            rows.push(Vec::new());
1102        }
1103        let opts = Options {
1104            layout: self.layout,
1105            line_numbers: self.line_numbers,
1106            wrap: self.wrap,
1107            titles: None,
1108        };
1109        for file in &self.patch.files {
1110            rows.extend(self.file_header(console, file, width));
1111            let blocks = self.blocks(file, console);
1112            rows.extend(render::render(console, &blocks, &opts, width));
1113            rows.push(Vec::new());
1114        }
1115        let (added, removed) = self.patch.stats();
1116        let n = self.patch.files.len();
1117        let summary = format!(
1118            "{n} file{} changed, {added} insertion{}(+), {removed} deletion{}(-)",
1119            if n == 1 { "" } else { "s" },
1120            if added == 1 { "" } else { "s" },
1121            if removed == 1 { "" } else { "s" },
1122        );
1123        rows.extend(render::banner(
1124            &summary,
1125            style(console, "diff.header"),
1126            width,
1127        ));
1128        render::join(rows, options.height)
1129    }
1130}