docgen_diff/
block_diff.rs1use std::sync::OnceLock;
13
14use regex::Regex;
15
16use crate::types::{DocDiffBlock, DocDiffBlockKind};
17
18struct BlockOp {
19 kind: DocDiffBlockKind,
20 raw: String,
21 old_index: Option<usize>,
22 new_index: Option<usize>,
23}
24
25pub fn split_markdown_blocks(markdown: &str) -> Vec<String> {
29 let body = strip_invisible_document_parts(markdown);
30 let lines: Vec<&str> = body.split('\n').collect();
31 let mut blocks: Vec<String> = Vec::new();
32 let mut index = 0usize;
33
34 while index < lines.len() {
35 while index < lines.len() && lines[index].trim().is_empty() {
36 index += 1;
37 }
38 if index >= lines.len() {
39 break;
40 }
41
42 let line = lines[index];
43 let trimmed = line.trim();
44
45 if trimmed.starts_with("```") {
47 let start = index;
48 index += 1;
49 while index < lines.len() && !lines[index].trim().starts_with("```") {
50 index += 1;
51 }
52 if index < lines.len() {
53 index += 1;
54 }
55 blocks.push(trim_block(&lines[start..index]));
56 continue;
57 }
58
59 if trimmed.starts_with('|') {
61 let start = index;
62 while index < lines.len() && lines[index].trim().starts_with('|') {
63 index += 1;
64 }
65 blocks.push(trim_block(&lines[start..index]));
66 continue;
67 }
68
69 if heading_re().is_match(line) {
71 blocks.push(line.trim_end().to_string());
72 index += 1;
73 continue;
74 }
75
76 if blockquote_re().is_match(line) {
78 let start = index;
79 while index < lines.len() && blockquote_re().is_match(lines[index]) {
80 index += 1;
81 }
82 blocks.push(trim_block(&lines[start..index]));
83 continue;
84 }
85
86 if list_start_re().is_match(line) {
88 let start = index;
89 index += 1;
90 while index < lines.len()
91 && (lines[index].trim().is_empty() || list_continue_re().is_match(lines[index]))
92 {
93 index += 1;
94 }
95 blocks.push(trim_block(&lines[start..index]));
96 continue;
97 }
98
99 if html_block_re().is_match(line) {
101 blocks.push(line.trim_end().to_string());
102 index += 1;
103 continue;
104 }
105
106 let start = index;
108 index += 1;
109 while index < lines.len() && !lines[index].trim().is_empty() {
110 index += 1;
111 }
112 blocks.push(trim_block(&lines[start..index]));
113 }
114
115 blocks
116 .into_iter()
117 .filter(|block| !block.trim().is_empty())
118 .collect()
119}
120
121pub fn strip_invisible_document_parts(markdown: &str) -> String {
124 let without_frontmatter = frontmatter_re().replace(markdown, "");
125 let without_scripts = script_re().replace_all(without_frontmatter.as_ref(), "");
126 without_scripts.trim().to_string()
127}
128
129pub fn build_block_diff(old_markdown: &str, new_markdown: &str) -> Vec<DocDiffBlock> {
132 let old_blocks = split_markdown_blocks(old_markdown);
133 let new_blocks = split_markdown_blocks(new_markdown);
134 let ops = build_block_ops(&old_blocks, &new_blocks);
135
136 ops.into_iter()
137 .enumerate()
138 .map(|(index, op)| DocDiffBlock {
139 id: format!("block-{index}"),
140 kind: op.kind,
141 raw: op.raw,
142 html: String::new(),
143 old_index: op.old_index,
144 new_index: op.new_index,
145 })
146 .collect()
147}
148
149fn trim_block(lines: &[&str]) -> String {
150 let joined = lines.join("\n");
151 joined.trim_end().to_string()
152}
153
154fn normalize_block(block: &str) -> String {
155 whitespace_re().replace_all(block.trim(), " ").into_owned()
156}
157
158fn build_block_ops(old_blocks: &[String], new_blocks: &[String]) -> Vec<BlockOp> {
159 let old_norm: Vec<String> = old_blocks.iter().map(|b| normalize_block(b)).collect();
160 let new_norm: Vec<String> = new_blocks.iter().map(|b| normalize_block(b)).collect();
161 let lcs = build_lcs_table(&old_norm, &new_norm);
162
163 let mut ops = Vec::new();
164 let mut old_index = 0usize;
165 let mut new_index = 0usize;
166
167 while old_index < old_blocks.len() || new_index < new_blocks.len() {
168 if old_index < old_blocks.len()
169 && new_index < new_blocks.len()
170 && old_norm[old_index] == new_norm[new_index]
171 {
172 ops.push(BlockOp {
173 kind: DocDiffBlockKind::Context,
174 raw: new_blocks[new_index].clone(),
175 old_index: Some(old_index),
176 new_index: Some(new_index),
177 });
178 old_index += 1;
179 new_index += 1;
180 } else if old_index < old_blocks.len()
181 && (new_index == new_blocks.len()
182 || lcs[old_index + 1][new_index] >= lcs[old_index][new_index + 1])
183 {
184 ops.push(BlockOp {
185 kind: DocDiffBlockKind::Removed,
186 raw: old_blocks[old_index].clone(),
187 old_index: Some(old_index),
188 new_index: None,
189 });
190 old_index += 1;
191 } else {
192 ops.push(BlockOp {
193 kind: DocDiffBlockKind::Added,
194 raw: new_blocks[new_index].clone(),
195 old_index: None,
196 new_index: Some(new_index),
197 });
198 new_index += 1;
199 }
200 }
201
202 ops
203}
204
205fn build_lcs_table(old_blocks: &[String], new_blocks: &[String]) -> Vec<Vec<usize>> {
206 let mut table = vec![vec![0usize; new_blocks.len() + 1]; old_blocks.len() + 1];
207
208 for old_index in (0..old_blocks.len()).rev() {
209 for new_index in (0..new_blocks.len()).rev() {
210 table[old_index][new_index] = if old_blocks[old_index] == new_blocks[new_index] {
211 table[old_index + 1][new_index + 1] + 1
212 } else {
213 table[old_index + 1][new_index].max(table[old_index][new_index + 1])
214 };
215 }
216 }
217
218 table
219}
220
221fn frontmatter_re() -> &'static Regex {
222 static RE: OnceLock<Regex> = OnceLock::new();
223 RE.get_or_init(|| Regex::new(r"(?s)^---.*?---\s*").unwrap())
225}
226
227fn script_re() -> &'static Regex {
228 static RE: OnceLock<Regex> = OnceLock::new();
229 RE.get_or_init(|| Regex::new(r"(?is)<script.*?</script>\s*").unwrap())
231}
232
233fn whitespace_re() -> &'static Regex {
234 static RE: OnceLock<Regex> = OnceLock::new();
235 RE.get_or_init(|| Regex::new(r"\s+").unwrap())
236}
237
238fn heading_re() -> &'static Regex {
239 static RE: OnceLock<Regex> = OnceLock::new();
240 RE.get_or_init(|| Regex::new(r"^(#{1,6})\s+").unwrap())
241}
242
243fn blockquote_re() -> &'static Regex {
244 static RE: OnceLock<Regex> = OnceLock::new();
245 RE.get_or_init(|| Regex::new(r"^>\s?").unwrap())
246}
247
248fn list_start_re() -> &'static Regex {
249 static RE: OnceLock<Regex> = OnceLock::new();
250 RE.get_or_init(|| Regex::new(r"^(\s{0,3}[-*+]\s+|\s{0,3}\d+\.\s+)").unwrap())
251}
252
253fn list_continue_re() -> &'static Regex {
254 static RE: OnceLock<Regex> = OnceLock::new();
255 RE.get_or_init(|| Regex::new(r"^(\s{0,3}[-*+]\s+|\s{0,3}\d+\.\s+|\s{2,}\S)").unwrap())
256}
257
258fn html_block_re() -> &'static Regex {
259 static RE: OnceLock<Regex> = OnceLock::new();
260 RE.get_or_init(|| Regex::new(r"(?s)^\s*<[A-Z].*>").unwrap())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use DocDiffBlockKind::*;
268
269 #[test]
270 fn split_preserves_fenced_code_and_tables() {
271 assert_eq!(
272 split_markdown_blocks(
273 "# Title\n\nPara one.\n\n```rust\nfn main() {}\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n"
274 ),
275 vec![
276 "# Title",
277 "Para one.",
278 "```rust\nfn main() {}\n```",
279 "| A | B |\n| - | - |\n| 1 | 2 |"
280 ]
281 );
282 }
283
284 #[test]
285 fn block_diff_full_stream_added_removed_context() {
286 let blocks = build_block_diff(
287 "# Title\n\nSame paragraph.\n\nOld paragraph.\n\nTail paragraph.\n",
288 "# Title\n\nSame paragraph.\n\nNew paragraph.\n\nTail paragraph.\n",
289 );
290 let proj: Vec<(&DocDiffBlockKind, &str)> =
291 blocks.iter().map(|b| (&b.kind, b.raw.as_str())).collect();
292 assert_eq!(
293 proj,
294 vec![
295 (&Context, "# Title"),
296 (&Context, "Same paragraph."),
297 (&Removed, "Old paragraph."),
298 (&Added, "New paragraph."),
299 (&Context, "Tail paragraph."),
300 ]
301 );
302 }
303
304 #[test]
305 fn block_diff_strips_frontmatter_and_script() {
306 let blocks = build_block_diff(
307 "---\ntitle: Old\n---\n\n<script>const hidden = true;</script>\n\nVisible old.",
308 "---\ntitle: New\n---\n\n<script>const hidden = false;</script>\n\nVisible new.",
309 );
310 let proj: Vec<(&DocDiffBlockKind, &str)> =
311 blocks.iter().map(|b| (&b.kind, b.raw.as_str())).collect();
312 assert_eq!(
313 proj,
314 vec![(&Removed, "Visible old."), (&Added, "Visible new.")]
315 );
316 }
317}