feat: improvement
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Pulling a citation apart when it was written as prose.
|
||||
//!
|
||||
//! A bibliography assembled by hand tends to collect entries like
|
||||
//!
|
||||
//! ```text
|
||||
//! note: 'Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389'
|
||||
//! ```
|
||||
//!
|
||||
//! which is a complete citation in a field that means "anything else worth
|
||||
//! saying". Nothing can use it: a reading list cannot link the DOI, an export to
|
||||
//! Hayagriva or BibTeX has no journal to put in `parent` or `journal`, and the
|
||||
//! `container`, `volume`, `pages`, and `doi` fields sit empty beside it.
|
||||
//!
|
||||
//! [`parse`] takes such a note apart. What it cannot account for it leaves in
|
||||
//! the note, which is the important half of the contract: a note reading
|
||||
//! `'Bioinformatics 18:440-445. Origin of spaced seeds.'` yields the journal,
|
||||
//! the volume, the pages, and a note that still says where spaced seeds came
|
||||
//! from. Nothing is discarded and nothing is invented — an issue number that was
|
||||
//! never written down stays absent, even when a publisher's DOI happens to
|
||||
//! encode one.
|
||||
|
||||
/// The parts of a citation recovered from a note.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Parsed {
|
||||
/// The journal, proceedings, or book the work appeared in.
|
||||
pub container: Option<String>,
|
||||
/// The volume.
|
||||
pub volume: Option<String>,
|
||||
/// The page range, as `first-last`.
|
||||
pub pages: Option<String>,
|
||||
/// The DOI, bare.
|
||||
pub doi: Option<String>,
|
||||
/// What the note still says after the citation is removed.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Takes a citation apart, leaving the rest of the note alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `note` - the note as written.
|
||||
/// * `year` - the record's year, which is how a trailing year is recognized as
|
||||
/// part of a conference name rather than part of the title of the venue.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parts found. Every field is independently optional: a note that carries
|
||||
/// only a DOI yields only a DOI.
|
||||
pub fn parse(note: &str, year: Option<u32>) -> Parsed {
|
||||
let mut out = Parsed::default();
|
||||
let mut rest = note.trim().to_string();
|
||||
|
||||
if let Some((container, volume, pages, tail)) = citation(&rest) {
|
||||
out.container = Some(container);
|
||||
out.volume = Some(volume);
|
||||
out.pages = Some(pages);
|
||||
rest = tail;
|
||||
}
|
||||
|
||||
if let Some((doi, tail)) = doi(&rest) {
|
||||
out.doi = Some(doi);
|
||||
rest = tail;
|
||||
}
|
||||
|
||||
// A venue with no volume or pages — a conference, usually — is named by the
|
||||
// clause that ends in the year the work was published.
|
||||
if out.container.is_none() {
|
||||
if let Some(y) = year {
|
||||
if let Some((container, tail)) = venue(&rest, y) {
|
||||
out.container = Some(container);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The year belongs to the record, not to the name of the venue.
|
||||
if let (Some(container), Some(y)) = (&out.container, year) {
|
||||
let suffix = format!(" {y}");
|
||||
if let Some(trimmed) = container.strip_suffix(&suffix) {
|
||||
out.container = Some(trimmed.trim_end().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let rest = rest.trim().trim_start_matches('.').trim().to_string();
|
||||
out.note = (!rest.is_empty()).then_some(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Finds `Journal 25:3389-3402` or `Journal 48, 443-453` at the start.
|
||||
///
|
||||
/// The volume is the first digit run that follows a space and is followed by a
|
||||
/// separator and a page range. Requiring the whole shape is what keeps a year in
|
||||
/// a conference name (`Proc. FOCS 2000.`) from being read as a volume.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The container, volume, page range, and whatever followed.
|
||||
fn citation(text: &str) -> Option<(String, String, String, String)> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut at = 0;
|
||||
|
||||
while at < bytes.len() {
|
||||
// A volume follows a space, so that a digit inside a name is not one.
|
||||
if !(bytes[at].is_ascii_digit() && at > 0 && bytes[at - 1] == b' ') {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let volume_start = at;
|
||||
let volume_end = digits(bytes, volume_start);
|
||||
let mut cursor = spaces(bytes, volume_end);
|
||||
|
||||
// The separator between volume and pages is a colon or a comma.
|
||||
if cursor < bytes.len() && (bytes[cursor] == b':' || bytes[cursor] == b',') {
|
||||
cursor = spaces(bytes, cursor + 1);
|
||||
let first_start = cursor;
|
||||
let first_end = digits(bytes, first_start);
|
||||
if first_end > first_start {
|
||||
let dash = text[first_end..]
|
||||
.strip_prefix('-')
|
||||
.or_else(|| text[first_end..].strip_prefix('\u{2013}'));
|
||||
if let Some(after_dash) = dash {
|
||||
let last_offset = text.len() - after_dash.len();
|
||||
let last_end = digits(bytes, last_offset);
|
||||
if last_end > last_offset {
|
||||
let container = text[..volume_start].trim_end_matches([' ', ',']);
|
||||
if !container.is_empty() {
|
||||
return Some((
|
||||
container.to_string(),
|
||||
text[volume_start..volume_end].to_string(),
|
||||
format!(
|
||||
"{}-{}",
|
||||
&text[first_start..first_end],
|
||||
&text[last_offset..last_end]
|
||||
),
|
||||
text[last_end..].to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
at = volume_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Finds a `doi:10.…` anywhere in the text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The DOI and the text with it removed.
|
||||
fn doi(text: &str) -> Option<(String, String)> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let at = lower.find("doi:")?;
|
||||
let after = text[at + 4..].trim_start();
|
||||
let offset = text.len() - after.len();
|
||||
let end = after
|
||||
.find(char::is_whitespace)
|
||||
.map(|n| offset + n)
|
||||
.unwrap_or(text.len());
|
||||
|
||||
let doi = text[offset..end].trim_end_matches('.');
|
||||
if !doi.starts_with("10.") {
|
||||
return None;
|
||||
}
|
||||
let mut remainder = String::from(text[..at].trim_end());
|
||||
let tail = text[end..].trim();
|
||||
if !tail.is_empty() {
|
||||
if !remainder.is_empty() {
|
||||
remainder.push(' ');
|
||||
}
|
||||
remainder.push_str(tail);
|
||||
}
|
||||
Some((doi.to_string(), remainder))
|
||||
}
|
||||
|
||||
/// Finds a leading clause ending in the publication year: `Proc. FOCS 2000.`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The clause without its trailing period, and whatever followed.
|
||||
fn venue(text: &str, year: u32) -> Option<(String, String)> {
|
||||
let needle = format!("{year}.");
|
||||
let at = text.find(&needle)?;
|
||||
let clause = text[..at + needle.len() - 1].trim();
|
||||
if clause.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
clause.to_string(),
|
||||
text[at + needle.len()..].trim().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The end of a run of ASCII digits starting at `from`.
|
||||
fn digits(bytes: &[u8], from: usize) -> usize {
|
||||
let mut at = from;
|
||||
while at < bytes.len() && bytes[at].is_ascii_digit() {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The end of a run of spaces starting at `from`.
|
||||
fn spaces(bytes: &[u8], from: usize) -> usize {
|
||||
let mut at = from;
|
||||
while at < bytes.len() && bytes[at] == b' ' {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// Whether a note still looks like it is carrying a citation.
|
||||
///
|
||||
/// Used by validation to say so, rather than leaving a note that a reading list
|
||||
/// cannot link and an export cannot use.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `note` - the note as written.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when a volume and page range, or a DOI, can be found in it.
|
||||
pub fn looks_like_a_citation(note: &str) -> bool {
|
||||
citation(note.trim()).is_some() || doi(note.trim()).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every article note in a real course bibliography, which is where the
|
||||
/// shapes below come from. Two separator styles, DOIs in three positions,
|
||||
/// a conference with no volume, and prose that has to survive.
|
||||
#[test]
|
||||
fn a_journal_citation_comes_apart() {
|
||||
let p = parse(
|
||||
"Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389",
|
||||
Some(1997),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Nucleic Acids Res"));
|
||||
assert_eq!(p.volume.as_deref(), Some("25"));
|
||||
assert_eq!(p.pages.as_deref(), Some("3389-3402"));
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1093/nar/25.17.3389"));
|
||||
assert_eq!(p.note, None);
|
||||
// The DOI encodes volume 25, issue 17. Nothing infers the issue from
|
||||
// it: a field nobody wrote down stays empty.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abbreviation_keeps_its_final_period() {
|
||||
let p = parse("J. Mol. Biol. 48, 443-453.", Some(1970));
|
||||
assert_eq!(p.container.as_deref(), Some("J. Mol. Biol."));
|
||||
assert_eq!(p.volume.as_deref(), Some("48"));
|
||||
assert_eq!(p.pages.as_deref(), Some("443-453"));
|
||||
assert_eq!(p.note, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_after_a_citation_stays_in_the_note() {
|
||||
let p = parse(
|
||||
"Bioinformatics 18:440-445. Origin of spaced seeds.",
|
||||
Some(2002),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Bioinformatics"));
|
||||
assert_eq!(p.note.as_deref(), Some("Origin of spaced seeds."));
|
||||
|
||||
// A caveat the author wrote is the last thing to throw away.
|
||||
let p = parse("J Mol Biol 215:403-410. Verify before use.", Some(1990));
|
||||
assert_eq!(p.note.as_deref(), Some("Verify before use."));
|
||||
|
||||
let p = parse(
|
||||
"Bioinformatics 25:2078-2079. doi:10.1093/bioinformatics/btp352. Author list is the \
|
||||
core set plus the 1000 Genomes Data Processing Subgroup; verify.",
|
||||
Some(2009),
|
||||
);
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1093/bioinformatics/btp352"));
|
||||
assert_eq!(
|
||||
p.note.as_deref(),
|
||||
Some(
|
||||
"Author list is the core set plus the 1000 Genomes Data Processing Subgroup; \
|
||||
verify."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conference_has_a_year_where_a_volume_would_be() {
|
||||
let p = parse(
|
||||
"Proc. FOCS 2000. doi:10.1109/SFCS.2000.892127. The FM-index. Theory background.",
|
||||
Some(2000),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Proc. FOCS"));
|
||||
// No volume and no pages were written, so none are invented — and the
|
||||
// 2000 in the DOI is not mistaken for either.
|
||||
assert_eq!(p.volume, None);
|
||||
assert_eq!(p.pages, None);
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1109/SFCS.2000.892127"));
|
||||
assert_eq!(p.note.as_deref(), Some("The FM-index. Theory background."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_with_nothing_to_find_is_left_whole() {
|
||||
let p = parse("Origin of the MAPQ score.", Some(2008));
|
||||
assert_eq!(p.container, None);
|
||||
assert_eq!(p.note.as_deref(), Some("Origin of the MAPQ score."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_word_journal_is_not_cut_at_a_number() {
|
||||
let p = parse("Advances in Mathematics 20, 367-387.", Some(1976));
|
||||
assert_eq!(p.container.as_deref(), Some("Advances in Mathematics"));
|
||||
assert_eq!(p.volume.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_validation_looks_for() {
|
||||
assert!(looks_like_a_citation("Nat Methods 12:59-60."));
|
||||
assert!(looks_like_a_citation("doi:10.1038/nmeth.3176"));
|
||||
assert!(!looks_like_a_citation("Origin of minimizers."));
|
||||
assert!(!looks_like_a_citation("Verify before use."));
|
||||
// A page range with no volume is not a citation shape.
|
||||
assert!(!looks_like_a_citation("see pages 12-14"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user