Skip to main content

docgen_diff/
block_diff.rs

1//! Markdown block segmentation + block-level diff — a faithful port of
2//! `block-diff.ts`.
3//!
4//! Blocks are segmented by a line classifier (fenced code, tables, headings,
5//! blockquotes, lists, block-level HTML, paragraph runs), then diffed with the
6//! same load-bearing LCS tie-break as `line_diff` (`lcs[i+1][j] >= lcs[i][j+1]`
7//! ⇒ prefer *removed*). Comparison is over *normalized* blocks (whitespace
8//! collapsed) while the emitted `raw` is the un-normalized block from the side
9//! that owns it. `html` is left empty here and filled later by the orchestrator
10//! via `docgen-core::markdown::render_markdown`.
11
12use 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
25/// Split a markdown document into its top-level blocks, preserving fenced-code
26/// and table blocks verbatim. Frontmatter and `<script>` blocks are stripped
27/// first.
28pub 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        // Fenced code block.
46        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        // Table block.
60        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        // ATX heading.
70        if heading_re().is_match(line) {
71            blocks.push(line.trim_end().to_string());
72            index += 1;
73            continue;
74        }
75
76        // Blockquote.
77        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        // List (continuation: blank line, list item, or indented continuation).
87        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        // Block-level HTML.
100        if html_block_re().is_match(line) {
101            blocks.push(line.trim_end().to_string());
102            index += 1;
103            continue;
104        }
105
106        // Paragraph run (until blank line).
107        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
121/// Strip a leading frontmatter block and all `<script>…</script>` blocks,
122/// then trim. Mirrors the original's three non-greedy regex replacements.
123pub 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
129/// Build the full block-level diff stream between two markdown documents. Each
130/// `DocDiffBlock` has `html = ""` (filled later by the orchestrator).
131pub 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    // JS: /^---[\s\S]*?---\s*/  (non-greedy, anchored at start, single match).
224    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    // JS: /<script[\s\S]*?<\/script>\s*/gi
230    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    // JS: /^\s*<[A-Z][\s\S]*>/
261    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}