use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Header {
pub text: String,
pub level: u8,
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IncludedFile {
pub path: String,
pub custom_output: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DuplicateAnchor {
pub id: String,
pub first_text: String,
pub first_level: u8,
pub second_text: String,
pub second_level: u8,
}
impl std::fmt::Display for DuplicateAnchor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Duplicate anchor ID '{}' found: '{}' (h{}) and '{}' (h{})",
self.id,
self.first_text,
self.first_level,
self.second_text,
self.second_level
)
}
}
#[derive(Debug, Clone)]
pub struct DuplicateAnchorError {
pub duplicates: Vec<DuplicateAnchor>,
}
impl std::fmt::Display for DuplicateAnchorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Found {} duplicate anchor ID(s):", self.duplicates.len())?;
for dup in &self.duplicates {
writeln!(f, " {dup}")?;
}
Ok(())
}
}
impl std::error::Error for DuplicateAnchorError {}
pub fn deduplicate_anchor_ids(
headers: &mut [Header],
html: &mut String,
) -> Vec<(String, String)> {
use rustc_hash::{FxHashMap, FxHashSet};
let mut used: FxHashSet<String> = FxHashSet::default();
let mut next_suffix: FxHashMap<String, usize> = FxHashMap::default();
let mut mapping: FxHashMap<(String, usize), String> = FxHashMap::default();
let mut header_occ: FxHashMap<String, usize> = FxHashMap::default();
let mut renames = Vec::new();
for header in headers.iter_mut() {
if header.id.is_empty() {
continue;
}
let old = header.id.clone();
let occ = header_occ.get(&old).copied().unwrap_or(0);
header_occ.insert(old.clone(), occ + 1);
if !used.contains(&old) {
used.insert(old.clone());
next_suffix.entry(old.clone()).or_insert(1);
mapping.insert((old.clone(), occ), old);
continue;
}
let mut suffix = next_suffix.get(&old).copied().unwrap_or(1);
let new_id = loop {
let candidate = format!("{old}-{suffix}");
suffix += 1;
if !used.contains(&candidate) {
break candidate;
}
};
next_suffix.insert(old.clone(), suffix);
used.insert(new_id.clone());
mapping.insert((old.clone(), occ), new_id.clone());
renames.push((old, new_id.clone()));
header.id = new_id;
}
let mut html_occ: FxHashMap<String, usize> = FxHashMap::default();
let mut result = String::with_capacity(html.len());
let mut rest = html.as_str();
while let Some(start) = rest.find("id=\"") {
result.push_str(&rest[..start + 4]);
rest = &rest[start + 4..];
let Some(end) = rest.find('"') else {
result.push_str(rest);
rest = "";
break;
};
let old = &rest[..end];
let occ = html_occ.get(old).copied().unwrap_or(0);
html_occ.insert(old.to_owned(), occ + 1);
if let Some(mapped) = mapping.get(&(old.to_owned(), occ)) {
result.push_str(mapped);
} else if used.contains(old) {
let mut suffix = next_suffix.get(old).copied().unwrap_or(1);
let new_id = loop {
let candidate = format!("{old}-{suffix}");
suffix += 1;
if !used.contains(&candidate) {
break candidate;
}
};
next_suffix.insert(old.to_owned(), suffix);
used.insert(new_id.clone());
renames.push((old.to_owned(), new_id.clone()));
result.push_str(&new_id);
} else {
used.insert(old.to_owned());
next_suffix.entry(old.to_owned()).or_insert(1);
result.push_str(old);
}
rest = &rest[end..];
}
result.push_str(rest);
*html = result;
renames
}
pub fn validate_anchor_ids(
headers: &[Header],
) -> Result<(), DuplicateAnchorError> {
use rustc_hash::FxHashMap;
let mut seen: FxHashMap<&str, (&str, u8)> = FxHashMap::default();
let mut duplicates = Vec::new();
for header in headers {
if let Some(&(first_text, first_level)) = seen.get(header.id.as_str()) {
duplicates.push(DuplicateAnchor {
id: header.id.clone(),
first_text: first_text.to_string(),
first_level,
second_text: header.text.clone(),
second_level: header.level,
});
} else {
seen.insert(&header.id, (&header.text, header.level));
}
}
if duplicates.is_empty() {
Ok(())
} else {
Err(DuplicateAnchorError { duplicates })
}
}
pub fn validate_rendered_anchor_ids(
headers: &[Header],
html: &str,
) -> Result<(), DuplicateAnchorError> {
validate_anchor_ids(headers)?;
let mut seen = rustc_hash::FxHashSet::default();
let mut duplicate_ids = Vec::new();
let mut rest = html;
while let Some(start) = rest.find("id=\"") {
rest = &rest[start + 4..];
let Some(end) = rest.find('"') else {
break;
};
let id = &rest[..end];
if !seen.insert(id) && !duplicate_ids.iter().any(|seen| seen == id) {
duplicate_ids.push(id.to_owned());
}
rest = &rest[end + 1..];
}
if duplicate_ids.is_empty() {
return Ok(());
}
let duplicates = duplicate_ids
.into_iter()
.map(|id| {
let heading = headers.iter().find(|header| header.id == id);
let (first_text, first_level) = heading.map_or_else(
|| ("non-heading anchor".to_owned(), 0),
|header| (header.text.clone(), header.level),
);
DuplicateAnchor {
id,
first_text,
first_level,
second_text: "non-heading anchor".to_owned(),
second_level: 0,
}
})
.collect();
Err(DuplicateAnchorError { duplicates })
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MarkdownResult {
pub html: String,
pub headers: Vec<Header>,
pub title: Option<String>,
pub included_files: Vec<IncludedFile>,
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "Fine in tests")]
mod tests {
use super::*;
#[test]
fn test_validate_anchor_ids_unique() {
let headers = vec![
Header {
text: "First".to_string(),
level: 1,
id: "first".to_string(),
},
Header {
text: "Second".to_string(),
level: 2,
id: "second".to_string(),
},
];
assert!(validate_anchor_ids(&headers).is_ok());
}
#[test]
fn test_validate_anchor_ids_duplicate() {
let headers = vec![
Header {
text: "First".to_string(),
level: 1,
id: "same".to_string(),
},
Header {
text: "Second".to_string(),
level: 2,
id: "same".to_string(),
},
];
let err = validate_anchor_ids(&headers).unwrap_err();
assert_eq!(err.duplicates.len(), 1);
assert_eq!(err.duplicates[0].id, "same");
}
#[test]
fn test_validate_anchor_ids_multiple_duplicates() {
let headers = vec![
Header {
text: "A".to_string(),
level: 1,
id: "x".to_string(),
},
Header {
text: "B".to_string(),
level: 2,
id: "x".to_string(),
},
Header {
text: "C".to_string(),
level: 1,
id: "y".to_string(),
},
Header {
text: "D".to_string(),
level: 2,
id: "y".to_string(),
},
];
let err = validate_anchor_ids(&headers).unwrap_err();
assert_eq!(err.duplicates.len(), 2);
}
#[test]
fn test_validate_anchor_ids_empty() {
assert!(validate_anchor_ids(&[]).is_ok());
}
#[test]
fn test_deduplicate_anchor_ids_suffixes() {
let mut headers = vec![
Header {
text: "Inputs".to_string(),
level: 3,
id: "inputs".to_string(),
},
Header {
text: "Inputs".to_string(),
level: 3,
id: "inputs".to_string(),
},
Header {
text: "Type".to_string(),
level: 3,
id: "type".to_string(),
},
];
let mut html = String::from(
"<h3 id=\"inputs\">Inputs</h3><h3 id=\"inputs\">Inputs</h3><h3 \
id=\"type\">Type</h3>",
);
let renames = deduplicate_anchor_ids(&mut headers, &mut html);
assert_eq!(headers[0].id, "inputs");
assert_eq!(headers[1].id, "inputs-1");
assert_eq!(headers[2].id, "type");
assert!(html.contains("id=\"inputs-1\""));
assert_eq!(renames, vec![(
"inputs".to_string(),
"inputs-1".to_string()
)]);
assert!(validate_anchor_ids(&headers).is_ok());
assert!(validate_rendered_anchor_ids(&headers, &html).is_ok());
}
#[test]
fn test_deduplicate_skips_existing_suffixed_id() {
let mut headers = vec![
Header {
text: "A".to_string(),
level: 2,
id: "x".to_string(),
},
Header {
text: "B".to_string(),
level: 2,
id: "x-1".to_string(),
},
Header {
text: "C".to_string(),
level: 2,
id: "x".to_string(),
},
];
let mut html = String::from(
"<h2 id=\"x\">A</h2><h2 id=\"x-1\">B</h2><h2 id=\"x\">C</h2>",
);
deduplicate_anchor_ids(&mut headers, &mut html);
assert_eq!(headers[2].id, "x-2");
assert!(html.contains("id=\"x-2\""));
}
}