Skip to main content

doiget_core/store/
citekey.rs

1//! Citation keys from a template (#610).
2//!
3//! `cite` and `bib` key every entry by its safekey (`doi_10.1007_BF01340294`)
4//! unless told otherwise, and that stays the default: it is stable and
5//! collision-free by construction. A project that keeps human keys
6//! (`fock1930naherungsmethode`) asks for a template instead:
7//!
8//! | placeholder | value |
9//! |---|---|
10//! | `{author}` | first author's family name, lower-cased and ASCII-folded |
11//! | `{year}` | the year, or nothing when unknown |
12//! | `{title_word}` | first significant title word, lower-cased and ASCII-folded |
13//! | `{safekey}` | the safekey itself |
14//!
15//! Folding is Unicode NFD with the combining marks dropped (`Näherung` ->
16//! `naherung`), plus the letters NFD does not decompose (`ß` -> `ss`,
17//! `ø` -> `o`, `æ` -> `ae`, ...). "Significant" skips articles and
18//! prepositions in English, German and French. Any character a BibTeX key
19//! cannot carry is dropped. A template that renders empty -- no author, no
20//! year, no title -- falls back to the safekey rather than emitting `@article{,`.
21
22use unicode_normalization::UnicodeNormalization;
23
24use super::Metadata;
25
26/// Why a key template was refused.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum KeyTemplateError {
29    /// A `{name}` that is not one of the placeholders.
30    #[error(
31        "unknown placeholder {{{0}}} in key template; use {{author}}, {{year}}, {{title_word}} or {{safekey}}"
32    )]
33    UnknownPlaceholder(String),
34    /// A `{` without its `}`.
35    #[error("unclosed '{{' in key template {0:?}")]
36    Unclosed(String),
37}
38
39/// Check `template` without rendering it, so a bad `--key-template` or
40/// `[cite] key_template` fails before any network work.
41///
42/// # Errors
43///
44/// As [`render_key`].
45pub fn validate_template(template: &str) -> Result<(), KeyTemplateError> {
46    render_key(template, &Metadata::default(), "x").map(|_| ())
47}
48
49/// The key `template` gives `m`, whose safekey is `safekey`.
50///
51/// # Errors
52///
53/// [`KeyTemplateError`] for an unknown placeholder or an unclosed `{`.
54pub 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
88/// `family` from `Family, Given`, or the last word of `Given Family`.
89fn 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
96/// Words a key should not be built from: articles, and the prepositions and
97/// conjunctions that open titles, in the three languages of most of the
98/// historical physics literature.
99const 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    // U+FFFD stays inside a word: Crossref's damaged `N\u{FFFD}herungsmethode`
107    // (#608) should key as `nherungsmethode`, not `n`.
108    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
114/// Lower-case ASCII: NFD with combining marks dropped, plus the letters NFD
115/// leaves whole.
116fn 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/// Whether `key` is usable as-is as a BibTeX / biblatex citation key: non-empty
136/// and made only of letters, digits and `-_:.+/`. An explicit `--key` is
137/// checked with this and refused otherwise, rather than silently rewritten:
138/// `smith, 2020` would otherwise end the key at the comma and shift every
139/// field after it (review of #622).
140#[must_use]
141pub fn is_valid_key(key: &str) -> bool {
142    !key.is_empty() && sanitize(key) == key
143}
144
145/// Drop what a BibTeX / biblatex key cannot carry. Letters, digits and
146/// `-_:.+/` survive; whitespace, braces, commas, quotes and `%` do not.
147fn sanitize(key: &str) -> String {
148    key.chars()
149        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.' | '+' | '/'))
150        .collect()
151}
152
153/// Suffix a key that already appears in `used` with `a`, `b`, ... (the
154/// biblatex convention for the same author and year), and record it.
155pub 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
169/// `a`..`z`, then `aa`, `ab`, ...
170fn suffix(mut n: usize) -> String {
171    let mut s = Vec::new();
172    loop {
173        // `n % 26` < 26, so the cast cannot truncate.
174        #[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    /// The keys from the issue's own project.
206    #[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}