use std::collections::HashMap;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use pointbreak::highlight::{
EmphSpan, TokenKind, TokenSpan, attributed_segments, emphasis_file, highlight_file,
};
use pointbreak::model::{DiffFile, DiffRowKind, DiffSnapshot, FileStatus, ReviewHunk, RevisionId};
use pointbreak::session::{
RevisionShowOptions, RevisionShowResult, SnapshotContentState, diffstat_from_files,
show_revision,
};
use super::theme::{self, DiffPalette};
#[derive(Debug, clap::Args)]
pub(super) struct DiffArgs {
#[arg(long, default_value = ".")]
repo: PathBuf,
#[arg(long)]
revision: Option<String>,
#[arg(long)]
stat: bool,
#[arg(long, value_enum, default_value_t = ColorChoice::Auto)]
color: ColorChoice,
#[arg(long)]
theme: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
pub(super) enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
pub(super) fn resolve_color_core(
flag: ColorChoice,
no_color: bool,
force: bool,
stdout_is_tty: bool,
) -> bool {
match flag {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => {
if no_color {
false
} else if force {
true
} else {
stdout_is_tty
}
}
}
}
pub(super) fn resolve_color(flag: ColorChoice) -> bool {
let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
let force = std::env::var("CLICOLOR_FORCE")
.ok()
.is_some_and(|v| v != "0" && !v.is_empty());
resolve_color_core(flag, no_color, force, std::io::stdout().is_terminal())
}
pub(super) fn run(
args: DiffArgs,
stdout: &mut dyn Write,
) -> Result<(), Box<dyn std::error::Error>> {
tracing::debug!(command = "diff", "command_start");
let mut options = RevisionShowOptions::new(&args.repo).with_read_for_display(true);
if let Some(revision) = &args.revision {
let ids = crate::cli::id_resolver::IdResolver::new(&args.repo);
options = options.with_revision_id(RevisionId::new(ids.rev(revision)?));
}
let result: RevisionShowResult = show_revision(options)?;
let lane: Option<ColorLane> = if resolve_color(args.color) && !args.stat {
Some(match color_depth() {
ColorDepth::Named => ColorLane::Named,
ColorDepth::Truecolor => {
let selection = theme::theme_selection_from_env(args.theme.as_deref());
let gate = theme::detection_allowed(
true, std::io::stdout().is_terminal(),
true, );
let choice = theme::resolve_truecolor_palette(&selection, || {
if gate { theme::detect_mode() } else { None }
})?;
if let Some(warning) = &choice.warning {
eprintln!("warning: {warning}");
}
ColorLane::Truecolor(Box::new(choice.palette))
}
})
} else {
None
};
write_all_filtered(stdout, &render_output(&args, &result, lane.as_ref()))
}
fn render_output(args: &DiffArgs, result: &RevisionShowResult, lane: Option<&ColorLane>) -> String {
if result.snapshot_content_state != SnapshotContentState::Present {
let hash = result
.removed_snapshot_content_hash
.as_deref()
.unwrap_or("");
return format!(
"captured diff content is unavailable ({}); it was removed from this store\n",
crate::cli::output::short_ref(hash)
);
}
if result.snapshot.files.is_empty() {
return "no changes in the captured revision\n".to_string();
}
if args.stat {
return render_stat_table(&result.snapshot.files);
}
let mut out = render_diffstat(&result.snapshot.files);
out.push('\n');
out.push_str(&render_body(&result.snapshot, lane));
out
}
fn render_body(snapshot: &DiffSnapshot, lane: Option<&ColorLane>) -> String {
match lane {
Some(lane) => render_unified_diff_colored(snapshot, lane),
None => render_unified_diff(snapshot),
}
}
fn write_all_filtered(
out: &mut dyn Write,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
match out.write_all(content.as_bytes()).and_then(|()| out.flush()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(e.into()),
}
}
pub(super) fn render_unified_diff(snapshot: &DiffSnapshot) -> String {
let mut out = String::new();
for file in &snapshot.files {
render_file_header(&mut out, file);
push_metadata_rows(&mut out, file);
for hunk in &file.hunks {
push_hunk_header(&mut out, hunk);
for row in &hunk.rows {
out.push(marker(&row.kind));
out.push_str(&row.text);
out.push('\n');
}
}
}
out
}
fn push_metadata_rows(out: &mut String, file: &DiffFile) {
for meta in &file.metadata_rows {
out.push_str(&meta.text);
out.push('\n');
}
}
fn push_hunk_header(out: &mut String, hunk: &ReviewHunk) {
out.push_str(&hunk.header);
if !hunk.header.ends_with('\n') {
out.push('\n');
}
}
fn marker(kind: &DiffRowKind) -> char {
match kind {
DiffRowKind::Added => '+',
DiffRowKind::Removed => '-',
DiffRowKind::Context => ' ',
}
}
const HIGHLIGHT_ROW_CAP: usize = 500;
const SGR_RESET: &str = "\x1b[0m";
const SGR_UNDERLINE: &str = "\x1b[4m";
#[derive(Clone, Copy)]
pub(super) enum ColorDepth {
Truecolor,
Named,
}
fn color_depth() -> ColorDepth {
match std::env::var("COLORTERM").ok().as_deref() {
Some("truecolor") | Some("24bit") => ColorDepth::Truecolor,
_ => ColorDepth::Named,
}
}
pub(super) enum ColorLane {
Named,
Truecolor(Box<DiffPalette>),
}
fn named_sgr_for_kind(kind: TokenKind) -> &'static str {
match kind {
TokenKind::Keyword => "\x1b[35m", TokenKind::String => "\x1b[32m", TokenKind::Comment => "\x1b[90m", TokenKind::Number => "\x1b[36m", TokenKind::Type => "\x1b[33m", TokenKind::Function => "\x1b[34m", TokenKind::Constant => "\x1b[93m", TokenKind::Operator => "\x1b[97m", TokenKind::Punctuation => "\x1b[37m", TokenKind::Variable => "\x1b[97m", TokenKind::Plain => "",
}
}
fn render_row_ansi(
text: &str,
kind: DiffRowKind,
tokens: &[TokenSpan],
emphasis: &[EmphSpan],
lane: &ColorLane,
) -> String {
let emph_sgr = match lane {
ColorLane::Named => SGR_UNDERLINE,
ColorLane::Truecolor(palette) => match kind {
DiffRowKind::Added => palette.emph_add_bg.as_ref(),
DiffRowKind::Removed => palette.emph_del_bg.as_ref(),
DiffRowKind::Context => "",
},
};
let mut out = String::new();
out.push(marker(&kind));
for seg in attributed_segments(text, tokens, emphasis) {
let slice = &text[seg.start..seg.end];
let fg = seg
.kind
.map(|k| match lane {
ColorLane::Named => named_sgr_for_kind(k),
ColorLane::Truecolor(palette) => palette.sgr_for(k),
})
.unwrap_or("");
let emph = if seg.emphasized { emph_sgr } else { "" };
if fg.is_empty() && emph.is_empty() {
out.push_str(slice);
} else {
out.push_str(fg);
out.push_str(emph);
out.push_str(slice);
out.push_str(SGR_RESET);
}
}
out.push('\n');
out
}
pub(super) fn render_unified_diff_colored(snapshot: &DiffSnapshot, lane: &ColorLane) -> String {
let mut out = String::new();
for file in &snapshot.files {
render_file_header(&mut out, file);
push_metadata_rows(&mut out, file);
let total_rows: usize = file.hunks.iter().map(|hunk| hunk.rows.len()).sum();
let (tokens_map, emphasis_map) = if total_rows <= HIGHLIGHT_ROW_CAP {
(highlight_file(file), emphasis_file(file))
} else {
(HashMap::new(), HashMap::new())
};
for (h, hunk) in file.hunks.iter().enumerate() {
push_hunk_header(&mut out, hunk);
for (r, row) in hunk.rows.iter().enumerate() {
let tokens = tokens_map
.get(&(h, r))
.map(Vec::as_slice)
.unwrap_or_default();
let emphasis = emphasis_map
.get(&(h, r))
.map(Vec::as_slice)
.unwrap_or_default();
out.push_str(&render_row_ansi(
&row.text,
row.kind.clone(),
tokens,
emphasis,
lane,
));
}
}
}
out
}
fn render_file_header(out: &mut String, file: &DiffFile) {
let old = file.old_path.as_deref();
let new = file.new_path.as_deref();
let a = old.or(new).unwrap_or("");
let b = new.or(old).unwrap_or("");
out.push_str(&format!("diff --git a/{a} b/{b}\n"));
match file.status {
FileStatus::Renamed => {
if let Some(similarity) = file.similarity {
out.push_str(&format!("similarity index {similarity}%\n"));
}
out.push_str(&format!("rename from {}\n", old.unwrap_or(a)));
out.push_str(&format!("rename to {}\n", new.unwrap_or(b)));
}
FileStatus::Copied => {
if let Some(similarity) = file.similarity {
out.push_str(&format!("similarity index {similarity}%\n"));
}
out.push_str(&format!("copy from {}\n", old.unwrap_or(a)));
out.push_str(&format!("copy to {}\n", new.unwrap_or(b)));
}
_ => {}
}
if let (Some(old_mode), Some(new_mode)) = (&file.old_mode, &file.new_mode)
&& old_mode != new_mode
{
out.push_str(&format!("old mode {old_mode}\n"));
out.push_str(&format!("new mode {new_mode}\n"));
}
if !file.hunks.is_empty() {
let minus = if matches!(file.status, FileStatus::Added) {
"/dev/null".to_string()
} else {
format!("a/{}", old.unwrap_or(a))
};
let plus = if matches!(file.status, FileStatus::Deleted) {
"/dev/null".to_string()
} else {
format!("b/{}", new.unwrap_or(b))
};
out.push_str(&format!("--- {minus}\n"));
out.push_str(&format!("+++ {plus}\n"));
}
}
fn plural(count: usize, singular: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {singular}s")
}
}
pub(super) fn render_diffstat(files: &[DiffFile]) -> String {
let stat = diffstat_from_files(files);
format!(
"{} changed, {}(+), {}(-)\n",
plural(stat.file_count, "file"),
plural(stat.added_lines, "insertion"),
plural(stat.removed_lines, "deletion"),
)
}
pub(super) fn render_stat_table(files: &[DiffFile]) -> String {
let mut out = String::new();
for file in files {
let stat = diffstat_from_files(std::slice::from_ref(file));
let path = file
.new_path
.as_deref()
.or(file.old_path.as_deref())
.unwrap_or("");
out.push_str(&format!(
" {path} | {} +{} -{}\n",
stat.added_lines + stat.removed_lines,
stat.added_lines,
stat.removed_lines,
));
}
out.push_str(&render_diffstat(files));
out
}
#[cfg(test)]
mod tests {
use pointbreak::model::{
DiffRow, FileId, FileMetadataKind, FileMetadataRow, HunkId, ObjectId, ReviewHunk, ReviewId,
};
use super::*;
fn row(kind: DiffRowKind, text: &str) -> DiffRow {
DiffRow {
kind,
old_line: None,
new_line: None,
text: text.to_owned(),
}
}
fn hunk(header: &str, rows: Vec<DiffRow>) -> ReviewHunk {
ReviewHunk {
id: HunkId::new("hunk:test"),
header: header.to_owned(),
old_start: 1,
old_lines: 1,
new_start: 1,
new_lines: 1,
rows,
}
}
fn base_file(status: FileStatus) -> DiffFile {
DiffFile {
id: FileId::new("file:test"),
status,
old_path: None,
new_path: None,
old_mode: None,
new_mode: None,
old_oid: None,
new_oid: None,
similarity: None,
is_binary: false,
is_submodule: false,
is_mode_only: false,
synthetic: false,
metadata_rows: Vec::new(),
hunks: Vec::new(),
}
}
fn modified_file(path: &str, hunks: Vec<ReviewHunk>) -> DiffFile {
DiffFile {
old_path: Some(path.to_owned()),
new_path: Some(path.to_owned()),
hunks,
..base_file(FileStatus::Modified)
}
}
fn added_file(path: &str) -> DiffFile {
DiffFile {
new_path: Some(path.to_owned()),
hunks: vec![hunk(
"@@ -0,0 +1 @@",
vec![row(DiffRowKind::Added, "new content")],
)],
..base_file(FileStatus::Added)
}
}
fn deleted_file(path: &str) -> DiffFile {
DiffFile {
old_path: Some(path.to_owned()),
hunks: vec![hunk(
"@@ -1 +0,0 @@",
vec![row(DiffRowKind::Removed, "old content")],
)],
..base_file(FileStatus::Deleted)
}
}
fn renamed_file(old: &str, new: &str, similarity: u16) -> DiffFile {
DiffFile {
old_path: Some(old.to_owned()),
new_path: Some(new.to_owned()),
similarity: Some(similarity),
..base_file(FileStatus::Renamed)
}
}
fn copied_file(old: &str, new: &str, similarity: u16) -> DiffFile {
DiffFile {
old_path: Some(old.to_owned()),
new_path: Some(new.to_owned()),
similarity: Some(similarity),
..base_file(FileStatus::Copied)
}
}
fn binary_file(path: &str, summary: &str) -> DiffFile {
DiffFile {
old_path: Some(path.to_owned()),
new_path: Some(path.to_owned()),
is_binary: true,
metadata_rows: vec![FileMetadataRow {
kind: FileMetadataKind::BinarySummary,
text: summary.to_owned(),
}],
..base_file(FileStatus::Modified)
}
}
fn snapshot_with(files: Vec<DiffFile>) -> DiffSnapshot {
DiffSnapshot::new(
ReviewId::new("review:test"),
ObjectId::new("obj:test"),
files,
)
}
#[test]
fn renders_a_modified_file_as_unified_diff() {
let snapshot = snapshot_with(vec![modified_file(
"src/lib.rs",
vec![hunk(
"@@ -1,2 +1,2 @@",
vec![
row(DiffRowKind::Context, "unchanged"),
row(DiffRowKind::Removed, "old line"),
row(DiffRowKind::Added, "new line"),
],
)],
)]);
let out = render_unified_diff(&snapshot);
assert!(out.contains("diff --git a/src/lib.rs b/src/lib.rs"));
assert!(out.contains("--- a/src/lib.rs"));
assert!(out.contains("+++ b/src/lib.rs"));
assert!(out.contains("@@ -1,2 +1,2 @@"));
assert!(out.contains("\n-old line"));
assert!(out.contains("\n+new line"));
assert!(out.contains("\n unchanged"));
}
#[test]
fn renders_added_and_deleted_files_with_devnull_headers() {
let added = render_unified_diff(&snapshot_with(vec![added_file("new.txt")]));
assert!(added.contains("--- /dev/null"));
assert!(added.contains("+++ b/new.txt"));
let deleted = render_unified_diff(&snapshot_with(vec![deleted_file("gone.txt")]));
assert!(deleted.contains("--- a/gone.txt"));
assert!(deleted.contains("+++ /dev/null"));
}
#[test]
fn renders_rename_header_from_status_and_paths() {
let out = render_unified_diff(&snapshot_with(vec![renamed_file(
"old/a.rs", "new/a.rs", 98,
)]));
assert!(out.contains("rename from old/a.rs"));
assert!(out.contains("rename to new/a.rs"));
assert!(out.contains("similarity index 98%"));
}
#[test]
fn renders_copy_header_distinct_from_rename() {
let out = render_unified_diff(&snapshot_with(vec![copied_file(
"src/a.rs", "src/b.rs", 95,
)]));
assert!(out.contains("copy from src/a.rs"));
assert!(out.contains("copy to src/b.rs"));
assert!(out.contains("similarity index 95%"));
assert!(!out.contains("rename"));
}
#[test]
fn renders_binary_from_metadata_rows_without_hunks() {
let out = render_unified_diff(&snapshot_with(vec![binary_file(
"img.png",
"Binary files a/img.png and b/img.png differ",
)]));
assert!(out.contains("img.png"));
assert!(out.contains("Binary files"));
assert!(!out.contains("@@"));
assert!(!out.contains("--- ")); }
#[test]
fn empty_snapshot_renders_nothing_substantive() {
assert!(
render_unified_diff(&snapshot_with(vec![]))
.trim()
.is_empty()
);
}
#[test]
fn diffstat_summary_counts_files_and_lines() {
let snapshot = snapshot_with(vec![
modified_file(
"a.rs",
vec![hunk(
"@@ -1 +1,2 @@",
vec![
row(DiffRowKind::Context, "keep"),
row(DiffRowKind::Added, "added one"),
row(DiffRowKind::Added, "added two"),
row(DiffRowKind::Removed, "removed one"),
],
)],
),
added_file("b.txt"),
]);
let summary = render_diffstat(&snapshot.files);
assert!(summary.contains("2 files changed"));
assert!(summary.contains("insertion"));
assert!(summary.contains("(+)"));
assert!(summary.contains("deletion"));
assert!(summary.contains("(-)"));
}
#[test]
fn diffstat_summary_uses_singular_for_one_file() {
let summary = render_diffstat(&snapshot_with(vec![added_file("only.txt")]).files);
assert!(summary.contains("1 file changed"));
assert!(!summary.contains("1 files changed"));
}
#[test]
fn stat_table_lists_each_file_and_summary() {
let snapshot = snapshot_with(vec![modified_file(
"src/lib.rs",
vec![hunk("@@ -1 +1 @@", vec![row(DiffRowKind::Added, "x")])],
)]);
let table = render_stat_table(&snapshot.files);
assert!(table.contains("src/lib.rs"));
assert!(table.contains("1 file changed"));
assert!(!table.contains("@@")); }
#[test]
fn flag_always_and_never_beat_everything() {
assert!(resolve_color_core(ColorChoice::Always, true, false, false));
assert!(!resolve_color_core(ColorChoice::Never, false, true, true));
}
#[test]
fn no_color_beats_clicolor_force_under_auto() {
assert!(!resolve_color_core(ColorChoice::Auto, true, true, true));
}
#[test]
fn clicolor_force_enables_color_when_piped_under_auto() {
assert!(resolve_color_core(ColorChoice::Auto, false, true, false));
}
#[test]
fn auto_falls_through_to_isatty() {
assert!(resolve_color_core(ColorChoice::Auto, false, false, true));
assert!(!resolve_color_core(ColorChoice::Auto, false, false, false));
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
let mut lookahead = chars.clone();
if lookahead.next() == Some('[') {
chars = lookahead;
for cc in chars.by_ref() {
if cc == 'm' {
break;
}
}
continue;
}
}
out.push(c);
}
out
}
#[test]
fn token_kind_maps_to_an_ansi_sgr_sequence() {
let seq = named_sgr_for_kind(TokenKind::Keyword);
assert!(seq.starts_with("\x1b[")); }
#[test]
fn theme_flag_parses_into_diff_args() {
use clap::Parser;
#[derive(clap::Parser)]
struct TestCli {
#[command(flatten)]
args: DiffArgs,
}
let cli = TestCli::parse_from(["test", "--theme", "OneHalfLight"]);
assert_eq!(cli.args.theme.as_deref(), Some("OneHalfLight"));
let cli = TestCli::parse_from(["test"]);
assert!(cli.args.theme.is_none());
}
#[test]
fn render_body_uses_the_given_lane() {
let snapshot = snapshot_with(vec![modified_file(
"src/lib.rs",
vec![hunk(
"@@ -1 +1 @@",
vec![row(DiffRowKind::Added, "let x = 2;")],
)],
)]);
let lane = ColorLane::Truecolor(Box::new(DiffPalette::builtin_light()));
let colored = render_body(&snapshot, Some(&lane));
assert!(colored.contains("\x1b[38;2;122;68;212m")); let plain = render_body(&snapshot, None);
assert!(!plain.contains('\x1b'));
assert_eq!(strip_ansi(&colored), plain);
}
#[test]
fn truecolor_emphasis_paints_background_by_row_kind() {
let palette = Box::new(DiffPalette::builtin_dark());
let emphasis = vec![EmphSpan { start: 0, end: 3 }];
let added = render_row_ansi(
"let x",
DiffRowKind::Added,
&[],
&emphasis,
&ColorLane::Truecolor(Box::new(DiffPalette::builtin_dark())),
);
assert!(added.contains("\x1b[48;2;0;96;0m")); assert!(!added.contains("\x1b[4m")); let removed = render_row_ansi(
"let x",
DiffRowKind::Removed,
&[],
&emphasis,
&ColorLane::Truecolor(palette),
);
assert!(removed.contains("\x1b[48;2;144;16;17m")); }
#[test]
fn named_lane_keeps_underline_emphasis_and_named_fg() {
let tokens = vec![TokenSpan {
start: 0,
end: 3,
kind: TokenKind::Keyword,
}];
let emphasis = vec![EmphSpan { start: 0, end: 3 }];
let out = render_row_ansi(
"let x",
DiffRowKind::Added,
&tokens,
&emphasis,
&ColorLane::Named,
);
assert!(out.contains("\x1b[35m")); assert!(out.contains("\x1b[4m")); assert!(!out.contains("48;2")); }
#[test]
fn truecolor_colored_render_strips_to_the_plain_render() {
let snapshot = snapshot_with(vec![modified_file(
"src/lib.rs",
vec![hunk(
"@@ -1 +1 @@",
vec![
row(DiffRowKind::Removed, "let x = 1;"),
row(DiffRowKind::Added, "let x = 2;"),
],
)],
)]);
let colored = render_unified_diff_colored(
&snapshot,
&ColorLane::Truecolor(Box::new(DiffPalette::builtin_light())),
);
assert_eq!(strip_ansi(&colored), render_unified_diff(&snapshot));
}
#[test]
fn colored_row_wraps_a_token_segment_and_resets() {
let tokens = vec![TokenSpan {
start: 0,
end: 3,
kind: TokenKind::Keyword,
}];
let out = render_row_ansi("let x", DiffRowKind::Added, &tokens, &[], &ColorLane::Named);
assert!(out.starts_with('+')); assert!(out.contains("\x1b[")); assert!(out.contains("\x1b[0m")); assert!(out.ends_with('\n'));
}
struct FailingWriter(std::io::ErrorKind);
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(self.0, "write failed"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::new(self.0, "flush failed"))
}
}
#[test]
fn write_all_filtered_treats_a_broken_pipe_as_a_clean_stop() {
let mut out = FailingWriter(std::io::ErrorKind::BrokenPipe);
assert!(write_all_filtered(&mut out, "diff --git a/x b/x\n").is_ok());
}
#[test]
fn write_all_filtered_propagates_non_pipe_errors() {
let mut out = FailingWriter(std::io::ErrorKind::PermissionDenied);
assert!(write_all_filtered(&mut out, "x").is_err());
}
}