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