use regex::Regex;
use std::sync::LazyLock;
use turbovault_core::Citation;
use crate::parse_headings;
static CITATION_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?:\[(?P<idx>\d+)\]\s*)?\[(?P<text>[^\[\]]+)\]\((?P<url>[^()\s]+)\)"#).unwrap()
});
pub fn parse_citations(content: &str) -> Vec<Citation> {
if !contains_ascii_ci(content, b"citations") {
return Vec::new();
}
let headings = parse_headings(content);
let Some(start) = headings
.iter()
.find(|h| h.text.trim().eq_ignore_ascii_case("citations"))
else {
return Vec::new();
};
let section_level = start.level;
let start_line = start.position.line; let end_line = headings
.iter()
.filter(|h| h.position.line > start_line && h.level <= section_level)
.map(|h| h.position.line)
.min();
let lines: Vec<&str> = content.lines().collect();
let from = start_line; let to = end_line.map(|l| l - 1).unwrap_or(lines.len());
let mut citations = Vec::new();
for line in lines.iter().take(to).skip(from) {
for caps in CITATION_LINE.captures_iter(line) {
let m = caps.get(0).expect("regex match always has group 0");
if m.start() > 0 && line.as_bytes().get(m.start() - 1) == Some(&b'!') {
continue;
}
let index = caps
.name("idx")
.and_then(|m| m.as_str().parse::<u32>().ok());
let text = caps
.name("text")
.map(|m| m.as_str().trim().to_string())
.unwrap_or_default();
let url = caps
.name("url")
.map(|m| m.as_str().to_string())
.unwrap_or_default();
if !url.is_empty() {
citations.push(Citation { index, text, url });
}
}
}
citations
}
fn contains_ascii_ci(haystack: &str, needle: &[u8]) -> bool {
let h = haystack.as_bytes();
if needle.is_empty() {
return true;
}
if h.len() < needle.len() {
return false;
}
h.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_citations_section() {
assert!(parse_citations("# Notes\n\njust text\n").is_empty());
}
#[test]
fn parses_numbered_citations() {
let body =
"# Schema\n\nstuff\n\n# Citations\n\n[1] [A](https://a.example)\n[2] [B](/refs/b.md)\n";
let cites = parse_citations(body);
assert_eq!(cites.len(), 2);
assert_eq!(cites[0].index, Some(1));
assert_eq!(cites[0].text, "A");
assert_eq!(cites[0].url, "https://a.example");
assert_eq!(cites[1].index, Some(2));
assert_eq!(cites[1].url, "/refs/b.md");
}
#[test]
fn parses_unnumbered_citation() {
let body = "# Citations\n\n[Source](https://src.example)\n";
let cites = parse_citations(body);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].index, None);
assert_eq!(cites[0].text, "Source");
}
#[test]
fn stops_at_next_same_level_heading() {
let body =
"# Citations\n\n[1] [A](https://a.example)\n\n# Other\n\n[2] [B](https://b.example)\n";
let cites = parse_citations(body);
assert_eq!(cites.len(), 1);
assert_eq!(cites[0].url, "https://a.example");
}
#[test]
fn subsection_heading_does_not_end_section() {
let body =
"## Citations\n\n[1] [A](https://a.example)\n\n### Sub\n\n[2] [B](https://b.example)\n";
let cites = parse_citations(body);
assert_eq!(cites.len(), 2);
}
#[test]
fn case_insensitive_heading() {
let body = "# CITATIONS\n\n[1] [A](https://a.example)\n";
assert_eq!(parse_citations(body).len(), 1);
}
}