use std::collections::HashMap;
pub(super) fn generate_anchor_id(heading_text: &str) -> String {
let mut fragment = String::new();
for ch in heading_text.chars() {
if ch.is_alphanumeric() {
fragment.extend(ch.to_lowercase());
} else if ch == '-' || ch == '_' {
fragment.push(ch);
} else if ch.is_whitespace() {
fragment.push('-');
}
}
fragment.trim_matches('-').to_string()
}
pub(super) fn extract_atx_heading(line: &str) -> Option<String> {
if !line.starts_with('#') {
return None;
}
let hash_count = line.chars().take_while(|&c| c == '#').count();
if hash_count == 0 || hash_count > 6 {
return None; }
let rest = &line[hash_count..];
let text = if let Some(stripped) = rest.strip_prefix(' ') {
stripped
} else {
rest
};
let text = text.trim_end_matches(['#', ' ']);
if text.is_empty() {
return None;
}
Some(text.to_string())
}
pub(super) fn extract_heading_anchors(content: &str) -> Vec<String> {
let mut anchors = Vec::new();
let mut anchor_counts: HashMap<String, usize> = HashMap::new();
for line in content.lines() {
let line = line.trim();
if let Some(heading_text) = extract_atx_heading(line) {
let base_anchor = generate_anchor_id(&heading_text);
if !base_anchor.is_empty() {
let count = anchor_counts.entry(base_anchor.clone()).or_insert(0);
let anchor = if *count == 0 {
base_anchor.clone()
} else {
format!("{base_anchor}-{count}")
};
*count += 1;
anchors.push(anchor);
}
}
}
anchors
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_anchor_id_basic() {
assert_eq!(generate_anchor_id("Getting Started"), "getting-started");
assert_eq!(generate_anchor_id("API Reference"), "api-reference");
assert_eq!(generate_anchor_id("C++ & Rust"), "c--rust");
assert_eq!(generate_anchor_id("snake_case-name"), "snake_case-name");
}
#[test]
fn test_extract_atx_heading_variants() {
assert_eq!(
extract_atx_heading("## Getting Started"),
Some("Getting Started".to_string())
);
assert_eq!(
extract_atx_heading("### Closed ###"),
Some("Closed".to_string())
);
assert_eq!(extract_atx_heading("####### Too deep"), None);
assert_eq!(extract_atx_heading("Not a heading"), None);
assert_eq!(extract_atx_heading("## "), None);
}
#[test]
fn test_extract_heading_anchors_dedupes() {
let content = "# Intro\n\n## Setup\n\n## Setup\n\n## Setup\n";
assert_eq!(
extract_heading_anchors(content),
vec!["intro", "setup", "setup-1", "setup-2"]
);
}
}