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
16fn 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 ®ions {
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
54pub 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 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(); 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}