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::{style, Layout};
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 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#[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 pub fn file_template(mut self, template: impl Into<String>) -> Self {
614 self.file = Some(template.into());
615 self
616 }
617 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
634fn 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
671fn 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
691fn 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
706impl 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
716pub 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 pub fn annotate(mut self, annotation: Annotation) -> Self {
757 self.annotations.push(annotation);
758 self
759 }
760 pub fn annotations(mut self, annotations: impl IntoIterator<Item = Annotation>) -> Self {
762 self.annotations.extend(annotations);
763 self
764 }
765 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 pub fn highlight(mut self, on: bool) -> Self {
784 self.highlight = on;
785 self
786 }
787 pub fn tree(mut self, show: bool) -> Self {
789 self.tree = show;
790 self
791 }
792 pub fn emphasis(mut self, on: bool) -> Self {
794 self.emphasis = on;
795 self
796 }
797 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 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 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 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 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 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 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}