use anyhow::anyhow;
use hayagriva::archive::{locales, ArchivedStyle};
use hayagriva::citationberg::{IndependentStyle, Locale, Style};
use hayagriva::{BibliographyDriver, BibliographyRequest, CitationItem, CitationRequest};
use lazy_static::lazy_static;
use mdbook_preprocessor::errors::Result as MdResult;
use regex::Regex;
use crate::models::BibItem;
use super::hayagriva_style::{
detect_style_format, find_style_info, supported_style_aliases, CitationContentType,
CitationFormat, CitationRendering, CitationStyle, DetectedStyleFormat, StyleInfo,
};
use super::{BibliographyBackend, CitationContext, CitationVariant};
lazy_static! {
static ref ANSI_REGEX: Regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
}
pub struct CslBackend {
#[allow(dead_code)]
style_name: String,
style: IndependentStyle,
locales: Vec<Locale>,
style_info: Option<&'static StyleInfo>,
detected_format: DetectedStyleFormat,
}
impl CslBackend {
pub fn new(style_name: String) -> anyhow::Result<Self> {
tracing::debug!("Initializing CSL backend with style: {}", style_name);
let style_info = find_style_info(&style_name);
let (style, resolved_info) = Self::load_style(&style_name, style_info)?;
let detected_format = detect_style_format(&style);
if resolved_info.is_none() {
tracing::warn!(
"Style '{}' not in registry, detected format: {:?}",
style_name,
detected_format.citation_format()
);
}
let locales = locales();
tracing::debug!(
"CSL backend initialized successfully with style '{}'",
style_name
);
Ok(Self {
style_name,
style,
locales,
style_info: resolved_info,
detected_format,
})
}
fn load_style(
style_name: &str,
style_info: Option<&'static StyleInfo>,
) -> anyhow::Result<(IndependentStyle, Option<&'static StyleInfo>)> {
let archived_style = if let Some(info) = style_info {
tracing::debug!("Style '{}' found in registry", style_name);
Some(info.archived)
} else {
ArchivedStyle::by_name(style_name)
};
let archived_style = archived_style.ok_or_else(|| {
let aliases: Vec<_> = supported_style_aliases().collect();
anyhow!(
"Style '{style_name}' not found. Custom .csl files not yet supported.\n\
Supported aliases: {}\n\
Full list: https://github.com/typst/hayagriva (use ArchivedStyle names)",
aliases.join(", ")
)
})?;
tracing::debug!("Loading bundled CSL style: {:?}", archived_style);
let style = archived_style.get();
match style {
Style::Independent(independent) => Ok((independent, style_info)),
Style::Dependent(_) => Err(anyhow!(
"Style '{style_name}' is a dependent style. Please use an independent style instead."
)),
}
}
fn citation_format(&self) -> CitationFormat {
match &self.style_info {
Some(info) => info.citation_format(),
None => self.detected_format.citation_format(),
}
}
fn get_hayagriva_citation_text(&self, item: &BibItem, fallback: &str) -> MdResult<String> {
let entry = item.hayagriva_entry.as_ref().ok_or_else(|| {
anyhow!(
"BibItem '{}' missing hayagriva_entry for CSL rendering",
item.citation_key
)
})?;
let mut driver = BibliographyDriver::new();
let citation_item = CitationItem::with_entry(entry.as_ref());
let citation_request =
CitationRequest::from_items(vec![citation_item], &self.style, &self.locales);
driver.citation(citation_request);
let bib_request = BibliographyRequest::new(&self.style, None, &self.locales);
let rendered = driver.finish(bib_request);
let citation_text = match rendered.citations.first() {
Some(c) => c.citation.to_string(),
None => {
tracing::warn!(
"Hayagriva returned no citation for '{}', using fallback",
item.citation_key
);
fallback.to_string()
}
};
Ok(Self::strip_ansi_codes(&citation_text))
}
fn strip_ansi_codes(text: &str) -> String {
let result = ANSI_REGEX.replace_all(text, "");
const BARE_CODES: &[&str] = &[
"[0m", "[1m", "[2m", "[3m", "[4m", "[22m", "[23m", "[24m", ];
let mut result = result.into_owned();
for code in BARE_CODES {
result = result.replace(code, "");
}
result
}
fn format_authors_for_citation(authors: &[Vec<String>]) -> String {
if authors.is_empty() {
return "Unknown".to_string();
}
let last_names: Vec<&str> = authors
.iter()
.filter_map(|name_parts| {
name_parts
.first()
.map(|s| s.as_str())
.filter(|s| !s.is_empty())
})
.collect();
match last_names.len() {
0 => "Unknown".to_string(),
1 => last_names[0].to_string(),
2 => format!("{} & {}", last_names[0], last_names[1]),
_ => format!("{} et al.", last_names[0]),
}
}
fn format_fallback_bibliography(item: &BibItem) -> String {
let mut parts = Vec::new();
if !item.authors.is_empty() {
let author_str: String = item
.authors
.iter()
.map(|name_parts| {
if name_parts.len() >= 2 {
let last = &name_parts[0];
let first = &name_parts[1];
let initial_part = first
.chars()
.next()
.map(|c| format!("{c}."))
.unwrap_or_else(|| first.clone());
format!("{last}, {initial_part}")
} else if !name_parts.is_empty() {
name_parts[0].clone()
} else {
"Unknown".to_string()
}
})
.collect::<Vec<_>>()
.join(" and ");
parts.push(author_str);
}
if !item.title.is_empty() {
parts.push(format!("\"{}.\"", item.title));
}
if let Some(year) = &item.pub_year {
parts.push(format!("{year}."));
}
if parts.is_empty() {
item.citation_key.clone()
} else {
parts.join(" ")
}
}
}
impl BibliographyBackend for CslBackend {
fn format_citation(&self, item: &BibItem, context: &CitationContext) -> MdResult<String> {
let format = self.citation_format();
let link = format!("{}#{}", context.bib_page_path, item.citation_key);
let variant = context.variant;
let linked_citation = match format.content {
CitationContentType::Numeric => {
let content = item.index.unwrap_or(1).to_string();
match format.rendering {
CitationRendering::Superscript => {
format!("<sup><a href=\"{link}\">{content}</a></sup>")
}
CitationRendering::Bracketed => {
format!("[[{content}]({link})]")
}
}
}
CitationContentType::Label => {
let content =
self.get_hayagriva_citation_text(item, &format!("[{}]", item.citation_key))?;
let label = content.trim_matches(&['[', ']'] as &[char]);
format!("[[{label}]({link})]")
}
CitationContentType::AuthorDate => {
let full_citation =
self.get_hayagriva_citation_text(item, &format!("({})", item.citation_key))?;
let full_text = full_citation.trim_matches(&['(', ')'] as &[char]);
match variant {
CitationVariant::Standard | CitationVariant::Parenthetical => {
format!("([{full_text}]({link}))")
}
CitationVariant::AuthorInText => {
let author = Self::format_authors_for_citation(&item.authors);
let year = item.pub_year.as_deref().unwrap_or("n.d.");
format!("{author} ([{year}]({link}))")
}
CitationVariant::SuppressAuthor => {
let year = item.pub_year.as_deref().unwrap_or("n.d.");
format!("([{year}]({link}))")
}
}
}
};
Ok(linked_citation)
}
fn format_reference(&self, item: &BibItem) -> MdResult<String> {
let format = self.citation_format();
let entry = item.hayagriva_entry.as_ref().ok_or_else(|| {
anyhow!(
"BibItem '{}' missing hayagriva_entry for CSL rendering",
item.citation_key
)
})?;
let mut driver = BibliographyDriver::new();
let citation_item = CitationItem::with_entry(entry.as_ref());
let citation_request =
CitationRequest::from_items(vec![citation_item], &self.style, &self.locales);
driver.citation(citation_request);
let bib_request = BibliographyRequest::new(&self.style, None, &self.locales);
let rendered = driver.finish(bib_request);
let bib_html = rendered
.bibliography
.and_then(|bib| bib.items.first().map(|i| i.content.to_string()));
let bib_content = match bib_html {
Some(html) => Self::strip_ansi_codes(&html),
None => Self::format_fallback_bibliography(item),
};
let formatted_entry = match (format.content, format.rendering) {
(CitationContentType::Numeric, CitationRendering::Superscript) => {
let index = item.index.unwrap_or(1);
format!("{index}. {bib_content}")
}
(CitationContentType::Numeric, CitationRendering::Bracketed) => {
let index = item.index.unwrap_or(1);
format!("[{index}] {bib_content}")
}
(CitationContentType::Label, _) => {
let citation_text = rendered
.citations
.first()
.map(|c| c.citation.to_string())
.unwrap_or_else(|| format!("[{}]", item.citation_key));
let clean_label = Self::strip_ansi_codes(&citation_text);
let label = clean_label.trim_matches(&['[', ']'] as &[char]);
format!("[{label}] {bib_content}")
}
(CitationContentType::AuthorDate, _) => {
bib_content
}
};
Ok(format!(
"<div class='csl-entry' id='{}'>{}</div>",
item.citation_key, formatted_entry
))
}
fn name(&self) -> &str {
"CSL"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_csl_backend_creation() {
let backend = CslBackend::new("ieee".to_string());
assert!(
backend.is_ok(),
"Failed to create IEEE backend: {:?}",
backend.err()
);
let backend = backend.unwrap();
assert_eq!(backend.name(), "CSL");
assert_eq!(backend.style_name, "ieee");
}
#[test]
fn test_csl_backend_with_apa_style() {
let backend = CslBackend::new("apa".to_string());
assert!(
backend.is_ok(),
"Failed to create APA backend: {:?}",
backend.err()
);
}
#[test]
fn test_csl_backend_with_chicago() {
let backend = CslBackend::new("chicago-author-date".to_string());
assert!(
backend.is_ok(),
"Failed to create Chicago backend: {:?}",
backend.err()
);
}
#[test]
fn test_csl_backend_with_invalid_style() {
let backend = CslBackend::new("invalid_style_name".to_string());
assert!(backend.is_err(), "Should fail with invalid style name");
}
#[test]
fn test_format_citation_ieee() {
let backend = CslBackend::new("ieee".to_string()).expect("Failed to create backend");
let entry_str = r#"@article{test2024,
author = {Smith, John},
title = {A Test Article},
journal = {Test Journal},
year = {2024},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "test2024".to_string(),
title: "A Test Article".to_string(),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let context = CitationContext {
bib_page_path: "bibliography.html".to_string(),
chapter_path: "chapter1.md".to_string(),
variant: CitationVariant::Standard,
};
let citation = backend.format_citation(&item, &context);
assert!(
citation.is_ok(),
"Citation formatting failed: {:?}",
citation.err()
);
let citation_text = citation.unwrap();
tracing::info!("IEEE citation: {}", citation_text);
assert!(!citation_text.is_empty(), "Citation should not be empty");
}
#[test]
fn test_format_reference_apa() {
let backend = CslBackend::new("apa".to_string()).expect("Failed to create backend");
let entry_str = r#"@article{smith2024,
author = {Smith, John and Doe, Jane},
title = {Research on Bibliography Systems},
journal = {Journal of Documentation},
year = {2024},
volume = {10},
pages = {123-145},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "smith2024".to_string(),
title: "Research on Bibliography Systems".to_string(),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let reference = backend.format_reference(&item);
assert!(
reference.is_ok(),
"Reference formatting failed: {:?}",
reference.err()
);
let ref_text = reference.unwrap();
tracing::info!("APA reference: {}", ref_text);
assert!(
ref_text.contains("class='csl-entry'"),
"Should have CSL entry class"
);
assert!(!ref_text.is_empty(), "Reference should not be empty");
}
#[test]
fn test_format_citation_nature() {
let backend = CslBackend::new("nature".to_string()).expect("Failed to create backend");
let entry_str = r#"@article{watson1953,
author = {Watson, James D. and Crick, Francis H.C.},
title = {Molecular Structure of Nucleic Acids: A Structure for Deoxyribose Nucleic Acid},
journal = {Nature},
year = {1953},
volume = {171},
pages = {737-738},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "watson1953".to_string(),
title: "Molecular Structure of Nucleic Acids".to_string(),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let context = CitationContext {
bib_page_path: "bibliography.html".to_string(),
chapter_path: "chapter1.md".to_string(),
variant: CitationVariant::Standard,
};
let citation = backend.format_citation(&item, &context);
assert!(citation.is_ok(), "Citation formatting failed");
let citation_text = citation.unwrap();
tracing::info!("Nature citation: {}", citation_text);
assert!(!citation_text.is_empty(), "Citation should not be empty");
}
#[test]
fn test_ansi_stripping_debug() {
let backend = CslBackend::new("ieee".to_string()).unwrap();
let entry_str = r#"@article{test2024,
author = {Smith, John},
title = {Test},
journal = {Test Journal},
year = {2024},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let mut driver = BibliographyDriver::new();
let citation_item = CitationItem::with_entry(entry);
let citation_request =
CitationRequest::from_items(vec![citation_item], &backend.style, &backend.locales);
driver.citation(citation_request);
let bib_request = BibliographyRequest::new(&backend.style, None, &backend.locales);
let rendered = driver.finish(bib_request);
if let Some(citation) = rendered.citations.first() {
let raw = citation.citation.to_string();
println!("\n=== RAW OUTPUT ===");
println!("String: {raw:?}");
println!("Bytes: {:?}", raw.as_bytes());
println!("Contains [0m: {}", raw.contains("[0m"));
let stripped = CslBackend::strip_ansi_codes(&raw);
println!("\n=== AFTER STRIPPING ===");
println!("String: {stripped:?}");
println!("Bytes: {:?}", stripped.as_bytes());
assert!(
!stripped.contains("[0m"),
"Stripped output should not contain [0m"
);
assert!(
!stripped.contains("[3m"),
"Stripped output should not contain [3m"
);
assert_eq!(stripped, "[1]", "IEEE citation should be [1]");
}
}
#[test]
fn test_format_citation_output_clean() {
let backend = CslBackend::new("ieee".to_string()).unwrap();
let entry_str = r#"@article{test2024,
author = {Smith, John},
title = {Test},
journal = {Test Journal},
year = {2024},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "test2024".to_string(),
title: "Test".to_string(),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let context = CitationContext {
bib_page_path: "bibliography.html".to_string(),
chapter_path: "chapter1.md".to_string(),
variant: CitationVariant::Standard,
};
let result = backend.format_citation(&item, &context).unwrap();
println!("format_citation result: {result:?}");
assert!(!result.contains("[0m"), "Output should not contain [0m");
assert!(!result.contains("[3m"), "Output should not contain [3m");
assert!(
!result.contains("\x1b"),
"Output should not contain ESC character"
);
}
#[test]
fn test_fallback_style_format_detection() {
let backend = CslBackend::new("annual-reviews".to_string());
assert!(
backend.is_ok(),
"Should load non-registry style via fallback: {:?}",
backend.err()
);
let backend = backend.unwrap();
assert!(
backend.style_info.is_none(),
"Non-registry style should have style_info=None"
);
assert_eq!(
backend.citation_format().content,
CitationContentType::Numeric,
"annual-reviews should be detected as numeric from CSL metadata"
);
}
#[test]
fn test_registry_style_has_style_info() {
let backend = CslBackend::new("ieee".to_string()).unwrap();
assert!(
backend.style_info.is_some(),
"Registry style should have style_info"
);
assert_eq!(
backend.citation_format().content,
CitationContentType::Numeric,
"IEEE should be numeric"
);
}
#[test]
fn test_vancouver_superscript_citation() {
let backend =
CslBackend::new("vancouver-superscript".to_string()).expect("Failed to create backend");
let format = backend.citation_format();
assert_eq!(
format.content,
CitationContentType::Numeric,
"vancouver-superscript should be numeric"
);
assert_eq!(
format.rendering,
CitationRendering::Superscript,
"vancouver-superscript should use superscript"
);
let entry_str = r#"@article{test2024,
author = {Smith, John},
title = {Test Article},
journal = {Test Journal},
year = {2024},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "test2024".to_string(),
title: "Test Article".to_string(),
index: Some(1),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let context = CitationContext {
bib_page_path: "bibliography.html".to_string(),
chapter_path: "chapter1.md".to_string(),
variant: CitationVariant::Standard,
};
let citation = backend.format_citation(&item, &context).unwrap();
assert!(citation.contains("<sup>"), "Should contain superscript tag");
assert!(citation.contains("</sup>"), "Should close superscript tag");
}
#[test]
fn test_alphanumeric_citation() {
let backend =
CslBackend::new("alphanumeric".to_string()).expect("Failed to create backend");
let format = backend.citation_format();
assert_eq!(
format.content,
CitationContentType::Label,
"alphanumeric should be a label style"
);
assert_eq!(
format.rendering,
CitationRendering::Bracketed,
"alphanumeric should be bracketed"
);
let entry_str = r#"@article{smith2024,
author = {Smith, John A.},
title = {Modern Data Analysis},
journal = {Data Science Journal},
year = {2024},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "smith2024".to_string(),
title: "Modern Data Analysis".to_string(),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let context = CitationContext {
bib_page_path: "bibliography.html".to_string(),
chapter_path: "chapter1.md".to_string(),
variant: CitationVariant::Standard,
};
let citation = backend.format_citation(&item, &context).unwrap();
println!("Alphanumeric citation: {citation}");
assert!(
citation.contains("bibliography.html#smith2024"),
"Citation should link to bibliography"
);
assert!(
!citation.contains(">1<"),
"Label style should not use sequential numbers"
);
}
#[test]
fn test_alphanumeric_reference_rendering() {
let backend =
CslBackend::new("alphanumeric".to_string()).expect("Failed to create backend");
let entry_str = r#"@article{smith2024,
author = {Smith, John A.},
title = {Modern Data Analysis},
journal = {Data Science Journal},
year = {2024},
volume = {10},
pages = {1-20},
}"#;
let bibliography = hayagriva::io::from_biblatex_str(entry_str).unwrap();
let entry = bibliography.iter().next().unwrap();
let item = BibItem {
citation_key: "smith2024".to_string(),
title: "Modern Data Analysis".to_string(),
authors: vec![vec!["Smith".to_string(), "John A.".to_string()]],
pub_year: Some("2024".to_string()),
hayagriva_entry: Some(Arc::new(entry.clone())),
..Default::default()
};
let reference = backend.format_reference(&item).unwrap();
println!("Alphanumeric reference: {reference}");
assert!(
reference.contains("[Smi24]"),
"Reference should contain the label [Smi24]"
);
assert!(
reference.contains("id='smith2024'"),
"Reference should have anchor for citation key"
);
assert!(
reference.contains("Smith"),
"Reference should contain author name"
);
assert!(
reference.contains("Modern Data Analysis"),
"Reference should contain title"
);
}
#[test]
fn test_elsevier_vancouver_citation() {
let backend =
CslBackend::new("elsevier-vancouver".to_string()).expect("Failed to create backend");
let format = backend.citation_format();
assert_eq!(
format.content,
CitationContentType::Numeric,
"elsevier-vancouver should be numeric"
);
assert_eq!(
format.rendering,
CitationRendering::Bracketed,
"elsevier-vancouver should be bracketed"
);
}
#[test]
fn test_springer_basic_author_date_citation() {
let backend = CslBackend::new("springer-basic-author-date".to_string())
.expect("Failed to create backend");
let format = backend.citation_format();
assert_eq!(
format.content,
CitationContentType::AuthorDate,
"springer-basic-author-date should be author-date"
);
assert_eq!(
format.rendering,
CitationRendering::Bracketed,
"springer-basic-author-date should be bracketed"
);
}
#[test]
fn test_mla8_citation() {
let backend = CslBackend::new("mla8".to_string()).expect("Failed to create backend");
let format = backend.citation_format();
assert_eq!(
format.content,
CitationContentType::AuthorDate,
"mla8 should be author-date style"
);
assert_eq!(
format.rendering,
CitationRendering::Bracketed,
"mla8 should be bracketed"
);
}
#[test]
fn test_no_duplicate_aliases() {
use std::collections::HashSet;
let mut seen = HashSet::new();
for style in super::super::hayagriva_style::all_registry_styles() {
for alias in style.aliases {
assert!(
seen.insert(*alias),
"Duplicate alias found in registry: '{alias}'"
);
}
}
}
#[test]
fn test_registry_style_count() {
let count = super::super::hayagriva_style::registry_style_count();
assert!(
count >= 19,
"Registry should have at least 19 styles, found {count}"
);
}
#[test]
fn test_format_style_list() {
let list = super::super::hayagriva_style::format_style_list();
assert!(list.contains("ieee"), "Should list ieee");
assert!(list.contains("apa"), "Should list apa");
assert!(list.contains("nature"), "Should list nature");
assert!(list.contains("alphanumeric"), "Should list alphanumeric");
assert!(
list.contains("Numeric styles:"),
"Should have numeric section"
);
assert!(
list.contains("Superscript styles:"),
"Should have superscript section"
);
assert!(list.contains("Label styles:"), "Should have label section");
assert!(
list.contains("Author-date styles:"),
"Should have author-date section"
);
}
#[test]
fn test_format_authors_for_citation_single_author() {
let authors = vec![vec!["Smith".to_string(), "John".to_string()]];
assert_eq!(CslBackend::format_authors_for_citation(&authors), "Smith");
}
#[test]
fn test_format_authors_for_citation_two_authors() {
let authors = vec![
vec!["Smith".to_string(), "John".to_string()],
vec!["Jones".to_string(), "Jane".to_string()],
];
assert_eq!(
CslBackend::format_authors_for_citation(&authors),
"Smith & Jones"
);
}
#[test]
fn test_format_authors_for_citation_three_plus_authors() {
let authors = vec![
vec!["Smith".to_string(), "John".to_string()],
vec!["Jones".to_string(), "Jane".to_string()],
vec!["Brown".to_string(), "Bob".to_string()],
];
assert_eq!(
CslBackend::format_authors_for_citation(&authors),
"Smith et al."
);
}
#[test]
fn test_format_authors_for_citation_empty_authors() {
let authors: Vec<Vec<String>> = vec![];
assert_eq!(CslBackend::format_authors_for_citation(&authors), "Unknown");
}
#[test]
fn test_format_authors_for_citation_empty_name_parts() {
let authors = vec![vec!["".to_string()], vec!["Smith".to_string()]];
assert_eq!(CslBackend::format_authors_for_citation(&authors), "Smith");
}
#[test]
fn test_format_authors_for_citation_all_empty_names() {
let authors = vec![vec!["".to_string()], vec!["".to_string()]];
assert_eq!(CslBackend::format_authors_for_citation(&authors), "Unknown");
}
#[test]
fn test_format_authors_for_citation_empty_vec_in_authors() {
let authors = vec![vec![], vec!["Smith".to_string()]];
assert_eq!(CslBackend::format_authors_for_citation(&authors), "Smith");
}
}