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::format::Format;
7use crate::parser::FormatParser;
8use crate::parser::Region;
9use crate::parser::latex::LatexParser;
10use crate::parser::markdown::MarkdownParser;
11use crate::parser::org::OrgParser;
12use crate::parser::plaintext::PlaintextParser;
13use crate::sentence::SentenceSplitter;
14use crate::sentence::unicode::UnicodeSentenceSplitter;
15
16/// Extract all sentences from a document, preserving their order.
17fn extract_sentences(input: &str, format: Format) -> Vec<String> {
18    let parser: Box<dyn FormatParser> = match format {
19        Format::Org => Box::new(OrgParser),
20        Format::Latex => Box::new(LatexParser),
21        Format::Markdown => Box::new(MarkdownParser),
22        Format::Rst => Box::new(crate::parser::rst::RstParser),
23        Format::Plaintext => Box::new(PlaintextParser),
24    };
25
26    let splitter = UnicodeSentenceSplitter::new();
27    let regions = parser.parse(input);
28    let mut sentences = Vec::new();
29
30    for region in &regions {
31        match region {
32            Region::Prose(text) => {
33                for s in splitter.split(text) {
34                    if !s.is_empty() {
35                        sentences.push(s);
36                    }
37                }
38            }
39            Region::Structure(s) => {
40                let trimmed = s.trim();
41                if !trimmed.is_empty() {
42                    sentences.push(trimmed.to_string());
43                }
44            }
45            Region::BlankLines(_) => {
46                sentences.push(String::new());
47            }
48        }
49    }
50
51    sentences
52}
53
54/// Run a sentence-level diff between two files.
55pub fn sentence_diff(
56    old_path: &Path,
57    new_path: &Path,
58    format: Option<Format>,
59    color: bool,
60) -> Result<String> {
61    let old_text = std::fs::read_to_string(old_path)
62        .with_context(|| format!("failed to read {}", old_path.display()))?;
63    let new_text = std::fs::read_to_string(new_path)
64        .with_context(|| format!("failed to read {}", new_path.display()))?;
65
66    let fmt = format.unwrap_or_else(|| Format::from_path(old_path));
67
68    let old_sentences = extract_sentences(&old_text, fmt);
69    let new_sentences = extract_sentences(&new_text, fmt);
70
71    // Join sentences as lines for diffing
72    let old_lines = old_sentences.join("\n");
73    let new_lines = new_sentences.join("\n");
74
75    let input = InternedInput::new(old_lines.as_str(), new_lines.as_str());
76    let diff = Diff::compute(Algorithm::Histogram, &input);
77
78    let config = UnifiedDiffConfig::default(); // 3 lines context
79    let printer = BasicLineDiffPrinter(&input.interner);
80    let diff_text = diff.unified_diff(&printer, config, &input).to_string();
81
82    if diff_text.is_empty() {
83        return Ok(String::new());
84    }
85
86    let mut output = String::new();
87    let old_name = old_path.display();
88    let new_name = new_path.display();
89
90    if color {
91        output.push_str(&format!("\x1b[1m--- a/{old_name}\x1b[0m\n"));
92        output.push_str(&format!("\x1b[1m+++ b/{new_name}\x1b[0m\n"));
93        for line in diff_text.lines() {
94            if line.starts_with("@@") {
95                output.push_str(&format!("\x1b[36m{line}\x1b[0m\n"));
96            } else if line.starts_with('+') {
97                output.push_str(&format!("\x1b[32m{line}\x1b[0m\n"));
98            } else if line.starts_with('-') {
99                output.push_str(&format!("\x1b[31m{line}\x1b[0m\n"));
100            } else {
101                output.push_str(line);
102                output.push('\n');
103            }
104        }
105    } else {
106        output.push_str(&format!("--- a/{old_name}\n"));
107        output.push_str(&format!("+++ b/{new_name}\n"));
108        output.push_str(&diff_text);
109        if !diff_text.ends_with('\n') {
110            output.push('\n');
111        }
112    }
113
114    Ok(output)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn identical_files_produce_empty_diff() {
123        let tmp1 = std::env::temp_dir().join("sdiff_same_a.txt");
124        let tmp2 = std::env::temp_dir().join("sdiff_same_b.txt");
125        std::fs::write(&tmp1, "Hello world. This is a test.\n").unwrap();
126        std::fs::write(&tmp2, "Hello world. This is a test.\n").unwrap();
127        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
128        assert!(result.is_empty());
129        std::fs::remove_file(&tmp1).ok();
130        std::fs::remove_file(&tmp2).ok();
131    }
132
133    #[test]
134    fn changed_sentence_shows_diff() {
135        let tmp1 = std::env::temp_dir().join("sdiff_change_a.txt");
136        let tmp2 = std::env::temp_dir().join("sdiff_change_b.txt");
137        std::fs::write(&tmp1, "Hello world. This is old. Goodbye.\n").unwrap();
138        std::fs::write(&tmp2, "Hello world. This is new. Goodbye.\n").unwrap();
139        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
140        assert!(result.contains("-This is old."));
141        assert!(result.contains("+This is new."));
142        std::fs::remove_file(&tmp1).ok();
143        std::fs::remove_file(&tmp2).ok();
144    }
145
146    #[test]
147    fn reflow_produces_no_diff() {
148        let tmp1 = std::env::temp_dir().join("sdiff_reflow_a.txt");
149        let tmp2 = std::env::temp_dir().join("sdiff_reflow_b.txt");
150        std::fs::write(&tmp1, "Hello world. This is a test. Another sentence.\n").unwrap();
151        std::fs::write(&tmp2, "Hello world.\nThis is a test.\nAnother sentence.\n").unwrap();
152        let result = sentence_diff(&tmp1, &tmp2, Some(Format::Plaintext), false).unwrap();
153        assert!(result.is_empty(), "reflow should not produce a diff");
154        std::fs::remove_file(&tmp1).ok();
155        std::fs::remove_file(&tmp2).ok();
156    }
157}