1use unicode_normalization::UnicodeNormalization;
23
24use super::Metadata;
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum KeyTemplateError {
29 #[error(
31 "unknown placeholder {{{0}}} in key template; use {{author}}, {{year}}, {{title_word}} or {{safekey}}"
32 )]
33 UnknownPlaceholder(String),
34 #[error("unclosed '{{' in key template {0:?}")]
36 Unclosed(String),
37}
38
39pub fn validate_template(template: &str) -> Result<(), KeyTemplateError> {
46 render_key(template, &Metadata::default(), "x").map(|_| ())
47}
48
49pub fn render_key(template: &str, m: &Metadata, safekey: &str) -> Result<String, KeyTemplateError> {
55 let mut out = String::new();
56 let mut rest = template;
57 while let Some(open) = rest.find('{') {
58 out.push_str(&rest[..open]);
59 let after = &rest[open + 1..];
60 let close = after
61 .find('}')
62 .ok_or_else(|| KeyTemplateError::Unclosed(template.to_string()))?;
63 let value = match &after[..close] {
64 "author" => m
65 .authors
66 .first()
67 .map(|a| fold(family_name(a)))
68 .unwrap_or_default(),
69 "year" => m.year.map(|y| y.to_string()).unwrap_or_default(),
70 "title_word" => first_significant_word(&m.title)
71 .map(fold)
72 .unwrap_or_default(),
73 "safekey" => safekey.to_string(),
74 other => return Err(KeyTemplateError::UnknownPlaceholder(other.to_string())),
75 };
76 out.push_str(&value);
77 rest = &after[close + 1..];
78 }
79 out.push_str(rest);
80 let key = sanitize(&out);
81 Ok(if key.is_empty() {
82 safekey.to_string()
83 } else {
84 key
85 })
86}
87
88fn family_name(author: &str) -> &str {
90 match author.split_once(',') {
91 Some((family, _)) => family.trim(),
92 None => author.split_whitespace().last().unwrap_or(""),
93 }
94}
95
96const STOP_WORDS: &[&str] = &[
100 "a", "an", "the", "of", "on", "in", "for", "to", "and", "with", "from", "at", "by", "via",
101 "der", "die", "das", "des", "dem", "den", "ein", "eine", "einer", "eines", "zur", "zum", "und",
102 "über", "uber", "von", "vom", "mit", "le", "la", "les", "un", "une", "du", "de", "sur", "et",
103];
104
105fn first_significant_word(title: &str) -> Option<&str> {
106 title
109 .split(|c: char| !c.is_alphanumeric() && c != '\u{FFFD}')
110 .filter(|w| !w.is_empty())
111 .find(|w| !STOP_WORDS.contains(&w.to_lowercase().as_str()))
112}
113
114fn fold(s: &str) -> String {
117 let mut out = String::with_capacity(s.len());
118 for c in s.nfd() {
119 match c {
120 'ß' => out.push_str("ss"),
121 'æ' | 'Æ' => out.push_str("ae"),
122 'œ' | 'Œ' => out.push_str("oe"),
123 'ø' | 'Ø' => out.push('o'),
124 'ł' | 'Ł' => out.push('l'),
125 'đ' | 'Đ' | 'ð' | 'Ð' => out.push('d'),
126 'þ' | 'Þ' => out.push_str("th"),
127 'ı' => out.push('i'),
128 c if c.is_ascii_alphanumeric() => out.push(c.to_ascii_lowercase()),
129 _ => {}
130 }
131 }
132 out
133}
134
135#[must_use]
141pub fn is_valid_key(key: &str) -> bool {
142 !key.is_empty() && sanitize(key) == key
143}
144
145fn sanitize(key: &str) -> String {
148 key.chars()
149 .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.' | '+' | '/'))
150 .collect()
151}
152
153pub fn disambiguate(key: String, used: &mut std::collections::HashSet<String>) -> String {
156 if used.insert(key.clone()) {
157 return key;
158 }
159 let mut n = 0usize;
160 loop {
161 let candidate = format!("{key}{}", suffix(n));
162 if used.insert(candidate.clone()) {
163 return candidate;
164 }
165 n += 1;
166 }
167}
168
169fn suffix(mut n: usize) -> String {
171 let mut s = Vec::new();
172 loop {
173 #[allow(clippy::cast_possible_truncation)]
175 s.push(b'a' + (n % 26) as u8);
176 if n < 26 {
177 break;
178 }
179 n = n / 26 - 1;
180 }
181 s.reverse();
182 String::from_utf8(s).unwrap_or_default()
183}
184
185#[cfg(test)]
186#[allow(clippy::expect_used, clippy::unwrap_used)]
187mod tests {
188 use super::*;
189
190 fn meta(title: &str, author: &str, year: Option<i32>) -> Metadata {
191 Metadata {
192 title: title.into(),
193 authors: if author.is_empty() {
194 Vec::new()
195 } else {
196 vec![author.into()]
197 },
198 year,
199 ..Metadata::default()
200 }
201 }
202
203 const T: &str = "{author}{year}{title_word}";
204
205 #[test]
207 fn the_issue_examples_render_as_written_by_hand() {
208 let fock = meta(
209 "Näherungsmethode zur Lösung des quantenmechanischen Mehrkörperproblems",
210 "Fock, V.",
211 Some(1930),
212 );
213 assert_eq!(
214 render_key(T, &fock, "sk").unwrap(),
215 "fock1930naherungsmethode"
216 );
217 let slater = meta("The Theory of Complex Spectra", "Slater, J. C.", Some(1929));
218 assert_eq!(render_key(T, &slater, "sk").unwrap(), "slater1929theory");
219 }
220
221 #[test]
222 fn names_fold_to_ascii_and_given_family_order_is_understood() {
223 let m = meta("Über die Quantenmechanik", "Erwin Schrödinger", Some(1926));
224 assert_eq!(
225 render_key(T, &m, "sk").unwrap(),
226 "schrodinger1926quantenmechanik"
227 );
228 let m = meta("Ørsted's legacy", "Łukasiewicz, J.", None);
229 assert_eq!(render_key(T, &m, "sk").unwrap(), "lukasiewiczorsted");
230 let m = meta("Straße", "Weiß, A.", Some(2001));
231 assert_eq!(
232 render_key("{author}-{title_word}", &m, "sk").unwrap(),
233 "weiss-strasse"
234 );
235 }
236
237 #[test]
238 fn a_replacement_character_does_not_cut_the_title_word_short() {
239 let m = meta(
240 "N\u{FFFD}herungsmethode zur L\u{FFFD}sung",
241 "Fock, V.",
242 Some(1930),
243 );
244 assert_eq!(render_key(T, &m, "sk").unwrap(), "fock1930nherungsmethode");
245 }
246
247 #[test]
248 fn an_empty_render_falls_back_to_the_safekey() {
249 let m = meta("", "", None);
250 assert_eq!(render_key(T, &m, "doi_10.1_x").unwrap(), "doi_10.1_x");
251 assert_eq!(
252 render_key("{safekey}-v2", &m, "doi_10.1_x").unwrap(),
253 "doi_10.1_x-v2"
254 );
255 }
256
257 #[test]
258 fn characters_a_key_cannot_carry_are_dropped() {
259 let m = meta("x", "O'Brien, P.", Some(2000));
260 assert_eq!(
261 render_key("{author} {year},%", &m, "sk").unwrap(),
262 "obrien2000"
263 );
264 }
265
266 #[test]
267 fn a_bad_template_is_refused_up_front() {
268 assert_eq!(
269 validate_template("{author}{yr}"),
270 Err(KeyTemplateError::UnknownPlaceholder("yr".into()))
271 );
272 assert!(matches!(
273 validate_template("{author"),
274 Err(KeyTemplateError::Unclosed(_))
275 ));
276 assert!(validate_template(T).is_ok());
277 }
278
279 #[test]
280 fn an_explicit_key_is_valid_only_if_bibtex_can_carry_it() {
281 assert!(is_valid_key("fock1930"));
282 assert!(is_valid_key("Fock:1930-a"));
283 assert!(!is_valid_key("smith, 2020"));
284 assert!(!is_valid_key("a}b"));
285 assert!(!is_valid_key(""));
286 }
287
288 #[test]
289 fn colliding_keys_get_biblatex_suffixes() {
290 let mut used = std::collections::HashSet::new();
291 assert_eq!(disambiguate("hartree1928".into(), &mut used), "hartree1928");
292 assert_eq!(
293 disambiguate("hartree1928".into(), &mut used),
294 "hartree1928a"
295 );
296 assert_eq!(
297 disambiguate("hartree1928".into(), &mut used),
298 "hartree1928b"
299 );
300 assert_eq!(suffix(25), "z");
301 assert_eq!(suffix(26), "aa");
302 }
303}