use unicode_normalization::UnicodeNormalization;
use super::Metadata;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum KeyTemplateError {
#[error(
"unknown placeholder {{{0}}} in key template; use {{author}}, {{year}}, {{title_word}} or {{safekey}}"
)]
UnknownPlaceholder(String),
#[error("unclosed '{{' in key template {0:?}")]
Unclosed(String),
}
pub fn validate_template(template: &str) -> Result<(), KeyTemplateError> {
render_key(template, &Metadata::default(), "x").map(|_| ())
}
pub fn render_key(template: &str, m: &Metadata, safekey: &str) -> Result<String, KeyTemplateError> {
let mut out = String::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let close = after
.find('}')
.ok_or_else(|| KeyTemplateError::Unclosed(template.to_string()))?;
let value = match &after[..close] {
"author" => m
.authors
.first()
.map(|a| fold(family_name(a)))
.unwrap_or_default(),
"year" => m.year.map(|y| y.to_string()).unwrap_or_default(),
"title_word" => first_significant_word(&m.title)
.map(fold)
.unwrap_or_default(),
"safekey" => safekey.to_string(),
other => return Err(KeyTemplateError::UnknownPlaceholder(other.to_string())),
};
out.push_str(&value);
rest = &after[close + 1..];
}
out.push_str(rest);
let key = sanitize(&out);
Ok(if key.is_empty() {
safekey.to_string()
} else {
key
})
}
fn family_name(author: &str) -> &str {
match author.split_once(',') {
Some((family, _)) => family.trim(),
None => author.split_whitespace().last().unwrap_or(""),
}
}
const STOP_WORDS: &[&str] = &[
"a", "an", "the", "of", "on", "in", "for", "to", "and", "with", "from", "at", "by", "via",
"der", "die", "das", "des", "dem", "den", "ein", "eine", "einer", "eines", "zur", "zum", "und",
"über", "uber", "von", "vom", "mit", "le", "la", "les", "un", "une", "du", "de", "sur", "et",
];
fn first_significant_word(title: &str) -> Option<&str> {
title
.split(|c: char| !c.is_alphanumeric() && c != '\u{FFFD}')
.filter(|w| !w.is_empty())
.find(|w| !STOP_WORDS.contains(&w.to_lowercase().as_str()))
}
fn fold(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.nfd() {
match c {
'ß' => out.push_str("ss"),
'æ' | 'Æ' => out.push_str("ae"),
'œ' | 'Œ' => out.push_str("oe"),
'ø' | 'Ø' => out.push('o'),
'ł' | 'Ł' => out.push('l'),
'đ' | 'Đ' | 'ð' | 'Ð' => out.push('d'),
'þ' | 'Þ' => out.push_str("th"),
'ı' => out.push('i'),
c if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()),
_ => {}
}
}
out
}
#[must_use]
pub fn is_valid_key(key: &str) -> bool {
!key.is_empty() && sanitize(key) == key
}
fn sanitize(key: &str) -> String {
key.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.' | '+' | '/'))
.collect()
}
pub fn disambiguate(key: String, used: &mut std::collections::HashSet<String>) -> String {
if used.insert(key.clone()) {
return key;
}
let mut n = 0usize;
loop {
let candidate = format!("{key}{}", suffix(n));
if used.insert(candidate.clone()) {
return candidate;
}
n += 1;
}
}
fn suffix(mut n: usize) -> String {
let mut s = Vec::new();
loop {
#[allow(clippy::cast_possible_truncation)]
s.push(b'a' + (n % 26) as u8);
if n < 26 {
break;
}
n = n / 26 - 1;
}
s.reverse();
String::from_utf8(s).unwrap_or_default()
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn meta(title: &str, author: &str, year: Option<i32>) -> Metadata {
Metadata {
title: title.into(),
authors: if author.is_empty() {
Vec::new()
} else {
vec![author.into()]
},
year,
..Metadata::default()
}
}
const T: &str = "{author}{year}{title_word}";
#[test]
fn the_issue_examples_render_as_written_by_hand() {
let fock = meta(
"Näherungsmethode zur Lösung des quantenmechanischen Mehrkörperproblems",
"Fock, V.",
Some(1930),
);
assert_eq!(
render_key(T, &fock, "sk").unwrap(),
"fock1930naherungsmethode"
);
let slater = meta("The Theory of Complex Spectra", "Slater, J. C.", Some(1929));
assert_eq!(render_key(T, &slater, "sk").unwrap(), "slater1929theory");
}
#[test]
fn names_fold_to_ascii_and_given_family_order_is_understood() {
let m = meta("Über die Quantenmechanik", "Erwin Schrödinger", Some(1926));
assert_eq!(
render_key(T, &m, "sk").unwrap(),
"schrodinger1926quantenmechanik"
);
let m = meta("Ørsted's legacy", "Łukasiewicz, J.", None);
assert_eq!(render_key(T, &m, "sk").unwrap(), "lukasiewiczorsted");
let m = meta("Straße", "Weiß, A.", Some(2001));
assert_eq!(
render_key("{author}-{title_word}", &m, "sk").unwrap(),
"weiss-strasse"
);
}
#[test]
fn a_replacement_character_does_not_cut_the_title_word_short() {
let m = meta(
"N\u{FFFD}herungsmethode zur L\u{FFFD}sung",
"Fock, V.",
Some(1930),
);
assert_eq!(render_key(T, &m, "sk").unwrap(), "fock1930nherungsmethode");
}
#[test]
fn an_empty_render_falls_back_to_the_safekey() {
let m = meta("", "", None);
assert_eq!(render_key(T, &m, "doi_10.1_x").unwrap(), "doi_10.1_x");
assert_eq!(
render_key("{safekey}-v2", &m, "doi_10.1_x").unwrap(),
"doi_10.1_x-v2"
);
}
#[test]
fn characters_a_key_cannot_carry_are_dropped() {
let m = meta("x", "O'Brien, P.", Some(2000));
assert_eq!(
render_key("{author} {year},%", &m, "sk").unwrap(),
"obrien2000"
);
}
#[test]
fn a_bad_template_is_refused_up_front() {
assert_eq!(
validate_template("{author}{yr}"),
Err(KeyTemplateError::UnknownPlaceholder("yr".into()))
);
assert!(matches!(
validate_template("{author"),
Err(KeyTemplateError::Unclosed(_))
));
assert!(validate_template(T).is_ok());
}
#[test]
fn an_explicit_key_is_valid_only_if_bibtex_can_carry_it() {
assert!(is_valid_key("fock1930"));
assert!(is_valid_key("Fock:1930-a"));
assert!(!is_valid_key("smith, 2020"));
assert!(!is_valid_key("a}b"));
assert!(!is_valid_key(""));
}
#[test]
fn colliding_keys_get_biblatex_suffixes() {
let mut used = std::collections::HashSet::new();
assert_eq!(disambiguate("hartree1928".into(), &mut used), "hartree1928");
assert_eq!(
disambiguate("hartree1928".into(), &mut used),
"hartree1928a"
);
assert_eq!(
disambiguate("hartree1928".into(), &mut used),
"hartree1928b"
);
assert_eq!(suffix(25), "z");
assert_eq!(suffix(26), "aa");
}
}