use std::cell::RefCell;
use std::collections::HashSet;
use std::path::{Component, Path};
use indexmap::IndexMap;
use lazy_static::lazy_static;
use mdbook_preprocessor::book::{Book, BookItem, Chapter};
use regex::Regex;
use crate::backend::{BibliographyBackend, CitationContext, CitationVariant};
use crate::config::{CitationSyntax, SortOrder};
use crate::models::BibItem;
use crate::renderer;
static BIB_OUT_FILE: &str = "bibliography";
const ESCAPED_AT_PLACEHOLDER: &str = "\u{E000}MDBIB_ESCAPED_AT\u{E001}";
const CODE_BLOCK_PLACEHOLDER: &str = "\u{E000}MDBIB_CODEBLOCK";
pub const REF_PATTERN: &str = r"(?x) # enable insignificant whitespace mode + in-regex comments
\{\{\s* # placeholder opening parens and whitespace
\#cite # explicitly match #cite (only, not other mdBook helpers like #include, #title)
\s+ # separating whitespace
([a-zA-Z0-9_\-:./@]+) # citation key (capture group 1) - BibLaTeX compliant, allows digit start
\s*\}\} # whitespace and placeholder closing parens";
pub const AT_REF_PATTERN: &str = r##"(@@)([a-zA-Z0-9_\-/@]+(?:[.:][a-zA-Z0-9_\-/@]+)*)"##;
pub const ESCAPED_AT_PATTERN: &str = r"\\@";
pub const PANDOC_SUPPRESS_AUTHOR_PATTERN: &str =
r"\[-@([a-zA-Z_][a-zA-Z0-9_]*(?:[:.#$%&\-+?<>~/][a-zA-Z0-9_]+)*)\]";
pub const PANDOC_BRACKETED_PATTERN: &str =
r"\[@([a-zA-Z_][a-zA-Z0-9_]*(?:[:.#$%&\-+?<>~/][a-zA-Z0-9_]+)*)\]";
pub const PANDOC_CITE_PATTERN: &str =
r"(^|[^\\@\w/])@([a-zA-Z_][a-zA-Z0-9_]*(?:[:.#$%&\-+?<>~/][a-zA-Z0-9_]+)*)";
const FENCED_CODE_PATTERN: &str = r"(?s)```[^\n]*\n.*?```|~~~[^\n]*\n.*?~~~";
const INLINE_CODE_PATTERN: &str = r"`[^`\n]+`";
lazy_static! {
static ref REF_REGEX: Regex = Regex::new(REF_PATTERN).unwrap();
static ref AT_REF_REGEX: Regex = Regex::new(AT_REF_PATTERN).unwrap();
static ref ESCAPED_AT_REGEX: Regex = Regex::new(ESCAPED_AT_PATTERN).unwrap();
static ref PANDOC_SUPPRESS_AUTHOR_REGEX: Regex =
Regex::new(PANDOC_SUPPRESS_AUTHOR_PATTERN).unwrap();
static ref PANDOC_BRACKETED_REGEX: Regex = Regex::new(PANDOC_BRACKETED_PATTERN).unwrap();
static ref PANDOC_CITE_REGEX: Regex = Regex::new(PANDOC_CITE_PATTERN).unwrap();
static ref FENCED_CODE_REGEX: Regex = Regex::new(FENCED_CODE_PATTERN).unwrap();
static ref INLINE_CODE_REGEX: Regex = Regex::new(INLINE_CODE_PATTERN).unwrap();
}
pub struct CitationResult {
pub all_cited: HashSet<String>,
pub per_chapter: IndexMap<String, HashSet<String>>,
}
fn protect_code_blocks(content: &str) -> (String, Vec<String>) {
let mut blocks = Vec::new();
let mut result = content.to_string();
result = FENCED_CODE_REGEX
.replace_all(&result, |caps: ®ex::Captures| {
let block = caps.get(0).unwrap().as_str().to_string();
let idx = blocks.len();
blocks.push(block);
format!("{CODE_BLOCK_PLACEHOLDER}{idx}\u{E001}")
})
.into_owned();
result = INLINE_CODE_REGEX
.replace_all(&result, |caps: ®ex::Captures| {
let block = caps.get(0).unwrap().as_str().to_string();
let idx = blocks.len();
blocks.push(block);
format!("{CODE_BLOCK_PLACEHOLDER}{idx}\u{E001}")
})
.into_owned();
(result, blocks)
}
fn restore_code_blocks(content: &str, blocks: &[String]) -> String {
let mut result = content.to_string();
for (idx, block) in blocks.iter().enumerate() {
let placeholder = format!("{CODE_BLOCK_PLACEHOLDER}{idx}\u{E001}");
result = result.replacen(&placeholder, block, 1);
}
result
}
pub fn expand_cite_references_in_book(
book: &mut Book,
bibliography: &mut IndexMap<String, BibItem>,
backend: &dyn BibliographyBackend,
citation_syntax: &CitationSyntax,
) -> CitationResult {
let mut all_cited = HashSet::new();
let mut per_chapter: IndexMap<String, HashSet<String>> = IndexMap::new();
let mut last_index = 0;
let syntax_info = match citation_syntax {
CitationSyntax::Default => "{{#cite ...}} and @@citation",
CitationSyntax::Pandoc => "{{#cite ...}}, @@citation, @key, [@key], and [-@key]",
};
book.for_each_mut(|section: &mut BookItem| {
if let BookItem::Chapter(ref mut ch) = *section {
if let Some(ref chapter_path) = ch.path {
tracing::debug!(
"Replacing placeholders: {} in {}",
syntax_info,
chapter_path.as_path().display()
);
let mut chapter_cited = HashSet::new();
let new_content = replace_all_placeholders(
ch,
bibliography,
&mut chapter_cited,
backend,
&mut last_index,
citation_syntax,
);
ch.content = new_content;
all_cited.extend(chapter_cited.clone());
per_chapter.insert(chapter_path.display().to_string(), chapter_cited);
}
}
});
CitationResult {
all_cited,
per_chapter,
}
}
pub fn add_bib_at_end_of_chapters(
book: &mut Book,
bibliography: &mut IndexMap<String, BibItem>,
backend: &dyn BibliographyBackend,
chapter_refs_header: &str,
order: SortOrder,
per_chapter_citations: &IndexMap<String, HashSet<String>>,
css_html: &str,
) {
book.for_each_mut(|section: &mut BookItem| {
if let BookItem::Chapter(ref mut ch) = *section {
if let Some(ref chapter_path) = ch.path {
let chapter_key = chapter_path.display().to_string();
let cited = per_chapter_citations
.get(&chapter_key)
.cloned()
.unwrap_or_default();
if cited.is_empty() {
tracing::debug!(
"No citations in chapter {}, skipping bibliography",
chapter_key
);
return;
}
tracing::debug!("Adding bibliography at the end of chapter {}", chapter_key);
tracing::debug!("Refs cited in this chapter: {:?}", cited);
let ch_bib_content_html = renderer::generate_bibliography_html(
bibliography,
&cited,
true,
backend,
order.clone(),
);
let new_content = format!(
"{}\n{}\n{}\n{}",
css_html, ch.content, chapter_refs_header, ch_bib_content_html
);
ch.content = new_content;
}
}
});
}
fn replace_citation_placeholder(
citation_key: &str,
chapter_path: &Path,
bib: &RefCell<&mut IndexMap<String, BibItem>>,
cited_set: &RefCell<&mut HashSet<String>>,
idx: &RefCell<&mut u32>,
backend: &dyn BibliographyBackend,
variant: CitationVariant,
) -> String {
let cite = citation_key.trim();
cited_set.borrow_mut().insert(cite.to_owned());
let mut bib_mut = bib.borrow_mut();
let mut idx_mut = idx.borrow_mut();
if bib_mut.contains_key(cite) {
let path_to_root = breadcrumbs_up_to_root(chapter_path);
let item = bib_mut.get_mut(cite).unwrap();
if item.index.is_none() {
**idx_mut += 1;
item.index = Some(**idx_mut);
}
let context = CitationContext {
bib_page_path: format!("{path_to_root}{BIB_OUT_FILE}.html"),
chapter_path: chapter_path.display().to_string(),
variant,
};
let formatted = backend.format_citation(item, &context).unwrap_or_else(|e| {
tracing::error!("Failed to format citation for '{}': {}", cite, e);
format!("\\[Error formatting {cite}\\]")
});
tracing::debug!(
"Citation replacement ({:?}): '{}' -> '{}'",
variant,
cite,
formatted
);
formatted
} else {
tracing::warn!("Unknown bibliography reference: '{}'", cite);
format!("\\[Unknown bib ref: {cite}\\]")
}
}
pub fn replace_all_placeholders(
chapter: &Chapter,
bibliography: &mut IndexMap<String, BibItem>,
cited: &mut HashSet<String>,
backend: &dyn BibliographyBackend,
last_index: &mut u32,
citation_syntax: &CitationSyntax,
) -> String {
let chapter_path = chapter.path.as_deref().unwrap_or_else(|| Path::new(""));
let bib = RefCell::new(bibliography);
let cited_set = RefCell::new(cited);
let idx = RefCell::new(last_index);
let (mut content, code_blocks) = protect_code_blocks(&chapter.content);
if *citation_syntax == CitationSyntax::Pandoc {
content = ESCAPED_AT_REGEX
.replace_all(&content, ESCAPED_AT_PLACEHOLDER)
.into_owned();
}
content = REF_REGEX
.replace_all(&content, |caps: ®ex::Captures| {
let citation_key = caps.get(1).map(|m| m.as_str()).unwrap_or("");
replace_citation_placeholder(
citation_key,
chapter_path,
&bib,
&cited_set,
&idx,
backend,
CitationVariant::Standard,
)
})
.into_owned();
content = AT_REF_REGEX
.replace_all(&content, |caps: ®ex::Captures| {
let citation_key = caps.get(2).map(|m| m.as_str()).unwrap_or("");
replace_citation_placeholder(
citation_key,
chapter_path,
&bib,
&cited_set,
&idx,
backend,
CitationVariant::Standard,
)
})
.into_owned();
if *citation_syntax == CitationSyntax::Pandoc {
content = PANDOC_SUPPRESS_AUTHOR_REGEX
.replace_all(&content, |caps: ®ex::Captures| {
let citation_key = caps.get(1).map(|m| m.as_str()).unwrap_or("");
replace_citation_placeholder(
citation_key,
chapter_path,
&bib,
&cited_set,
&idx,
backend,
CitationVariant::SuppressAuthor,
)
})
.into_owned();
content = PANDOC_BRACKETED_REGEX
.replace_all(&content, |caps: ®ex::Captures| {
let citation_key = caps.get(1).map(|m| m.as_str()).unwrap_or("");
replace_citation_placeholder(
citation_key,
chapter_path,
&bib,
&cited_set,
&idx,
backend,
CitationVariant::Parenthetical,
)
})
.into_owned();
content = PANDOC_CITE_REGEX
.replace_all(&content, |caps: ®ex::Captures| {
let prefix = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let citation_key = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let replacement = replace_citation_placeholder(
citation_key,
chapter_path,
&bib,
&cited_set,
&idx,
backend,
CitationVariant::AuthorInText,
);
format!("{prefix}{replacement}")
})
.into_owned();
content = content.replace(ESCAPED_AT_PLACEHOLDER, "@");
}
restore_code_blocks(&content, &code_blocks)
}
fn breadcrumbs_up_to_root(source_file: &Path) -> String {
if source_file.as_os_str().is_empty() {
return String::new();
}
let components_count = source_file.components().fold(0, |acc, c| match c {
Component::Normal(_) => acc + 1,
Component::ParentDir => acc - 1,
Component::CurDir => acc,
Component::RootDir | Component::Prefix(_) => panic!(
"mdBook is not supposed to give us absolute paths, only relative from the book root."
),
}) - 1;
let mut to_root = vec![".."; components_count].join("/");
if components_count > 0 {
to_root.push('/');
}
to_root
}