mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-14 08:05:40 +00:00
`10.1093/<zwsp>oso/<zwsp>` is exactly sixteen bytes, which is the length of `https://doi.org/`, so testing that prefix against it sliced into the middle of the third zero-width space and brought the process down. Found by running citefind over the corpus: the DOI is printed that way in a real book, because the typesetter marked where the line could break. Both prefix tests — DOI and arXiv — now ask for the head rather than cutting one, so a byte length that is not a character boundary is an answer of no rather than a panic. And an invisible character is not part of an identifier. Zero-width spaces, joiners, the soft hyphen, a byte-order mark left at the front of a fragment: text out of a PDF or an EPUB is full of them, they cannot be seen, and a DOI carrying one does not resolve. They are dropped before anything is read.
128 lines
4.1 KiB
Rust
128 lines
4.1 KiB
Rust
//! arXiv preprint identifiers.
|
|
//!
|
|
//! Two forms, because the scheme changed in 2007. The new one is four digits
|
|
//! of year and month, a dot, and a number: `2401.01234`. The old one is an
|
|
//! archive name, optionally a subject class, a slash, and seven digits:
|
|
//! `math.GT/0309136`.
|
|
//!
|
|
//! Either may carry a version — `v2` — which is part of the identifier: it
|
|
//! names a particular revision, and citing one without it means the latest.
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use fancy_regex::Regex;
|
|
|
|
/// The post-2007 form: `YYMM.NNNNN`, four or five digits after the dot.
|
|
static MODERN: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"(?i)\b(\d{4}\.\d{4,5}(?:v\d+)?)\b").expect("a valid pattern"));
|
|
|
|
/// The pre-2007 form: an archive, an optional subject class, then seven
|
|
/// digits.
|
|
static LEGACY: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"(?i)\b([a-z][a-z-]{1,20}(?:\.[A-Z]{2})?/\d{7}(?:v\d+)?)\b")
|
|
.expect("a valid pattern")
|
|
});
|
|
|
|
/// Read an arXiv identifier out of a string.
|
|
///
|
|
/// The old form keeps the case of its subject class, which is capitalised by
|
|
/// convention and is how the archives are written; the rest is lowercased.
|
|
pub fn parse(text: &str) -> Option<String> {
|
|
let text = text.trim();
|
|
let without = strip_prefix(text);
|
|
|
|
if let Some(found) = LEGACY.captures(without).ok().flatten() {
|
|
let id = found.get(1)?.as_str();
|
|
let (archive, number) = id.split_once('/')?;
|
|
return Some(format!(
|
|
"{}/{}",
|
|
lowercase_archive(archive),
|
|
number.to_lowercase()
|
|
));
|
|
}
|
|
let found = MODERN.captures(without).ok().flatten()?;
|
|
Some(found.get(1)?.as_str().to_lowercase())
|
|
}
|
|
|
|
/// The archive lowercased, but its subject class left as it was written.
|
|
fn lowercase_archive(archive: &str) -> String {
|
|
match archive.split_once('.') {
|
|
Some((name, class)) => format!("{}.{}", name.to_lowercase(), class.to_uppercase()),
|
|
None => archive.to_lowercase(),
|
|
}
|
|
}
|
|
|
|
/// Whatever introduced it.
|
|
fn strip_prefix(text: &str) -> &str {
|
|
for prefix in [
|
|
"https://arxiv.org/abs/",
|
|
"http://arxiv.org/abs/",
|
|
"arxiv:",
|
|
"arxiv ",
|
|
] {
|
|
// See the note in `doi::parse`: a slice at the prefix's byte length
|
|
// can land inside a character the text happens to carry.
|
|
if text
|
|
.get(..prefix.len())
|
|
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
|
|
{
|
|
return text[prefix.len()..].trim();
|
|
}
|
|
}
|
|
text
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_modern_form_reads_as_itself() {
|
|
assert_eq!(parse("2401.01234").as_deref(), Some("2401.01234"));
|
|
// Four digits after the dot before 2015, five after it.
|
|
assert_eq!(parse("0704.0001").as_deref(), Some("0704.0001"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_version_is_part_of_the_identifier() {
|
|
// It names a revision; without it a citation means the latest.
|
|
assert_eq!(parse("2401.01234v2").as_deref(), Some("2401.01234v2"));
|
|
assert_eq!(
|
|
parse("math.GT/0309136v1").as_deref(),
|
|
Some("math.GT/0309136v1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_old_form_keeps_its_subject_class_capitalised() {
|
|
assert_eq!(parse("math.GT/0309136").as_deref(), Some("math.GT/0309136"));
|
|
assert_eq!(parse("MATH.gt/0309136").as_deref(), Some("math.GT/0309136"));
|
|
assert_eq!(parse("hep-th/9901001").as_deref(), Some("hep-th/9901001"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_way_of_introducing_one_gives_the_same_identifier() {
|
|
for text in [
|
|
"arXiv:2401.01234",
|
|
"arxiv:2401.01234",
|
|
"arXiv 2401.01234",
|
|
"https://arxiv.org/abs/2401.01234",
|
|
] {
|
|
assert_eq!(parse(text).as_deref(), Some("2401.01234"), "{text}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn what_is_not_an_arxiv_identifier_is_not_one() {
|
|
for text in ["", "hello", "24.01234", "2401.012", "2401.012345"] {
|
|
assert_eq!(parse(text), None, "{text:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_doi_is_not_mistaken_for_one() {
|
|
// `10.1000/182` has a slash and digits, but not seven of them.
|
|
assert_eq!(parse("10.1000/182"), None);
|
|
}
|
|
}
|