#![allow(dead_code)]
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use crate::theme::Palette;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DiffFile {
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffHunk {
pub old_start: u32,
pub old_count: u32,
pub new_start: u32,
pub new_count: u32,
pub section: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineKind {
Context,
Added,
Removed,
NoNewline,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub kind: DiffLineKind,
pub content: String,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
}
pub fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32, String)> {
let line = line.trim();
if !line.starts_with("@@") {
return None;
}
let closing = line[2..].find("@@")?;
let closing_abs = closing + 2;
let inner = line[2..closing_abs].trim(); let section = line[closing_abs + 2..].trim().to_owned();
let mut parts = inner.split_whitespace();
let old_spec = parts.next()?; let new_spec = parts.next()?;
let (old_start, old_count) = parse_range_spec(old_spec, '-')?;
let (new_start, new_count) = parse_range_spec(new_spec, '+')?;
Some((old_start, old_count, new_start, new_count, section))
}
pub fn parse_unified_diff(patch: &str) -> DiffFile {
if patch.is_empty() {
return DiffFile::default();
}
let mut hunks: Vec<DiffHunk> = Vec::new();
let mut old_cursor: u32 = 0;
let mut new_cursor: u32 = 0;
for raw_line in patch.lines() {
if let Some((old_start, old_count, new_start, new_count, section)) =
parse_hunk_header(raw_line)
{
old_cursor = old_start;
new_cursor = new_start;
hunks.push(DiffHunk {
old_start,
old_count,
new_start,
new_count,
section,
lines: Vec::new(),
});
continue;
}
let Some(hunk) = hunks.last_mut() else {
continue;
};
let mut chars = raw_line.chars();
let prefix = chars.next();
let content = chars.as_str().to_owned();
let diff_line = match prefix {
Some(' ') => {
let line = DiffLine {
kind: DiffLineKind::Context,
content,
old_lineno: Some(old_cursor),
new_lineno: Some(new_cursor),
};
old_cursor += 1;
new_cursor += 1;
line
}
Some('+') => {
let line = DiffLine {
kind: DiffLineKind::Added,
content,
old_lineno: None,
new_lineno: Some(new_cursor),
};
new_cursor += 1;
line
}
Some('-') => {
let line = DiffLine {
kind: DiffLineKind::Removed,
content,
old_lineno: Some(old_cursor),
new_lineno: None,
};
old_cursor += 1;
line
}
Some('\\') => {
DiffLine {
kind: DiffLineKind::NoNewline,
content,
old_lineno: None,
new_lineno: None,
}
}
_ => {
DiffLine {
kind: DiffLineKind::Context,
content: raw_line.to_owned(),
old_lineno: Some(old_cursor),
new_lineno: Some(new_cursor),
}
}
};
hunk.lines.push(diff_line);
}
DiffFile { hunks }
}
#[allow(clippy::too_many_lines)]
pub fn render_diff(file: &DiffFile, palette: &Palette) -> Vec<Line<'static>> {
if file.hunks.is_empty() {
return vec![Line::from(Span::styled(
"(no changes to show)",
Style::default().fg(palette.dim),
))];
}
let mut output: Vec<Line<'static>> = Vec::new();
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
if hunk_idx > 0 {
output.push(Line::default());
}
let header_coords = format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
);
let mut header_spans = vec![Span::styled(
header_coords,
Style::default().fg(palette.accent).add_modifier(Modifier::BOLD),
)];
if !hunk.section.is_empty() {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(hunk.section.clone(), Style::default().fg(palette.dim)));
}
output.push(Line::from(header_spans));
for diff_line in &hunk.lines {
let line = render_diff_line(diff_line, palette);
output.push(line);
}
}
output
}
fn parse_range_spec(spec: &str, expected_prefix: char) -> Option<(u32, u32)> {
let spec = spec.strip_prefix(expected_prefix)?;
if let Some((start_str, count_str)) = spec.split_once(',') {
let start = start_str.parse().ok()?;
let count = count_str.parse().ok()?;
Some((start, count))
} else {
let start = spec.parse().ok()?;
Some((start, 1))
}
}
fn format_lineno(lineno: Option<u32>) -> String {
match lineno {
Some(n) => format!("{n:>5}"),
None => " ".to_owned(),
}
}
pub(crate) fn render_diff_line(diff_line: &DiffLine, palette: &Palette) -> Line<'static> {
if diff_line.kind == DiffLineKind::NoNewline {
let text = format!("\\ {}", diff_line.content);
return Line::from(Span::styled(text, Style::default().fg(palette.dim)));
}
let old_str = format_lineno(diff_line.old_lineno);
let new_str = format_lineno(diff_line.new_lineno);
let lineno_style = Style::default().fg(palette.muted);
let (prefix_char, content_style) = match diff_line.kind {
DiffLineKind::Added => ('+', Style::default().fg(palette.git_new)),
DiffLineKind::Removed => ('-', Style::default().fg(palette.danger)),
DiffLineKind::Context => (' ', Style::default().fg(palette.foreground)),
DiffLineKind::NoNewline => unreachable!("NoNewline handled above"),
};
Line::from(vec![
Span::styled(old_str, lineno_style),
Span::styled(" ", lineno_style),
Span::styled(new_str, lineno_style),
Span::styled(" ", lineno_style),
Span::styled(prefix_char.to_string(), content_style),
Span::raw(" "),
Span::styled(diff_line.content.clone(), content_style),
])
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::theme::{Palette, Theme};
fn default_palette() -> Palette {
Palette::from_theme(Theme::Default)
}
#[test]
fn parse_hunk_header_basic() {
let result = parse_hunk_header("@@ -1,5 +1,7 @@");
assert_eq!(result, Some((1, 5, 1, 7, String::new())));
}
#[test]
fn parse_hunk_header_with_section() {
let result = parse_hunk_header("@@ -10,3 +12,3 @@ fn main()");
assert_eq!(result, Some((10, 3, 12, 3, "fn main()".to_owned())));
}
#[test]
fn parse_hunk_header_single_line_counts_default_to_one() {
let result = parse_hunk_header("@@ -1 +1 @@");
assert_eq!(result, Some((1, 1, 1, 1, String::new())));
}
#[test]
fn parse_hunk_header_non_hunk_line_returns_none() {
assert_eq!(parse_hunk_header("--- a/src/lib.rs"), None);
assert_eq!(parse_hunk_header("+++ b/src/lib.rs"), None);
assert_eq!(parse_hunk_header("diff --git a/x b/x"), None);
}
#[test]
fn parse_empty_patch_returns_empty_diffile() {
let file = parse_unified_diff("");
assert_eq!(file, DiffFile::default());
assert!(file.hunks.is_empty());
}
#[test]
fn parse_malformed_lines_before_hunk_are_skipped() {
let patch = "\
diff --git a/x b/x
index abc123..def456 100644
--- a/x
+++ b/x
@@ -1,2 +1,3 @@
context
+added
";
let file = parse_unified_diff(patch);
assert_eq!(file.hunks.len(), 1, "preamble lines must not create a hunk");
assert_eq!(file.hunks[0].lines.len(), 2);
}
#[test]
fn parse_single_hunk_context_add_remove() {
let patch = "\
@@ -5,3 +5,3 @@
context line
+added line
-removed line
";
let file = parse_unified_diff(patch);
assert_eq!(file.hunks.len(), 1);
let lines = &file.hunks[0].lines;
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].kind, DiffLineKind::Context);
assert_eq!(lines[0].old_lineno, Some(5));
assert_eq!(lines[0].new_lineno, Some(5));
assert_eq!(lines[1].kind, DiffLineKind::Added);
assert_eq!(lines[1].old_lineno, None);
assert_eq!(lines[1].new_lineno, Some(6));
assert_eq!(lines[2].kind, DiffLineKind::Removed);
assert_eq!(lines[2].old_lineno, Some(6));
assert_eq!(lines[2].new_lineno, None);
}
#[test]
fn parse_multi_hunk() {
let patch = "\
@@ -1,2 +1,3 @@
context
+added
@@ -10,2 +11,2 @@
-removed
context
";
let file = parse_unified_diff(patch);
assert_eq!(file.hunks.len(), 2, "two hunk headers should produce two hunks");
assert_eq!(file.hunks[0].lines.len(), 2);
assert_eq!(file.hunks[1].lines.len(), 2);
assert_eq!(file.hunks[1].old_start, 10);
assert_eq!(file.hunks[1].new_start, 11);
}
#[test]
fn parse_no_newline_at_eof_marker() {
let patch = "\
@@ -1 +1 @@
-old content
\\ No newline at end of file
+new content
\\ No newline at end of file
";
let file = parse_unified_diff(patch);
assert_eq!(file.hunks.len(), 1);
let lines = &file.hunks[0].lines;
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].kind, DiffLineKind::Removed);
let no_nl = &lines[1];
assert_eq!(no_nl.kind, DiffLineKind::NoNewline);
assert_eq!(no_nl.old_lineno, None);
assert_eq!(no_nl.new_lineno, None);
assert_eq!(lines[2].kind, DiffLineKind::Added);
assert_eq!(lines[3].kind, DiffLineKind::NoNewline);
}
#[test]
fn render_diff_empty_returns_placeholder() {
let palette = default_palette();
let file = DiffFile::default();
let lines = render_diff(&file, &palette);
assert_eq!(lines.len(), 1);
let first_span = lines[0].spans.first().expect("placeholder line must have a span");
assert!(
first_span.content.contains("no changes to show"),
"placeholder text mismatch: {:?}",
first_span.content
);
}
#[test]
fn render_diff_colours_additions_and_deletions() {
let palette = default_palette();
let patch = "\
@@ -1,2 +1,2 @@
context line
+added line
-removed line
";
let file = parse_unified_diff(patch);
let lines = render_diff(&file, &palette);
let all_fgs: Vec<ratatui::style::Color> =
lines.iter().flat_map(|l| l.spans.iter()).filter_map(|s| s.style.fg).collect();
assert!(
all_fgs.contains(&palette.git_new),
"no span with git_new fg found; fgs = {all_fgs:?}"
);
assert!(
all_fgs.contains(&palette.danger),
"no span with danger fg found; fgs = {all_fgs:?}"
);
assert!(
all_fgs.contains(&palette.foreground),
"no span with foreground fg found; fgs = {all_fgs:?}"
);
}
#[test]
fn render_diff_hunk_header_styled_in_accent_bold() {
let palette = default_palette();
let patch = "@@ -1,1 +1,1 @@\n context\n";
let file = parse_unified_diff(patch);
let lines = render_diff(&file, &palette);
let header_line = &lines[0];
let first_span = header_line.spans.first().expect("header line must have spans");
assert_eq!(
first_span.style.fg,
Some(palette.accent),
"hunk header first span fg must be accent"
);
assert!(
first_span.style.add_modifier.contains(Modifier::BOLD),
"hunk header first span must be bold"
);
}
#[test]
fn render_diff_section_context_appears_in_dim() {
let palette = default_palette();
let patch = "@@ -1,1 +1,1 @@ fn example()\n context\n";
let file = parse_unified_diff(patch);
let lines = render_diff(&file, &palette);
let header_line = &lines[0];
let section_span = header_line.spans.last().expect("header must have section span");
assert_eq!(
section_span.style.fg,
Some(palette.dim),
"section context span must use palette.dim"
);
assert!(
section_span.content.contains("fn example()"),
"section text not found in header spans"
);
}
#[test]
fn render_diff_blank_line_between_hunks() {
let palette = default_palette();
let patch = "\
@@ -1,1 +1,1 @@
first
@@ -10,1 +10,1 @@
second
";
let file = parse_unified_diff(patch);
assert_eq!(file.hunks.len(), 2);
let lines = render_diff(&file, &palette);
let blank_count = lines.iter().filter(|l| l.spans.is_empty()).count();
assert_eq!(blank_count, 1, "expected exactly one blank separator between hunks");
}
}