Skip to main content

snapper_fmt/
sdiff.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
5
6use crate::diff::colorize_unified_diff;
7use crate::format::Format;
8use crate::parser::Region;
9use crate::sentence::SentenceSplitter;
10use crate::sentence::unicode::UnicodeSentenceSplitter;
11
12/// Extract all sentences from a document, preserving their order.
13fn extract_sentences(input: &str, format: Format) -> Vec<String> {
14    let parser = crate::parser::parser_for_format(format);
15
16    let splitter = UnicodeSentenceSplitter::new();
17    let regions = parser.parse(input);
18    let mut sentences = Vec::new();
19
20    for region in &regions {
21        match region {
22            Region::Prose(text) => {
23                for s in splitter.split(text) {
24                    if !s.is_empty() {
25                        sentences.push(s);
26                    }
27                }
28            }
29            Region::Structure(s) => {
30                let trimmed = s.trim();
31                if !trimmed.is_empty() {
32                    sentences.push(trimmed.to_string());
33                }
34            }
35            Region::BlankLines(_) => {
36                sentences.push(String::new());
37            }
38            Region::Code {
39                header,
40                body,
41                footer,
42                ..
43            } => {
44                // Treat each non-empty code-block line as a single sentence
45                // for diff purposes; this matches the previous behaviour where
46                // code lines were emitted via `Region::Structure`.
47                for line in header.lines().chain(body.lines()).chain(footer.lines()) {
48                    let trimmed = line.trim();
49                    if !trimmed.is_empty() {
50                        sentences.push(trimmed.to_string());
51                    }
52                }
53            }
54        }
55    }
56
57    sentences
58}
59
60/// Run a sentence-level diff between two files.
61pub fn sentence_diff(
62    old_path: &Path,
63    new_path: &Path,
64    format: Option<Format>,
65    color: bool,
66) -> Result<String> {
67    let old_text = std::fs::read_to_string(old_path)
68        .with_context(|| format!("failed to read {}", old_path.display()))?;
69    let new_text = std::fs::read_to_string(new_path)
70        .with_context(|| format!("failed to read {}", new_path.display()))?;
71
72    let fmt = match format.or_else(|| Format::recognized_from_path(old_path)) {
73        Some(f) => f,
74        None => anyhow::bail!(
75            "{}: not a prose format; pass --format or use .org/.tex/.md/.rst/.txt",
76            old_path.display()
77        ),
78    };
79
80    let old_sentences = extract_sentences(&old_text, fmt);
81    let new_sentences = extract_sentences(&new_text, fmt);
82
83    // Join sentences as lines for diffing
84    let old_lines = old_sentences.join("\n");
85    let new_lines = new_sentences.join("\n");
86
87    let input = InternedInput::new(old_lines.as_str(), new_lines.as_str());
88    let diff = Diff::compute(Algorithm::Histogram, &input);
89
90    let config = UnifiedDiffConfig::default(); // 3 lines context
91    let printer = BasicLineDiffPrinter(&input.interner);
92    let diff_text = diff.unified_diff(&printer, config, &input).to_string();
93
94    if diff_text.is_empty() {
95        return Ok(String::new());
96    }
97
98    let old_name = old_path.display();
99    let new_name = new_path.display();
100    let mut plain = String::new();
101    plain.push_str(&format!("--- a/{old_name}\n"));
102    plain.push_str(&format!("+++ b/{new_name}\n"));
103    plain.push_str(&diff_text);
104    if !diff_text.ends_with('\n') {
105        plain.push('\n');
106    }
107
108    if color {
109        Ok(colorize_unified_diff(&plain))
110    } else {
111        Ok(plain)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn identical_files_produce_empty_diff() {
121        let tmp1 = std::env::temp_dir().join("sdiff_same_a.txt");
122        let tmp2 = std::env::temp_dir().join("sdiff_same_b.txt");
123        std::fs::write(&tmp1, "Hello world. This is a test.\n").unwrap();
124        std::fs::write(&tmp2, "Hello world. This is a test.\n").unwrap();
125        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
126        assert!(result.is_empty());
127        std::fs::remove_file(&tmp1).ok();
128        std::fs::remove_file(&tmp2).ok();
129    }
130
131    #[test]
132    fn changed_sentence_shows_diff() {
133        let tmp1 = std::env::temp_dir().join("sdiff_change_a.txt");
134        let tmp2 = std::env::temp_dir().join("sdiff_change_b.txt");
135        std::fs::write(&tmp1, "Hello world. This is old. Goodbye.\n").unwrap();
136        std::fs::write(&tmp2, "Hello world. This is new. Goodbye.\n").unwrap();
137        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
138        assert!(result.contains("-This is old."));
139        assert!(result.contains("+This is new."));
140        std::fs::remove_file(&tmp1).ok();
141        std::fs::remove_file(&tmp2).ok();
142    }
143
144    #[test]
145    fn reflow_produces_no_diff() {
146        let tmp1 = std::env::temp_dir().join("sdiff_reflow_a.txt");
147        let tmp2 = std::env::temp_dir().join("sdiff_reflow_b.txt");
148        std::fs::write(&tmp1, "Hello world. This is a test. Another sentence.\n").unwrap();
149        std::fs::write(&tmp2, "Hello world.\nThis is a test.\nAnother sentence.\n").unwrap();
150        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
151        assert!(result.is_empty(), "reflow should not produce a diff");
152        std::fs::remove_file(&tmp1).ok();
153        std::fs::remove_file(&tmp2).ok();
154    }
155
156    #[test]
157    fn colored_sentence_diff_contains_ansi() {
158        let tmp1 = std::env::temp_dir().join("sdiff_color_a.txt");
159        let tmp2 = std::env::temp_dir().join("sdiff_color_b.txt");
160        std::fs::write(&tmp1, "Hello world. This is old. Goodbye.\n").unwrap();
161        std::fs::write(&tmp2, "Hello world. This is new. Goodbye.\n").unwrap();
162        let colored = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), true).unwrap();
163        let plain = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
164        assert!(
165            colored.contains("\x1b["),
166            "color=true must emit ANSI: {colored:?}"
167        );
168        assert!(
169            !plain.contains("\x1b["),
170            "color=false must not emit ANSI: {plain:?}"
171        );
172        assert!(colored.contains("\x1b[31m-This is old.\x1b[0m"));
173        assert!(colored.contains("\x1b[32m+This is new.\x1b[0m"));
174        std::fs::remove_file(&tmp1).ok();
175        std::fs::remove_file(&tmp2).ok();
176    }
177}