Skip to main content

snapper_fmt/
diff.rs

1use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
2
3/// When colored unified-diff output should be emitted.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum ColorMode {
6    /// Color when stdout is an interactive terminal.
7    #[default]
8    Auto,
9    /// Always emit ANSI color codes.
10    Always,
11    /// Never emit ANSI color codes.
12    Never,
13}
14
15impl ColorMode {
16    /// Resolve whether to colorize given a TTY probe (or forced override).
17    pub fn should_colorize(self, is_tty: bool) -> bool {
18        match self {
19            ColorMode::Always => true,
20            ColorMode::Never => false,
21            ColorMode::Auto => is_tty,
22        }
23    }
24}
25
26/// Apply ANSI styling to a unified-diff body (headers, hunks, adds, removals).
27///
28/// Lines that already lack a leading `+`/`-`/`@`/`---`/`+++` marker are left
29/// unchanged. Empty input stays empty.
30pub fn colorize_unified_diff(diff: &str) -> String {
31    if diff.is_empty() {
32        return String::new();
33    }
34    let mut output = String::with_capacity(diff.len() + 32);
35    for line in diff.lines() {
36        if line.starts_with("--- ") || line.starts_with("+++ ") {
37            output.push_str("\x1b[1m");
38            output.push_str(line);
39            output.push_str("\x1b[0m\n");
40        } else if line.starts_with("@@") {
41            output.push_str("\x1b[36m");
42            output.push_str(line);
43            output.push_str("\x1b[0m\n");
44        } else if line.starts_with('+') {
45            output.push_str("\x1b[32m");
46            output.push_str(line);
47            output.push_str("\x1b[0m\n");
48        } else if line.starts_with('-') {
49            output.push_str("\x1b[31m");
50            output.push_str(line);
51            output.push_str("\x1b[0m\n");
52        } else {
53            output.push_str(line);
54            output.push('\n');
55        }
56    }
57    if diff.ends_with('\n') && !output.ends_with('\n') {
58        output.push('\n');
59    }
60    // Preserve trailing newline when present; unified diffs always end with \n
61    // after the loop above. If the input had no trailing newline, trim once.
62    if !diff.ends_with('\n') && output.ends_with('\n') {
63        output.pop();
64    }
65    output
66}
67
68/// Produce a unified diff between original and formatted text using
69/// imara-diff's histogram algorithm (same as git's default).
70///
71/// Returns an empty string if the texts are identical.
72pub fn unified_diff(path: &str, original: &str, formatted: &str) -> String {
73    let input = InternedInput::new(original, formatted);
74    let mut diff = Diff::compute(Algorithm::Histogram, &input);
75    diff.postprocess_lines(&input);
76
77    let mut config = UnifiedDiffConfig::default();
78    config.context_len(3);
79
80    let body = diff
81        .unified_diff(&BasicLineDiffPrinter(&input.interner), config, &input)
82        .to_string();
83
84    if body.is_empty() {
85        return String::new();
86    }
87    format!("--- a/{}\n+++ b/{}\n{}", path, path, body)
88}
89
90/// Print a unified diff to stdout. No-op if texts are identical.
91///
92/// When `color` is true, headers / hunks / additions / removals are ANSI-styled.
93pub fn print_diff(path: &str, original: &str, formatted: &str, color: bool) {
94    let diff = unified_diff(path, original, formatted);
95    if diff.is_empty() {
96        return;
97    }
98    if color {
99        print!("{}", colorize_unified_diff(&diff));
100    } else {
101        print!("{diff}");
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn identical_texts_produce_empty_diff() {
111        let text = "Hello world.\nLine two.\n";
112        assert!(unified_diff("test.md", text, text).is_empty());
113    }
114
115    #[test]
116    fn split_lines_show_correctly() {
117        let old = "First.\nSecond line. Third line.\nFourth.\n";
118        let new = "First.\nSecond line.\nThird line.\nFourth.\n";
119        let diff = unified_diff("test.md", old, new);
120        assert!(diff.contains("-Second line. Third line."));
121        assert!(diff.contains("+Second line."));
122        assert!(diff.contains("+Third line."));
123        assert!(diff.contains(" First."));
124        assert!(diff.contains(" Fourth."));
125    }
126
127    #[test]
128    fn preserves_structural_context() {
129        let old = "# Heading\n\nHello world. This is a test.\n\n# Other\n";
130        let new = "# Heading\n\nHello world.\nThis is a test.\n\n# Other\n";
131        let diff = unified_diff("test.md", old, new);
132        assert!(diff.contains(" # Heading"));
133        assert!(diff.contains("-Hello world. This is a test."));
134        assert!(diff.contains("+Hello world."));
135    }
136
137    #[test]
138    fn file_headers_present() {
139        let diff = unified_diff("foo.org", "a\n", "b\n");
140        assert!(diff.starts_with("--- a/foo.org\n+++ b/foo.org\n"));
141    }
142
143    #[test]
144    fn color_mode_resolution() {
145        assert!(ColorMode::Always.should_colorize(false));
146        assert!(ColorMode::Always.should_colorize(true));
147        assert!(!ColorMode::Never.should_colorize(false));
148        assert!(!ColorMode::Never.should_colorize(true));
149        assert!(!ColorMode::Auto.should_colorize(false));
150        assert!(ColorMode::Auto.should_colorize(true));
151    }
152
153    #[test]
154    fn colorize_adds_ansi_for_headers_hunks_and_changes() {
155        let plain = "--- a/t.md\n+++ b/t.md\n@@ -1 +1 @@\n-old line\n+new line\n context\n";
156        let colored = colorize_unified_diff(plain);
157        assert!(
158            colored.contains("\x1b[1m--- a/t.md\x1b[0m"),
159            "headers should be bold: {colored:?}"
160        );
161        assert!(
162            colored.contains("\x1b[1m+++ b/t.md\x1b[0m"),
163            "headers should be bold: {colored:?}"
164        );
165        assert!(
166            colored.contains("\x1b[36m@@ -1 +1 @@\x1b[0m"),
167            "hunk headers should be cyan: {colored:?}"
168        );
169        assert!(
170            colored.contains("\x1b[31m-old line\x1b[0m"),
171            "removals should be red: {colored:?}"
172        );
173        assert!(
174            colored.contains("\x1b[32m+new line\x1b[0m"),
175            "additions should be green: {colored:?}"
176        );
177        assert!(
178            colored.contains(" context\n"),
179            "context lines stay plain: {colored:?}"
180        );
181        assert!(
182            colored.contains("\x1b["),
183            "colored output must contain ANSI escapes"
184        );
185    }
186
187    #[test]
188    fn colorize_empty_is_empty() {
189        assert!(colorize_unified_diff("").is_empty());
190    }
191
192    #[test]
193    fn plain_unified_diff_has_no_ansi() {
194        let diff = unified_diff("foo.md", "a\n", "b\n");
195        assert!(
196            !diff.contains('\x1b'),
197            "raw unified_diff must not emit ANSI: {diff:?}"
198        );
199        assert!(!colorize_unified_diff(&diff).is_empty());
200    }
201}