1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub enum FileStatus {
32 Added,
33 Deleted,
34 Modified,
35 Renamed,
36 Copied,
37 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57pub enum LineKind {
58 Context,
59 Added,
60 Removed,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct PatchLine {
66 pub kind: LineKind,
67 pub text: String,
69 pub old_line: Option<usize>,
70 pub new_line: Option<usize>,
71 pub no_newline: bool,
73}
74
75#[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 pub section: String,
84 pub lines: Vec<PatchLine>,
85}
86
87impl PatchHunk {
88 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#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct FilePatch {
115 pub old_path: Option<String>,
117 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 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 pub fn path(&self) -> &str {
147 self.new_path
148 .as_deref()
149 .or(self.old_path.as_deref())
150 .unwrap_or("")
151 }
152 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub struct Patch {
161 pub files: Vec<FilePatch>,
162}
163
164impl Patch {
165 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#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct ParseError {
176 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
188fn 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
228fn 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
243fn git_header_paths(rest: &str) -> (Option<String>, Option<String>) {
245 if rest.starts_with('"') {
246 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 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
288fn 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
315pub fn parse_unified(input: &str) -> Result<Patch, ParseError> {
318 let mut patch = Patch::default();
319 let mut file: Option<FilePatch> = None;
320 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 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 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
547pub struct Annotation {
548 pub path: String,
549 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
571pub trait LinkProvider: Send + Sync {
573 fn file_url(&self, path: &str) -> Option<String>;
575 fn line_url(&self, path: &str, line: usize) -> Option<String>;
577}
578
579#[derive(Clone, Debug, Default)]
596pub struct TemplateLinks {
597 line: String,
598 file: Option<String>,
599 vars: BTreeMap<String, String>,
600}
601
602impl TemplateLinks {
603 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 #[must_use]
614 pub fn file_template(mut self, template: impl Into<String>) -> Self {
615 self.file = Some(template.into());
616 self
617 }
618 #[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
636fn 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
673fn 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
693fn 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
708impl 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#[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 pub fn annotate(mut self, annotation: Annotation) -> Self {
760 self.annotations.push(annotation);
761 self
762 }
763 pub fn annotations(mut self, annotations: impl IntoIterator<Item = Annotation>) -> Self {
765 self.annotations.extend(annotations);
766 self
767 }
768 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 pub fn highlight(mut self, on: bool) -> Self {
787 self.highlight = on;
788 self
789 }
790 pub fn tree(mut self, show: bool) -> Self {
792 self.tree = show;
793 self
794 }
795 pub fn emphasis(mut self, on: bool) -> Self {
797 self.emphasis = on;
798 self
799 }
800 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 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 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 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 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 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 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}