use std::sync::OnceLock;
use regex::Regex;
use crate::types::{DocDiffBlock, DocDiffBlockKind};
struct BlockOp {
kind: DocDiffBlockKind,
raw: String,
old_index: Option<usize>,
new_index: Option<usize>,
}
pub fn split_markdown_blocks(markdown: &str) -> Vec<String> {
let body = strip_invisible_document_parts(markdown);
let lines: Vec<&str> = body.split('\n').collect();
let mut blocks: Vec<String> = Vec::new();
let mut index = 0usize;
while index < lines.len() {
while index < lines.len() && lines[index].trim().is_empty() {
index += 1;
}
if index >= lines.len() {
break;
}
let line = lines[index];
let trimmed = line.trim();
if trimmed.starts_with("```") {
let start = index;
index += 1;
while index < lines.len() && !lines[index].trim().starts_with("```") {
index += 1;
}
if index < lines.len() {
index += 1;
}
blocks.push(trim_block(&lines[start..index]));
continue;
}
if trimmed.starts_with('|') {
let start = index;
while index < lines.len() && lines[index].trim().starts_with('|') {
index += 1;
}
blocks.push(trim_block(&lines[start..index]));
continue;
}
if heading_re().is_match(line) {
blocks.push(line.trim_end().to_string());
index += 1;
continue;
}
if blockquote_re().is_match(line) {
let start = index;
while index < lines.len() && blockquote_re().is_match(lines[index]) {
index += 1;
}
blocks.push(trim_block(&lines[start..index]));
continue;
}
if list_start_re().is_match(line) {
let start = index;
index += 1;
while index < lines.len()
&& (lines[index].trim().is_empty() || list_continue_re().is_match(lines[index]))
{
index += 1;
}
blocks.push(trim_block(&lines[start..index]));
continue;
}
if html_block_re().is_match(line) {
blocks.push(line.trim_end().to_string());
index += 1;
continue;
}
let start = index;
index += 1;
while index < lines.len() && !lines[index].trim().is_empty() {
index += 1;
}
blocks.push(trim_block(&lines[start..index]));
}
blocks
.into_iter()
.filter(|block| !block.trim().is_empty())
.collect()
}
pub fn strip_invisible_document_parts(markdown: &str) -> String {
let without_frontmatter = frontmatter_re().replace(markdown, "");
let without_scripts = script_re().replace_all(without_frontmatter.as_ref(), "");
without_scripts.trim().to_string()
}
pub fn build_block_diff(old_markdown: &str, new_markdown: &str) -> Vec<DocDiffBlock> {
let old_blocks = split_markdown_blocks(old_markdown);
let new_blocks = split_markdown_blocks(new_markdown);
let ops = build_block_ops(&old_blocks, &new_blocks);
ops.into_iter()
.enumerate()
.map(|(index, op)| DocDiffBlock {
id: format!("block-{index}"),
kind: op.kind,
raw: op.raw,
html: String::new(),
old_index: op.old_index,
new_index: op.new_index,
})
.collect()
}
fn trim_block(lines: &[&str]) -> String {
let joined = lines.join("\n");
joined.trim_end().to_string()
}
fn normalize_block(block: &str) -> String {
whitespace_re().replace_all(block.trim(), " ").into_owned()
}
fn build_block_ops(old_blocks: &[String], new_blocks: &[String]) -> Vec<BlockOp> {
let old_norm: Vec<String> = old_blocks.iter().map(|b| normalize_block(b)).collect();
let new_norm: Vec<String> = new_blocks.iter().map(|b| normalize_block(b)).collect();
let lcs = build_lcs_table(&old_norm, &new_norm);
let mut ops = Vec::new();
let mut old_index = 0usize;
let mut new_index = 0usize;
while old_index < old_blocks.len() || new_index < new_blocks.len() {
if old_index < old_blocks.len()
&& new_index < new_blocks.len()
&& old_norm[old_index] == new_norm[new_index]
{
ops.push(BlockOp {
kind: DocDiffBlockKind::Context,
raw: new_blocks[new_index].clone(),
old_index: Some(old_index),
new_index: Some(new_index),
});
old_index += 1;
new_index += 1;
} else if old_index < old_blocks.len()
&& (new_index == new_blocks.len()
|| lcs[old_index + 1][new_index] >= lcs[old_index][new_index + 1])
{
ops.push(BlockOp {
kind: DocDiffBlockKind::Removed,
raw: old_blocks[old_index].clone(),
old_index: Some(old_index),
new_index: None,
});
old_index += 1;
} else {
ops.push(BlockOp {
kind: DocDiffBlockKind::Added,
raw: new_blocks[new_index].clone(),
old_index: None,
new_index: Some(new_index),
});
new_index += 1;
}
}
ops
}
fn build_lcs_table(old_blocks: &[String], new_blocks: &[String]) -> Vec<Vec<usize>> {
let mut table = vec![vec![0usize; new_blocks.len() + 1]; old_blocks.len() + 1];
for old_index in (0..old_blocks.len()).rev() {
for new_index in (0..new_blocks.len()).rev() {
table[old_index][new_index] = if old_blocks[old_index] == new_blocks[new_index] {
table[old_index + 1][new_index + 1] + 1
} else {
table[old_index + 1][new_index].max(table[old_index][new_index + 1])
};
}
}
table
}
fn frontmatter_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?s)^---.*?---\s*").unwrap())
}
fn script_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?is)<script.*?</script>\s*").unwrap())
}
fn whitespace_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\s+").unwrap())
}
fn heading_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^(#{1,6})\s+").unwrap())
}
fn blockquote_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^>\s?").unwrap())
}
fn list_start_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^(\s{0,3}[-*+]\s+|\s{0,3}\d+\.\s+)").unwrap())
}
fn list_continue_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^(\s{0,3}[-*+]\s+|\s{0,3}\d+\.\s+|\s{2,}\S)").unwrap())
}
fn html_block_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?s)^\s*<[A-Z].*>").unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
use DocDiffBlockKind::*;
#[test]
fn split_preserves_fenced_code_and_tables() {
assert_eq!(
split_markdown_blocks(
"# Title\n\nPara one.\n\n```rust\nfn main() {}\n```\n\n| A | B |\n| - | - |\n| 1 | 2 |\n"
),
vec![
"# Title",
"Para one.",
"```rust\nfn main() {}\n```",
"| A | B |\n| - | - |\n| 1 | 2 |"
]
);
}
#[test]
fn block_diff_full_stream_added_removed_context() {
let blocks = build_block_diff(
"# Title\n\nSame paragraph.\n\nOld paragraph.\n\nTail paragraph.\n",
"# Title\n\nSame paragraph.\n\nNew paragraph.\n\nTail paragraph.\n",
);
let proj: Vec<(&DocDiffBlockKind, &str)> =
blocks.iter().map(|b| (&b.kind, b.raw.as_str())).collect();
assert_eq!(
proj,
vec![
(&Context, "# Title"),
(&Context, "Same paragraph."),
(&Removed, "Old paragraph."),
(&Added, "New paragraph."),
(&Context, "Tail paragraph."),
]
);
}
#[test]
fn block_diff_strips_frontmatter_and_script() {
let blocks = build_block_diff(
"---\ntitle: Old\n---\n\n<script>const hidden = true;</script>\n\nVisible old.",
"---\ntitle: New\n---\n\n<script>const hidden = false;</script>\n\nVisible new.",
);
let proj: Vec<(&DocDiffBlockKind, &str)> =
blocks.iter().map(|b| (&b.kind, b.raw.as_str())).collect();
assert_eq!(
proj,
vec![(&Removed, "Visible old."), (&Added, "Visible new.")]
);
}
}