Files
catena/crates/citefind/src/parse.rs
T

1696 lines
66 KiB
Rust
Raw Normal View History

//! Reading one reference as far as its shape allows.
//!
//! Reference styles differ in punctuation and agree on order: who, when,
//! what, where. So the reading goes for the parts that announce themselves —
//! an identifier, a year in brackets, a title in italics or quotation marks —
//! and uses where those landed to divide up what is left. A field that does
//! not announce itself is left empty and named in `unsettled`, which is the
//! whole point: a wrong year and a real one look identical three stages
//! downstream, and a missing one does not.
use std::sync::LazyLock;
use regex::Regex;
use crate::{Id, Kind, Reference};
/// Shorter than this and it is a page number or a running head.
const MIN_ENTRY: usize = 12;
/// A reference that only points back at an earlier one.
static CONTINUATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[\s\p{Pi}\p{Pf}*_]*(?:ibid|idem|op\.\s*cit|loc\.\s*cit|op\.cit|loc\.cit)\b")
.expect("a valid pattern")
});
static DOI: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b10\.\d{4,9}/[^\s,;)\]>]+").expect("a valid pattern"));
static ARXIV: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\barxiv:\s*([A-Za-z\-.]*/?\d{4,7}(?:\.\d{4,5})?(?:v\d+)?)")
.expect("a valid pattern")
});
static ISBN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bisbn(?:-?1[03])?:?\s*((?:97[89][\s\-]?)?(?:\d[\s\-]?){9}[\dXx])")
.expect("a valid pattern")
});
static ISSN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bissn:?\s*(\d{4}[\s\-]?\d{3}[\dXx])").expect("a valid pattern")
});
static URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"https?://[^\s,;>\]]+").expect("a valid pattern"));
/// A year inside brackets, whether alone or at the end of an imprint.
static PAREN_YEAR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\((?:[^()]*?[^\d])?((?:1[4-9]|20)\d{2})[a-z]?\)").expect("a valid pattern")
});
/// A year after a comma, which is where an imprint puts it.
static COMMA_YEAR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[,(]\s*((?:1[4-9]|20)\d{2})[a-z]?\b").expect("a valid pattern"));
/// A year anywhere, which is the last resort.
static ANY_YEAR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b((?:1[4-9]|20)\d{2})\b").expect("a valid pattern"));
/// Pages announced as pages.
static PP: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bpp?\.?\s*(\d{1,5}(?:\s*[-–—]+\s*\d{1,5})?)\b").expect("a valid pattern")
});
/// The page range a journal citation ends with: `12 (1998): 45-67`.
static COLON_PAGES: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r":\s*(\d{1,5}(?:\s*[-–—]+\s*\d{1,5})?)\s*\.?\s*$").expect("a valid pattern")
});
/// A page range after a comma or colon, which is where author-date styles
/// put one: `Nature, 570(7761), 321-325`.
static RANGE_PAGES: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[,:]\s*(\d{1,5}\s*[-–—]+\s*\d{1,5})(?:\b|$)").expect("a valid pattern")
});
/// The bare page a note ends with: `…, 183.`
static TRAILING_PAGE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r",\s*(\d{1,5})\s*\.?\s*$").expect("a valid pattern"));
/// A title in markdown emphasis, or in the tags a converter left instead.
static EMPHASISED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)\*([^*\n]{3,300})\*|_([^_\n]{3,300})_|<(?:em|i|cite)\b[^>]*>(.{3,300}?)</(?:em|i|cite)>")
.expect("a valid pattern")
});
/// A title in quotation marks, straight or curly.
static QUOTED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#""([^"\n]{3,300})"|“([^”\n]{3,300})”|«([^»\n]{3,300})»|‘([^’\n]{3,300})’"#)
.expect("a valid pattern")
});
/// The imprint of a book: a place, a colon, a publisher.
static IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:^|[(.,;]\s*)[\p{Lu}][^:;()\d]{1,40}:\s*([^;()]{2,80})")
.expect("a valid pattern")
});
/// The same, but only where it follows something. A title with a colon in
/// it — `Subordination and Equivalence: The Nature and Role of Woman` — is
/// an imprint by the pattern above when the pattern may match at the start.
static IMPRINT_AFTER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[(.,;]\s*[\p{Lu}][^:;()\d]{1,40}:\s*[^;()]{2,80}").expect("a valid pattern")
});
/// Words that name a publisher rather than a journal.
static PUBLISHER_WORD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(?:press|publisher|publishing|books|verlag|editions?|university|imprint|&\s*co\b|ltd\b|inc\b)")
.expect("a valid pattern")
});
/// The volume-and-number a journal citation carries.
static VOLUME: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(?:vol\.?\s*)?\d{1,4}\s*(?:,\s*)?(?:\(\s*\d+\s*\)|no\.\s*\d+|\(\s*(?:1[4-9]|20)\d{2}\s*\))")
.expect("a valid pattern")
});
/// What marks a piece inside a larger edited work.
static WITHIN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:^|[,.]\s*)in\s|\bed(?:s|ited by)?\.?\s").expect("a valid pattern")
});
/// The role a compiler of a book is credited with, at the end of the run of
/// names it follows.
static ROLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i),?\s+(?:ed|eds|edited\s+by|trans|translated\s+by|comp|comps|rev)\.?$")
.expect("a valid pattern")
});
/// What a title never is: an opening that is a page or a volume number, or a
/// run that carries a page reference or a semicolon — both of which mean two
/// entries were run together, or that this was never an entry at all.
static NOT_A_TITLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?ix)
^[\d\p{Pd}] # opens on a page or a volume number
| \bpp?\.\s*\d # carries a page reference
| ; # two entries run together
| \bibid\b
| \\ # an escape conversion left behind
| ,\s+(?:eds?|trans)\.\s+\p{Lu} # ran past the title into a credit
"#,
)
.expect("a valid pattern")
});
/// A reference to chapter and verse: `Matthew 4:17`, `1 Peter 2:6-8`.
///
/// A book of the Bible is not a work in a bibliography — nobody is looking
/// for the publisher of Galatians — and a page of them is what the notes of
/// a theological book largely are.
static VERSE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
^(?:(?:also|cf|see|and|vv?)\.?\s+)* # 'Cf.', 'also cf.', 'see'
(?:[1-4]\s+)? # 1 Peter, 2 Corinthians
(?:\p{L}[\p{L}.]{2,14}\.?\s+)? # the book, where it is named
\d{1,3}(?:[:.]\d{1,3})? # chapter, and verse
(?:[-–]\d{1,3})? # through to another
\.?$",
)
.expect("a valid pattern")
});
/// A note number sitting inside a run of verses, which is where conversion
/// leaves it when several notes share one line.
static EMBEDDED_MARKER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\s\d{1,3}\.\s").expect("a valid pattern"));
/// A contributor's note: a name, and then what they are. It opens exactly
/// as a Chicago note does — `S. Marc Cohen, ...` — and the verb gives it
/// away. These sit in the same back matter as the endnotes and are not
/// references to anything.
static BIOGRAPHY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^\*{0,2}\p{Lu}[^*\n]{2,60}?\*{0,2}\s+(?:is|was)\s+(?:a|an|the|professor|emeritus|emerita|lecturer|reader|assistant|associate|senior|currently|director|head|editor|author)\b",
)
.expect("a valid pattern")
});
/// What a browser prints at the foot of a page: the address it came from,
/// which page of how many, and when it was printed. A book assembled out of
/// printed web pages carries one on every page, and none of them is a
/// reference to anything.
static PRINT_FURNITURE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) ^ https?://\S+
(?: \s* \( \s* \d+ \s+ of \s+ \d+ \s* \) )? # (54 of 100)
(?: \s* \[ [^\]]{0,40} \] )? # [4/7/2002 1:48:08 PM]
\s* $",
)
.expect("a valid pattern")
});
/// A short cite: a surname, a year, and at most a page. `Spade 1980, 12.`
/// and `Trentman 1970; Spade 1980.` name a work the entry does not — the
/// full one is elsewhere in the same bibliography — so there is nothing in
/// them to read and nothing to ask anybody about.
static SHORT_CITE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) ^ (?:(?:see|cf\.?|and)\s+)?
\p{Lu}[\p{L}'’\-]{1,24}(?:\s+(?:and|&)\s+\p{Lu}[\p{L}'’\-]{1,24})?
\s+ (?:1[4-9]|20)\d{2} [a-z]?
(?: \s* ,\s* (?:pp?\.\s*)? \d{1,4}(?:[-–]\d{1,4})? )?
\.? $",
)
.expect("a valid pattern")
});
/// A name and a locator with nothing between them: `Lowrie and Fletcher,
/// p. 133`, `Anderson, p. 400`. Like a short cite with a year, this points
/// at a work it does not name.
static BARE_CITE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) ^ (?:(?:see|cf\.?|and|also)\s+)*
\p{Lu}[\p{L}'’\-]{1,24}
(?:\s+(?:and|&)\s+\p{Lu}[\p{L}'’\-]{1,24})?
\s*,\s* (?:pp?\.|ff?\.|vol\.|no\.)\s*
\d{1,5}(?:[-–—]\d{1,5})? \.? $",
)
.expect("a valid pattern")
});
/// A text cited by the critical series it is printed in rather than by its
/// own name: `PG 4: 264`, `CSEL 34.2, 12`, `NPNF 2/5: 430`. Migne and the
/// rest are shelves, not works, and the work is not named here.
static SERIES_CITE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x) ^ (?:PG|PL|CSEL|CCSL|CCSG|SC|NPNF|ANF|GCS|PO|CSCO|ACO|ACW|FC|LCL|SVS)
\s* \d{0,3} (?:[/.]\d{1,3})* \s* :? \s*
[\d\s,:.–-]* \.? $",
)
.expect("a valid pattern")
});
/// A page reference or a bracketed imprint: what a citation has and a remark
/// about the argument does not.
static CITATION_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bpp?\.\s*\d|\bvol\.\s*\d|\bno\.\s*\d").expect("a valid pattern")
});
/// How many sentences a reference runs to before it is prose.
///
/// A full entry in the heaviest style — names, title, an edited volume, an
/// imprint, pages — is five. A discursive endnote runs on past that, and the
/// three it is checked against are the signals a citation carries anyway.
const MAX_SENTENCES: usize = 5;
/// A credit standing where the container would be: `trans. E. B. Ashton`,
/// `edited by Mary Douglas`. A role is not a journal and not a publisher.
static ROLE_CREDIT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:trans(?:lated)?|ed(?:ited)?|eds|comp(?:iled)?|rev(?:ised)?|intro(?:duction)?|with)\b\.?(?:\s+by\b)?")
.expect("a valid pattern")
});
/// A run of text that is nothing but an identifier.
static IDENTIFIER_ONLY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:arxiv:|doi:|https?://|isbn|issn|\b10\.\d{4})\S*\.?$")
.expect("a valid pattern")
});
/// Any HTML tag, which conversion scatters through a reference list.
static MARKUP_TAG: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"</?[a-zA-Z][^>]*>").expect("a valid pattern"));
/// The Chicago note: a name, the title, the imprint in brackets, the page.
/// Nothing in it is marked up, so the commas and the brackets are all there
/// is to divide it by.
static NOTE_FORM: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x) ^([^,()]{2,60}),\s+([^()]{4,300}?)\s*
\( ( [^()]* (?: (?:1[4-9]|20)\d{2}[a-z]? | :\s*\p{Lu} ) [^()]* ) \)",
)
.expect("a valid pattern")
});
/// A volume or part trailing a title: `…, Vol. 2`, `…, bk. iv`.
static TRAILING_VOLUME: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i),\s*(?:vol|bk|pt|sect?)\.?\s*[\divxlcdm]+\s*$").expect("a valid pattern")
});
/// The word that introduces a locator, which is where the name stops.
static LOCATOR_WORD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:pp?|ff?|vol|bk|ch|no|n|sec)\.?,?$").expect("a valid pattern")
});
/// A locator: where in the work, rather than what the work is. A page, a
/// range, a volume in roman numerals, a folio. These trail a short note and
/// are not part of its title.
static LOCATOR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) ^ (?:pp?\.|ff?\.|vol\.|bk\.|ch\.|no\.|n\.|sec\.)?\s*
(?: \d{1,5}(?:[-–—]\d{1,5})? | [ivxlcdm]+ (?:[-–—][ivxlcdm]+)? )
\s* (?:n\.?\s*\d{1,3})? \.? $",
)
.expect("a valid pattern")
});
/// What a note says before it gets to the citation.
static LEAD_IN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^(?:see|cf\.|compare|quoted|cited|as\s+quoted|reprinted)(?:\s*,?\s*(?:also|for\s+(?:example|instance)|e\.g\.|in))*\s*,?\s+")
.expect("a valid pattern")
});
/// The lowercase words a name may contain. Anything else lowercase in the
/// middle of a supposed name means it is a sentence.
const PARTICLES: [&str; 20] = [
"van", "von", "de", "del", "della", "der", "den", "di", "da", "du", "la", "le", "of", "y",
"bin", "ibn", "al", "ter", "v", "zu",
];
/// Anything that says this is a reference at all.
static CITATION_SIGNAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
\b(?:1[4-9]|20)\d{2}\b | \bpp?\.\s*\d | https?:// | \b10\.\d{4}
| \bed\. | \btrans\. | \bvol\. | \bno\. | :\s*\d
| \bpress\b | \buniversity\b | \bjournal\b
| ,\s*\d{1,5}(?:[-–—]\d{1,5})?\s*\.?$ # a short note ends on its page
",
)
.expect("a valid pattern")
});
/// A rule of dashes standing in for the author of the entry above, which is
/// how every style avoids printing the same name forty times running.
static REPEATED_AUTHOR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(?:[—–]+|-{3,}|_{3,})").expect("a valid pattern"));
/// Whether an entry defers to the one above it for its author.
pub fn repeats_author(text: &str) -> bool {
REPEATED_AUTHOR.is_match(text)
}
/// Read one entry.
///
/// `None` when it is not a reference at all — a section heading, a page
/// number, a stray line of running text that the cut picked up.
pub fn one(text: &str, marker: Option<&str>) -> Option<Reference> {
let text = text.trim();
if text.chars().count() < MIN_ENTRY {
return None;
}
let mut out = Reference::of(text);
out.marker = marker.map(str::to_string);
if CONTINUATION.is_match(text) {
out.continuation = true;
out.pages = pages(text);
return Some(out);
}
if !CITATION_SIGNAL.is_match(text) {
return None;
}
if is_scripture(text) || BIOGRAPHY.is_match(text) || PRINT_FURNITURE.is_match(text) {
return None;
}
// A short cite points at a full entry elsewhere, the way `ibid.` points
// at the note above. The author and the year it does give are kept.
if is_short_cite(text) {
out.continuation = true;
out.year = year(text);
out.pages = pages(text);
out.authors = short_cite_authors(text);
return Some(out);
}
// A note that runs on for six sentences with no imprint, no page
// reference and no identifier is a remark about the argument. The LLM
// path this replaces was told to skip those; a parser has to decide.
if sentences(text).len() > MAX_SENTENCES
&& !IMPRINT.is_match(text)
&& !CITATION_ANCHOR.is_match(text)
&& ids(text).is_empty()
{
return None;
}
out.ids = ids(text);
out.year = year(text);
out.pages = pages(text);
let (quoted, quoted_at) = span(&QUOTED, text);
let (emphasised, emphasised_at) = span(&EMPHASISED, text);
let dated = author_date(text);
// An author-date opening settles the authors and the year whatever the
// rest of the entry marks up, so it is asked first. A bibliography full
// of German titles has quotation marks inside its titles, and taking
// those for the title is how a parser that asked the other way round
// gets it wrong.
if let Some(year_at) = dated {
out.authors = authors(&text[..year_at.0]);
let tail = text[year_at.1..].trim_start_matches(|c: char| ".,:‘“\" ".contains(c));
let tail_at = text.len() - tail.len();
// Quotation marks count as the title only where they open the tail.
// Further in they are a quotation inside a title, not the title.
let opens = |at: Option<(usize, usize)>| {
at.is_some_and(|at| at.0 + 2 >= tail_at && at.0 <= tail_at + 2)
};
let after_year = |at: (usize, usize)| at.0 >= year_at.1;
// Whatever is marked up at the head of the tail is the work cited.
// Italics stand for the journal in `Peirce (1878). Plain title.
// *Popular Science Monthly*` — where they come after the title — and
// for the title itself in `Goffman (1963). *Stigma* Penguin`, where
// they open it. Which it is depends on where they sit, not on what
// they are.
let head = match (opens(quoted_at), opens(emphasised_at)) {
(true, _) => quoted.clone().zip(quoted_at),
(_, true) => emphasised.clone().zip(emphasised_at),
_ => None,
};
if let Some((title, at)) = head {
out.title = Some(title);
out.container = match (&emphasised, emphasised_at) {
(Some(book), Some(book_at)) if book_at.0 >= at.1 => tidy_container(book),
_ => container(text, &text[at.1..]),
};
} else {
let parts = sentences(tail);
// `…, Subordination and Equivalence: …, Washington: University
// Press of America.` has no stop between the title and the
// imprint, so the imprint is where the title ends.
out.title = parts.first().map(|first| {
let cut = IMPRINT_AFTER
.find(first)
.map_or(first.len(), |found| found.start());
tidy_title(&strip_markup(&first[..cut]))
});
out.container = match (emphasised, emphasised_at) {
(Some(journal), Some(at)) if after_year(at) => tidy_container(&journal),
_ => parts.get(1).and_then(|second| tidy_container(second)),
};
}
} else if let (Some(title), Some(at)) = (quoted, quoted_at) {
out.title = Some(title);
out.authors = authors(&text[..at.0]);
// A piece inside an edited book appeared in that book, and the book
// is the italic title after this one.
let within = WITHIN.is_match(&text[at.1..]);
out.container = match (within, emphasised, emphasised_at) {
(true, Some(book), Some(book_at)) if book_at.0 >= at.1 => Some(book),
_ => container(text, &text[at.1..]),
};
} else if let (Some(title), Some(at)) = (emphasised, emphasised_at) {
out.title = Some(title);
out.authors = authors(&text[..at.0]);
out.container = container(text, &text[at.1..]);
} else if let Some(found) = note_form(text)
.or_else(|| comma_form(text))
.or_else(|| short_note(text))
{
out.authors = found.0;
out.title = found.1;
out.container = found.2;
} else {
let (found_authors, found_title, found_container) = by_sentence(text);
out.authors = found_authors;
out.title = found_title;
out.container = found_container;
}
// A title the reading refused comes back empty rather than absent,
// because the refusal happens where the trimming does. An empty title is
// an absent one, and `unsettled` should say so.
if out.title.as_deref().is_some_and(str::is_empty) {
out.title = None;
}
out.kind = kind(text, &out);
Some(out)
}
/// The first match of a title pattern: what it held, and where it sat.
fn span(pattern: &Regex, text: &str) -> (Option<String>, Option<(usize, usize)>) {
let Some(found) = pattern.captures(text) else {
return (None, None);
};
let whole = found.get(0).expect("the whole match");
let Some(inner) = (1..=4).find_map(|at| found.get(at)) else {
return (None, None);
};
let cleaned = tidy_title(&strip_markup(inner.as_str()));
match cleaned.is_empty() {
true => (None, None),
false => (Some(cleaned), Some((whole.start(), whole.end()))),
}
}
/// An author-date opening with no brackets round the year: `Altmann, M.
/// 1951, A Study of Behaviour`. Common in the sciences and in older German
/// and Russian bibliographies.
static NAME_YEAR: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(.{2,80}?)[,.]?\s+((?:1[4-9]|20)\d{2})[a-z]?\s*[,.:]\s+")
.expect("a valid pattern")
});
/// Where an author-date entry puts its year, when it is one.
///
/// The bracketed year has to come early and what precedes it has to read as
/// names — otherwise `(Oxford, 1998)` at the end of a note would look like
/// the opening of an author-date entry with the whole note as its authors.
fn author_date(text: &str) -> Option<(usize, usize)> {
let span = match PAREN_YEAR.find(text) {
Some(found) => (found.start(), found.end()),
None => {
let found = NAME_YEAR.captures(text)?;
let year = found.get(2).expect("the year");
let whole = found.get(0).expect("the whole match");
(year.start(), whole.end())
}
};
let head = &text[..span.0];
if head.chars().count() > 120 || !reads_as_names(head) {
return None;
}
// Something has to follow the year, or there is no title to find.
match text[span.1..].trim().chars().count() >= 4 {
true => Some(span),
false => None,
}
}
/// Read a note that marked none of its parts but bracketed its imprint.
///
/// `Name, Title (Place: Publisher, Year), page.` — the commonest shape in
/// any book with endnotes, and the one that offers a parser nothing but its
/// punctuation. The guard is that the part standing where the title goes has
/// to be one sentence: a contributor's biography opens the same way and runs
/// on for three.
fn note_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>)> {
let text = LEAD_IN.replace(text.trim(), "");
let found = NOTE_FORM.captures(&text)?;
let names = authors(&found[1]);
if names.is_empty() {
return None;
}
let middle = found[2].trim();
if sentences(middle).len() > 1 {
return None;
}
let title = tidy_title(&strip_markup(middle));
if title.chars().count() < 4 {
return None;
}
// `(Tübingen, 1933)` names a place and a year; only a bracket with a
// colon or a publisher's name in it names a publisher.
let imprint = found[3].trim();
let publisher = match imprint.contains(':') || PUBLISHER_WORD.is_match(imprint) {
true => tidy_container(imprint),
false => None,
};
Some((names, Some(title), publisher))
}
/// Read an entry whose only punctuation is commas.
///
/// `Miller, David, On Nationality, Oxford: Clarendon Press, 1995` — the style
/// gives nothing away except that the imprint has a colon in it, so the title
/// is taken to be whatever sits between the names and the imprint. Without an
/// imprint there is no second edge and nothing to be read here.
fn comma_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>)> {
let text = LEAD_IN.replace(text.trim(), "");
if sentences(&text).len() > 1 {
return None;
}
let parts: Vec<&str> = text.split(',').collect();
if parts.len() < 4 {
return None;
}
// The imprint is whichever comma-part carries the colon. Found by
// counting commas rather than by where a pattern matched: a pattern for
// `Place: Publisher` starts at the comma before the place, which is
// exactly where the title ends, and the two cannot both have it.
// The last part with a colon, not the first: `Theories of Nationalism: A
// Critical Introduction, Basingstoke: Palgrave` has two, and the imprint
// is the one at the end.
// The imprint is whichever comma-part carries the colon — the last one,
// because a title has a colon in it as often as an imprint does. Failing
// that, an entry ending on a year has its imprint at the end: `Cheyne,
// T. K., Founders of Old Testament Criticism, London, 1893.`
let at = match parts.iter().rposition(|part| part.contains(':')) {
Some(at) => at,
None => {
let last = parts.last()?.trim().trim_end_matches('.');
if !looks_like_a_year(last) || parts.len() < 4 {
return None;
}
// The part before the year is where it was published, which is
// not a publisher and not kept.
parts.len() - 2
}
};
// `Miller, David` is the usual opening, and `Procopius` on its own is the
// other one. Whichever reads as a name, the title starts after it.
let pair = format!("{},{}", parts[0], parts[1]);
let (names, used) = match reads_as_names(&pair) {
true => (authors(&pair), 2),
false if reads_as_names(parts[0]) => (authors(parts[0]), 1),
false => return None,
};
if names.is_empty() || at <= used {
return None;
}
let title = tidy_title(&strip_markup(&parts[used..at].join(",")));
if title.chars().count() < 4 {
return None;
}
// Where the colon was is a publisher; where the year was is a place, and
// a place is not kept.
let publisher = match parts[at].contains(':') {
true => tidy_container(parts[at]),
false => None,
};
Some((names, Some(title), publisher))
}
/// Read a short note: a surname, a short title, and where in it.
///
/// `Unger, Dutch Shipbuilding, 33-4` and `Sjoerds, Algemene Beschryvinge, i,
/// f I, 501-2.` — the form a history book uses for every citation after the
/// first, once the full entry has been given. There is no year in it and no
/// publisher; what is there is a name, a title, and a page.
///
/// The trailing locators are not part of the title. Neither is a year: an
/// entry ending on one is an imprint, and belongs to `comma_form`.
fn short_note(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>)> {
let text = LEAD_IN.replace(text.trim(), "");
if sentences(&text).len() > 1 {
return None;
}
let parts: Vec<&str> = text
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
if parts.len() < 3 {
return None;
}
// It has to end on a locator, or it is some other kind of run entirely.
let last = parts.last()?.trim_end_matches('.');
if !LOCATOR.is_match(last) || looks_like_a_year(last) {
return None;
}
// `Krochmal, N., Moreh Nebukhe ha-Zeman` puts the initials in a part of
// their own; they belong to the name, not to the title.
let named = match parts.get(1).is_some_and(|part| is_initials(part)) {
true => 2,
false => 1,
};
let names = authors(&parts[..named].join(", "));
if names.is_empty() {
return None;
}
// The title runs from after the name to the first locator.
let end = parts
.iter()
.skip(named)
.position(|part| LOCATOR.is_match(part.trim_end_matches('.')))
.map(|at| at + named)
.unwrap_or(parts.len());
if end <= named {
return None;
}
let title = tidy_title(&strip_markup(&parts[named..end].join(", ")));
match title.chars().count() >= 4 {
true => Some((names, Some(title), None)),
false => None,
}
}
/// Whether a run is nothing but initials: `N.`, `T. K.`, `J. R. R.`
fn is_initials(text: &str) -> bool {
let words: Vec<&str> = text.split_whitespace().collect();
!words.is_empty()
&& words.len() <= 4
&& words.iter().all(|word| {
let core = word.trim_end_matches('.');
core.chars().count() == 1 && core.chars().all(char::is_uppercase)
})
}
/// Whether a run of text is names and nothing else.
///
/// Stricter than asking whether it *contains* a name. `Doe, Jane, and Peter
/// Brown. "Obligation and Its Limits." Journal of Medieval Studies 12, no. 3`
/// contains one, and reading it as the authors of an author-date entry would
/// take the journal's issue number for a year.
fn reads_as_names(head: &str) -> bool {
let head = head.trim_end_matches(|c: char| ",.;: ".contains(c));
if head.contains(['"', '“', '”', '*', '_', '(', ')']) {
return false;
}
let head = ROLE.replace(head, "");
let parts = split_parts(&head);
!parts.is_empty() && parts.iter().all(|part| is_name(part))
}
/// Drop the emphasis and the tags a converter leaves round a title.
fn strip_markup(text: &str) -> String {
let without_tags = MARKUP_TAG.replace_all(text, "");
without_tags.replace(['*', '_'], "").trim().to_string()
}
/// Whether every part of an entry is a short cite and nothing more.
fn is_short_cite(text: &str) -> bool {
let parts: Vec<&str> = text
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
!parts.is_empty()
&& parts
.iter()
.all(|part| SHORT_CITE.is_match(part) || BARE_CITE.is_match(part))
}
/// The surnames a short cite names, in the order it names them.
fn short_cite_authors(text: &str) -> Vec<String> {
text.split(';')
.filter_map(|part| {
let part = LEAD_IN.replace(part.trim(), "").trim().to_string();
// Up to the year or the locator, whichever the cite carries.
let name: Vec<&str> = part
.split_whitespace()
.take_while(|word| {
!word.chars().next().is_some_and(|c| c.is_ascii_digit())
&& !LOCATOR_WORD.is_match(word)
})
.collect();
let name = name.join(" ");
let name = name.trim_end_matches([',', '.', ';', ' ']);
match name.is_empty() {
true => None,
false => Some(restore_initial(name)),
}
})
.collect()
}
/// Whether an entry is chapter and verse and nothing else.
///
/// Several of them separated by semicolons is still a scripture reference
/// and still not a work: `Cf. Deuteronomy 18:15-19; Isaiah 53; Acts 1:6`.
fn is_scripture(text: &str) -> bool {
let text = text.trim_start_matches(['.', ' ']);
let text = EMBEDDED_MARKER.replace_all(text, ";");
let parts: Vec<&str> = text
.split([';', ','])
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
if parts.is_empty()
|| !parts
.iter()
.all(|part| VERSE.is_match(part) || SERIES_CITE.is_match(part))
{
return false;
}
// A run of bare numbers is not scripture just because it could follow
// from some book named elsewhere. One of them has to name the book.
parts
.iter()
.any(|part| part.chars().any(char::is_alphabetic))
}
/// Every identifier the entry carries, normalised.
///
/// Order is fixed rather than found: two entries carrying the same
/// identifiers produce the same record whichever way round they wrote them.
fn ids(text: &str) -> Vec<Id> {
let mut out = Vec::new();
let mut push = |kind: &str, raw: &str| {
let normalised = match kind {
"url" => Some(raw.trim_end_matches(['.', ',', ';', ')']).to_string()),
_ => idnorm::parse(raw).map(|found| found.id),
};
if let Some(id) = normalised {
let one = Id {
kind: kind.to_string(),
id,
};
if !out.contains(&one) {
out.push(one);
}
}
};
for found in DOI.find_iter(text) {
push("doi", found.as_str().trim_end_matches(['.', ',', ';']));
}
for found in ARXIV.captures_iter(text) {
push("arxiv", &found[1]);
}
for found in ISBN.captures_iter(text) {
push("isbn", &found[1]);
}
for found in ISSN.captures_iter(text) {
push("issn", &found[1]);
}
for found in URL.find_iter(text) {
// A DOI written as a URL is already recorded as the DOI it is.
if !found.as_str().contains("doi.org") && !found.as_str().contains("arxiv.org") {
push("url", found.as_str());
}
}
out
}
/// The year of publication.
///
/// A bracketed year first, because that is where a style puts the one it
/// means; then a year after a comma, which is where an imprint ends; then
/// any year at all, which may well be part of a title.
fn year(text: &str) -> Option<u32> {
for pattern in [&*PAREN_YEAR, &*COMMA_YEAR, &*ANY_YEAR] {
if let Some(found) = pattern.captures(text) {
return found[1].parse().ok();
}
}
None
}
/// The pages, as the entry wrote them.
///
/// The last two patterns end at a number, and so does an imprint — so a
/// value that reads as a year is refused rather than reported as a page. A
/// book published in 1998 is not a book cited at page 1998.
fn pages(text: &str) -> Option<String> {
for pattern in [&*PP, &*COLON_PAGES, &*RANGE_PAGES, &*TRAILING_PAGE] {
let Some(found) = pattern.captures(text) else {
continue;
};
let value: String = found[1].split_whitespace().collect::<Vec<_>>().join("");
if value.split(['-', '–', '—']).all(looks_like_a_year) {
continue;
}
return Some(value);
}
None
}
/// Whether a number is in the range a year of publication falls in.
fn looks_like_a_year(text: &str) -> bool {
match text.parse::<u32>() {
Ok(number) => (1400..=2099).contains(&number),
Err(_) => false,
}
}
/// Strip the punctuation a style leaves inside its own title delimiters.
fn tidy_title(raw: &str) -> String {
let text = raw.split_whitespace().collect::<Vec<_>>().join(" ");
// A trailing backslash is a line break conversion left behind, not part
// of anything.
let text = text.trim_end_matches('\\');
// The quotation marks a style puts round a title are the style's, not the
// title's, and only the outermost pair is trimmed.
let text =
text.trim_matches(|c: char| ",.;:'\u{2018}\u{2019}".contains(c) || c.is_whitespace());
// `Geschichte des Papsttums, Vol. 2` names the work and then which volume
// of it. The volume is where in the work, not what the work is called.
let text = TRAILING_VOLUME.replace(text, "");
let text = text.trim_end_matches(|c: char| ",.;: ".contains(c));
// A run that opens on a page number, or carries one, is two entries run
// together or a reference to a work rather than the name of one. Better
// to have no title and say so than to report that as one.
if NOT_A_TITLE.is_match(text) || !quotes_balance(text) {
return String::new();
}
text.to_string()
}
/// Whether every quotation mark in a run has its partner.
///
/// `What's the Meaning of "This"?` is a title with a quotation inside it.
/// `Mediterraneans." In Rethinking the Mediterranean` is the tail of a title
/// whose opening is on the page before. One quotation mark tells them apart.
fn quotes_balance(text: &str) -> bool {
let straight = text.matches('"').count();
let opened = text.matches('\u{201c}').count();
let closed = text.matches('\u{201d}').count();
straight % 2 == 0 && opened == closed
}
/// Who wrote it, from the run of text before the title.
fn authors(before: &str) -> Vec<String> {
let mut head = LEAD_IN.replace(before.trim(), "").trim().to_string();
let mut head = head.as_mut_str() as &str;
// An author-date entry puts the year between the authors and the title.
if let Some(at) = head.rfind('(') {
if head[at..].contains(|c: char| c.is_ascii_digit()) {
head = head[..at].trim();
}
}
let head = head.trim_end_matches(|c: char| ",.;:".contains(c) || c.is_whitespace());
// `Eberhardt, Christian, ed.` is an editor, and an editor is a person.
// The role is dropped rather than the name with it.
split_authors(&ROLE.replace(head, ""))
}
/// Cut a run of names at the separators a reference list uses.
fn split_authors(head: &str) -> Vec<String> {
split_parts(head)
.into_iter()
.filter(|part| is_name(part))
.collect()
}
/// The separators a list of authors is joined by.
///
/// A bare comma is not among them: `Smith, John` is one author and
/// `Smith, John, and Jane Doe` is two, so only the words can divide them.
fn split_parts(head: &str) -> Vec<String> {
let head = head.trim();
if head.is_empty() || head.chars().count() > 200 {
return Vec::new();
}
let mut parts: Vec<String> = vec![head.to_string()];
for separator in [", and ", " and ", "; ", " & ", ", & "] {
if head.contains(separator) {
parts = head
.split(separator)
.map(|part| part.trim().to_string())
.collect();
break;
}
}
parts
.into_iter()
.map(|part| {
// A trailing "et al." means the entry dropped the rest itself,
// and there is nothing here to recover.
restore_initial(
part.trim_end_matches(|c: char| ",.;".contains(c) || c.is_whitespace())
.trim_end_matches("et al")
.trim_end_matches(|c: char| ",.;".contains(c) || c.is_whitespace()),
)
})
.filter(|part| !part.is_empty())
.collect()
}
/// Put back the full stop that belongs to a closing initial.
///
/// Trimming trailing punctuation is right for `Smith, John,` and wrong for
/// `Peirce, C. S.`, where the stop is part of the initial and not the
/// separator that followed it.
fn restore_initial(name: &str) -> String {
let last = name
.rsplit(|c: char| c.is_whitespace())
.next()
.unwrap_or("");
match last.chars().count() == 1 && last.chars().all(char::is_uppercase) {
true => format!("{name}."),
false => name.to_string(),
}
}
/// Whether a run of words reads as somebody's name.
fn is_name(text: &str) -> bool {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() || words.len() > 6 {
return false;
}
if text.chars().any(|c| c.is_ascii_digit()) {
return false;
}
// An editor's or translator's credit is a role, not a name.
if text.to_lowercase().contains("ed.") || text.to_lowercase().contains("trans.") {
return false;
}
// A lowercase word in the middle of a name is a particle, an initial the
// scan read in lower case, or a sentence — and there are only so many
// particles.
let sentence = words.iter().any(|word| {
let core = word.trim_matches(|c: char| !c.is_alphanumeric());
core.chars().next().is_some_and(char::is_lowercase)
&& core.chars().count() > 1
&& !PARTICLES.contains(&core.to_lowercase().as_str())
});
if sentence {
return false;
}
// `de la Torre, Carlos` opens on a particle and is still a name; what it
// must not be is a run with no capital in it anywhere.
let first = words[0].trim_matches(|c: char| !c.is_alphanumeric());
let opens_well = first.chars().next().is_some_and(char::is_uppercase)
|| PARTICLES.contains(&first.to_lowercase().as_str())
|| first.chars().count() == 1;
opens_well
&& words
.iter()
.any(|word| word.chars().next().is_some_and(char::is_uppercase))
}
/// What the work appeared in: a journal, or a book's publisher.
fn container(whole: &str, after: &str) -> Option<String> {
// A book names its publisher in an imprint after the title, whether
// bracketed or not. Only after the title: a title with a colon in it —
// and half of them have one — would otherwise read as an imprint.
if let Some(found) = IMPRINT.captures(after) {
if let Some(publisher) = tidy_container(found[1].trim()) {
return Some(publisher);
}
}
// A journal sits between the title and the volume, but a credit can sit
// in front of it — `*Negative Dialectics*, trans. E. B. Ashton` — and a
// translator is neither a journal nor a publisher.
let tail = after.trim_start_matches(|c: char| ",.;:\"”'’ ".contains(c));
let tail = match ROLE_CREDIT.find(tail) {
Some(found) => match tail[found.end()..].find([',', ';', '(']) {
Some(at) => tail[found.end() + at + 1..].trim_start(),
None => "",
},
None => tail,
};
let end = VOLUME
.find(tail)
.map(|found| found.start())
.or_else(|| tail.find(['(', '.', ';']))
.unwrap_or(tail.len());
let found = tidy_container(&tail[..end])?;
// `(London, 1973)` names a place and a year and no publisher. Reporting
// the place as the publisher would be a plausible wrong answer, and the
// place is still there in `text` for anyone who wants it.
match is_place_of_publication(whole, &found) {
true => None,
false => Some(found),
}
}
/// Whether a candidate is the place a book was published rather than its
/// publisher: it sits inside brackets with the year and nothing else.
fn is_place_of_publication(whole: &str, found: &str) -> bool {
let needle = format!("({found}, ");
let Some(at) = whole.find(&needle) else {
return false;
};
let rest = &whole[at + needle.len()..];
let year: String = rest.chars().take_while(char::is_ascii_digit).collect();
year.len() == 4 && rest[year.len()..].starts_with(')')
}
/// Reduce a run of text to the name of the thing a work appeared in.
///
/// What arrives is a whole trailing segment — the journal with its volume,
/// issue and pages after it, or the imprint with its year. All of those are
/// recorded in their own fields already, so what is wanted here is only the
/// name, and everything that is somewhere else on the record is cut away.
fn tidy_container(raw: &str) -> Option<String> {
let text = strip_markup(raw);
// An identifier is not a container. A preprint has no journal, and
// saying `arXiv:1706.03762` where a journal goes invents one.
if IDENTIFIER_ONLY.is_match(text.trim()) {
return None;
}
let mut text = text.as_str();
for pattern in [&*VOLUME, &*RANGE_PAGES, &*COMMA_YEAR, &*PP] {
if let Some(found) = pattern.find(text) {
text = &text[..found.start()];
}
}
let text = tidy_title(text);
let text = text.trim_end_matches(|c: char| ",.;: ".contains(c));
// `Oxford: Clarendon Press` names a place and then a publisher, and it
// is the publisher a catalogue is keyed on. The place stays in `text`.
let text = match text.split_once(": ") {
Some((place, publisher))
if place.chars().count() <= 40
&& !place.chars().any(|c| c.is_ascii_digit())
&& publisher.chars().count() >= 3 =>
{
publisher
}
_ => text,
};
// `Sociometry 14` is a journal and a volume, and the volume is a field
// this record does not keep. What it is not is part of the name.
let text = match text.rsplit_once(' ') {
Some((name, last))
if name.chars().count() >= 3
&& last.chars().all(|c| c.is_ascii_digit())
&& !last.is_empty() =>
{
name.trim_end_matches(',')
}
_ => text,
};
match text.chars().count() >= 3 && text.chars().any(char::is_alphabetic) {
true => Some(text.to_string()),
false => None,
}
}
/// Divide an entry that marked none of its parts, on its sentence stops.
///
/// The order is the one every style agrees on: who, then what, then where.
/// It is weaker than reading a marked-up title and is only reached when the
/// entry offered nothing to read.
fn by_sentence(text: &str) -> (Vec<String>, Option<String>, Option<String>) {
let head = match PAREN_YEAR.find(text) {
// Author-date: the authors are what comes before the bracketed year.
Some(found) => &text[..found.start()],
None => text,
};
let segments: Vec<&str> = sentences(text);
let found_authors = match PAREN_YEAR.find(text) {
Some(_) => authors(head),
None => segments
.first()
.map(|first| authors(first))
.unwrap_or_default(),
};
// One segment and no names in it means the entry was never divided: the
// commas that hold it together say nothing about where the title starts.
// Reporting the whole entry as the title would be a wrong answer dressed
// as a settled one, which is worse than no answer at all.
if segments.len() < 2 && found_authors.is_empty() {
return (found_authors, None, None);
}
// The title is the first segment that is not the authors and is not an
// imprint. An imprint has a colon in it, or names a publisher.
let mut rest = segments.iter().skip(match found_authors.is_empty() {
true => 0,
false => 1,
});
let title = rest
.by_ref()
.find(|segment| {
let segment = segment.trim();
segment.chars().count() >= 4 && !PAREN_YEAR.is_match(segment)
})
.map(|segment| tidy_title(segment));
let container = rest
.find(|segment| {
let segment = segment.trim();
segment.chars().count() >= 3 && segment.chars().any(char::is_alphabetic)
})
.and_then(|segment| tidy_container(segment));
(found_authors, title, container)
}
/// Cut an entry at its sentence stops, which is where styles put their parts.
///
/// A stop inside an initial — `J. R. R.` — or after a known abbreviation is
/// not a stop, which is why this is not a split on '.'.
fn sentences(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut at = 0;
while at < bytes.len() {
if matches!(bytes[at], b'.' | b'?' | b'!') {
let before = text[start..at].trim_end();
let last = before
.rsplit(|c: char| c.is_whitespace())
.next()
.unwrap_or("");
let initial = last.chars().count() <= 1;
let abbreviated = matches!(
last.to_lowercase().as_str(),
"ed" | "eds"
| "trans"
| "vol"
| "no"
| "pp"
| "p"
| "rev"
| "st"
| "mr"
| "mrs"
| "dr"
| "jr"
| "sr"
| "cf"
| "esp"
| "repr"
);
let followed_by_space = bytes.get(at + 1).is_some_and(|c| *c == b' ');
// A question mark or an exclamation is always a stop; a full
// stop is one unless it belongs to an initial or an abbreviation.
let stop = bytes[at] != b'.' || (!initial && !abbreviated);
if stop && followed_by_space {
out.push(text[start..=at].trim_end_matches('.').trim());
start = at + 1;
}
}
at += 1;
}
if start < text.len() {
let tail = text[start..].trim();
if !tail.is_empty() {
out.push(tail);
}
}
out.into_iter().filter(|part| !part.is_empty()).collect()
}
/// What sort of work the entry points at.
fn kind(text: &str, found: &Reference) -> Kind {
if found.ids.iter().any(|id| id.kind == "arxiv") {
return Kind::Article;
}
let quoted = QUOTED.is_match(text);
let within = WITHIN.is_match(text);
if quoted && within {
return Kind::Chapter;
}
if found
.ids
.iter()
.any(|id| id.kind == "doi" || id.kind == "issn")
{
return Kind::Article;
}
if quoted && VOLUME.is_match(text) {
return Kind::Article;
}
// An imprint — a place, a colon, a publisher — is what a book has and a
// journal article does not. Asked of the entry rather than of the
// container, because the container has had the place cut off it by then.
let publisher = IMPRINT.is_match(text)
|| found
.container
.as_deref()
.is_some_and(|container| PUBLISHER_WORD.is_match(container));
if publisher {
return match within {
true => Kind::Chapter,
false => Kind::Book,
};
}
if found.ids.iter().any(|id| id.kind == "isbn") {
return Kind::Book;
}
if VOLUME.is_match(text) && found.container.is_some() {
return Kind::Article;
}
// A container that is not a publisher, with a run of pages inside it, is
// a journal however sparely the style wrote the volume out.
let ranged = found
.pages
.as_deref()
.is_some_and(|pages| pages.contains(['-', '–', '—']));
if ranged && found.container.is_some() {
return Kind::Article;
}
if found.ids.iter().any(|id| id.kind == "url") {
return Kind::Web;
}
Kind::Other
}
#[cfg(test)]
mod tests {
use super::*;
fn read(text: &str) -> Reference {
one(text, None).expect("a reference")
}
#[test]
fn a_book_in_a_bibliography() {
let found =
read("Smith, John. *The Making of the Second Estate*. Oxford: Clarendon Press, 1998.");
assert_eq!(found.authors, ["Smith, John"]);
assert_eq!(found.year, Some(1998));
assert_eq!(
found.title.as_deref(),
Some("The Making of the Second Estate")
);
assert_eq!(found.container.as_deref(), Some("Clarendon Press"));
assert_eq!(found.kind, Kind::Book);
// The year of an imprint is not the page the work was cited at.
assert_eq!(found.pages, None);
}
#[test]
fn an_article_with_its_journal_in_italics() {
let found = read(
"Doe, Jane, and Peter Brown. \"Obligation and Its Limits.\" \
*Journal of Medieval Studies* 12, no. 3 (2001): 45-67.",
);
assert_eq!(found.authors, ["Doe, Jane", "Peter Brown"]);
assert_eq!(found.title.as_deref(), Some("Obligation and Its Limits"));
assert_eq!(
found.container.as_deref(),
Some("Journal of Medieval Studies")
);
assert_eq!(found.pages.as_deref(), Some("45-67"));
assert_eq!(found.kind, Kind::Article);
}
#[test]
fn an_author_date_entry_puts_the_journal_in_the_italics_not_the_title() {
let found = read(
"Peirce, C. S. (1878). How to make our ideas clear. \
*Popular Science Monthly*, 12, 286-302.",
);
assert_eq!(found.authors, ["Peirce, C. S."]);
assert_eq!(found.title.as_deref(), Some("How to make our ideas clear"));
assert_eq!(found.container.as_deref(), Some("Popular Science Monthly"));
assert_eq!(found.pages.as_deref(), Some("286-302"));
}
#[test]
fn an_author_date_entry_need_not_bracket_its_year() {
let found = read(
"Altmann, M. 1951, A Study of Behaviour in a Horse-Mule Group. Sociometry 14, 351-354.",
);
assert_eq!(found.authors, ["Altmann, M."]);
assert_eq!(found.year, Some(1951));
assert_eq!(
found.title.as_deref(),
Some("A Study of Behaviour in a Horse-Mule Group")
);
// The volume belongs to the journal and is not part of its name.
assert_eq!(found.container.as_deref(), Some("Sociometry"));
}
#[test]
fn a_chapter_appeared_in_the_book_and_not_in_its_publisher() {
let found = read(
"Brown, Peter. \"Sorcery, Demons and the Rise of Christianity.\" \
In *Witchcraft Confessions and Accusations*, edited by Mary Douglas, \
17-45. London: Tavistock, 1970.",
);
assert_eq!(found.kind, Kind::Chapter);
assert_eq!(
found.title.as_deref(),
Some("Sorcery, Demons and the Rise of Christianity")
);
assert_eq!(
found.container.as_deref(),
Some("Witchcraft Confessions and Accusations")
);
}
#[test]
fn a_note_that_marks_nothing_up_is_still_read() {
let found = read("John MacGowan, The Imperial History of China (1897), p. 154.");
assert_eq!(found.authors, ["John MacGowan"]);
assert_eq!(
found.title.as_deref(),
Some("The Imperial History of China")
);
assert_eq!(found.year, Some(1897));
assert_eq!(found.pages.as_deref(), Some("154"));
}
#[test]
fn what_a_note_says_before_the_citation_is_not_the_author() {
for lead in ["Quoted in ", "See ", "See, for example, ", "Cf. "] {
let found = read(&format!(
"{lead}Eugene F. Hemrick, One Nation Under God (Huntington, IN: Our Sunday Visitor, 2001), 72-74."
));
assert_eq!(found.authors, ["Eugene F. Hemrick"], "{lead}");
assert_eq!(
found.container.as_deref(),
Some("Our Sunday Visitor"),
"{lead}"
);
}
}
#[test]
fn a_contributors_biography_is_not_a_citation() {
// Opens exactly as a Chicago note does — a name, a comma — and then
// says what the person is. These sit in the same back matter as the
// endnotes and refer to nothing.
for bio in [
"Thomas L. Sheridan, SJ, is Professor Emeritus of Theology at St \
Peter's College. He is the author of Newman on Justification (1967).",
"**S. Marc Cohen** is Professor of Philosophy at the University of \
Washington, where he teaches the history of ancient philosophy.",
"William J. Gavin was a Professor of Philosophy at the University \
of Southern Maine for 34 years.",
] {
assert!(one(bio, None).is_none(), "{bio}");
}
}
#[test]
fn an_editor_is_a_person_and_not_a_disqualification() {
let found = read(
"Eberhardt, Christian, ed. *Ritual and Metaphor: Sacrifice in the Bible*. Atlanta: Society of Biblical Literature, 2011.",
);
assert_eq!(found.authors, ["Eberhardt, Christian"]);
assert_eq!(
found.title.as_deref(),
Some("Ritual and Metaphor: Sacrifice in the Bible")
);
}
#[test]
fn an_entry_with_nothing_but_commas_is_read_between_the_names_and_the_imprint() {
let found = read(
"Schopflin, George, Nations, Identity, Power, New York: New York University Press, 2000",
);
assert_eq!(found.authors, ["Schopflin, George"]);
assert_eq!(found.title.as_deref(), Some("Nations, Identity, Power"));
assert_eq!(
found.container.as_deref(),
Some("New York University Press")
);
assert_eq!(found.year, Some(2000));
}
#[test]
fn a_short_note_names_a_work_without_a_year_or_a_publisher() {
// The form a history book uses for every citation after the first:
// a surname, a short title, and where in it. No year, no imprint.
let found = read("Unger, Dutch Shipbuilding, 33-4.");
assert_eq!(found.authors, ["Unger"]);
assert_eq!(found.title.as_deref(), Some("Dutch Shipbuilding"));
assert_eq!(found.pages.as_deref(), Some("33-4"));
assert_eq!(found.year, None);
// The locators trailing it are not part of the title.
let volume = read("Sjoerds, Algemene Beschryvinge, i, f I, 501-2.");
assert_eq!(volume.title.as_deref(), Some("Algemene Beschryvinge"));
// The quotation marks round a short article title are the style's.
let quoted = read("Schoeck, 'Agricola and Erasmus', 181, 184.");
assert_eq!(quoted.title.as_deref(), Some("Agricola and Erasmus"));
}
#[test]
fn an_entry_ending_on_a_year_is_an_imprint_and_not_a_page() {
// `short_note` must not take `Oxford, Clarendon Press, 1998` for a
// name, a title and a page.
let found = read("Miller, David, On Nationality, Oxford: Clarendon Press, 1995");
assert_eq!(found.authors, ["Miller, David"]);
assert_eq!(found.title.as_deref(), Some("On Nationality"));
assert_eq!(found.container.as_deref(), Some("Clarendon Press"));
assert_eq!(found.year, Some(1995));
}
#[test]
fn the_chicago_forms_a_history_or_a_theology_book_sets_its_notes_in() {
// A place and a year, no colon and no publisher word anywhere.
let place = read("Cheyne, T. K., Founders of Old Testament Criticism, London, 1893.");
assert_eq!(place.authors, ["Cheyne, T. K."]);
assert_eq!(
place.title.as_deref(),
Some("Founders of Old Testament Criticism")
);
assert_eq!(place.year, Some(1893));
// London is where it was published, not who published it.
assert_eq!(place.container, None);
// A volume is where in the work, not what the work is called.
let volume =
read("Erich Caspar, Geschichte des Papsttums, Vol. 2 (Tubingen, 1933), pp. 587-89");
assert_eq!(volume.title.as_deref(), Some("Geschichte des Papsttums"));
assert_eq!(volume.container, None);
assert_eq!(volume.pages.as_deref(), Some("587-89"));
// A bracketed imprint need not carry a year.
let undated =
read("St Valerian, Homilies (Washington D.C.: Catholic University of America Press).");
assert_eq!(undated.title.as_deref(), Some("Homilies"));
assert_eq!(
undated.container.as_deref(),
Some("Catholic University of America Press")
);
// A title with a colon in it is not an imprint.
let colon = read(
"BORRESEN, K. E. (1981), Subordination and Equivalence: The Nature \
and Role of Woman, Washington: University Press of America.",
);
assert_eq!(
colon.title.as_deref(),
Some("Subordination and Equivalence: The Nature and Role of Woman")
);
}
#[test]
fn a_work_cited_by_the_shelf_it_sits_on_is_not_named() {
// Migne and the rest are series, not works.
for series in [
"PG 4: 264, 337, 360, 393, 405; PG 91: 1312-13",
"NPNF 2/5: 430",
] {
assert!(one(series, None).is_none(), "{series}");
}
// A name and a page with nothing between them names no work either.
let bare = read("Lowrie and Fletcher, p. 133; Anderson, p. 400.");
assert!(bare.continuation);
assert_eq!(bare.authors, ["Lowrie and Fletcher", "Anderson"]);
assert!(bare.missing().is_empty());
}
#[test]
fn a_continuation_is_flagged_and_not_resolved() {
let found = read("Ibid., pp. 23-24, 39.");
assert!(found.continuation);
assert!(found.title.is_none());
assert!(found.authors.is_empty());
assert_eq!(found.pages.as_deref(), Some("23-24"));
// Nothing to ask a model about either: the entry does not say.
assert!(found.missing().is_empty());
}
#[test]
fn identifiers_are_normalised_and_the_doi_is_not_repeated_as_a_url() {
let found = read(
"Chen, L. (2019). A study of things. Nature, 570(7761), 321-325. \
https://doi.org/10.1038/s41586-019-1234-5",
);
assert_eq!(found.ids.len(), 1);
assert_eq!(found.ids[0].kind, "doi");
assert_eq!(found.ids[0].id, "10.1038/s41586-019-1234-5");
assert_eq!(found.kind, Kind::Article);
}
#[test]
fn a_preprint_has_an_identifier_and_no_journal() {
let found =
read("Vaswani, A., et al. (2017). Attention is all you need. arXiv:1706.03762.");
assert_eq!(found.ids[0].kind, "arxiv");
assert_eq!(found.kind, Kind::Article);
// Naming arXiv as the journal would invent one.
assert_eq!(found.container, None);
}
#[test]
fn chapter_and_verse_is_not_a_work_in_a_bibliography() {
for verse in [
"Matthew 4:17",
". Cf. Deuteronomy 18:15-19; Isaiah 53; Acts 1:6; 2:16-36; 1 Peter 2:6-8",
"1 Peter 2:6-8",
"Matthew 13:43; cf Exodus 34:29-35",
"Cf. Matthew 3:7-9; Acts 1:8; 11:1-18; 15:16-17; Galatians 3:14;28",
"Colossians 1:18,24; Romans 12:5; 1 Corinthians 12:12-13. 19. 2 Corinthians 11:2",
"Galatians 6:15-16; also cf. John. 1:11-13; Romans 2:28-29; James 1:1 13. Romans 9-11",
] {
assert!(one(verse, None).is_none(), "{verse}");
}
// A journal and its volume look similar and are not the same thing.
assert!(
one(
"Altmann, M. 1951, A Study of Behaviour. Sociometry 14, 351-354.",
None
)
.is_some()
);
}
#[test]
fn what_a_browser_printed_at_the_foot_of_a_page_is_not_a_reference() {
for junk in [
"http://www.u.arizona.edu/~chalmers/biblio/1.html (54 of 100) [4/7/2002 1:48:08 PM]",
"http://www.u.arizona.edu/~chalmers/papers/content95.html",
"https://example.org/a/b (3 of 9)",
] {
assert!(one(junk, None).is_none(), "{junk}");
}
// A URL that is part of a reference still is one.
let real = read(
"Chalmers, D. (1996). *The Conscious Mind*. Oxford University Press. http://consc.net/book",
);
assert_eq!(real.title.as_deref(), Some("The Conscious Mind"));
}
#[test]
fn a_short_cite_names_a_work_it_does_not_name() {
// `Spade 1980` points at the full entry elsewhere in the same
// bibliography, the way `ibid.` points at the note above.
let found = read("Spade 1980, 12.");
assert!(found.continuation);
assert_eq!(found.authors, ["Spade"]);
assert_eq!(found.year, Some(1980));
assert_eq!(found.title, None);
// Nothing to ask anybody about, so nothing does.
assert!(found.missing().is_empty());
let several = read("Trentman 1970; Spade 1980.");
assert!(several.continuation);
assert_eq!(several.authors, ["Trentman", "Spade"]);
}
#[test]
fn a_heading_or_a_page_number_is_not_a_reference() {
for junk in ["Bibliography", "233", "Notes to pages 12-14", "see below"] {
assert!(one(junk, None).is_none(), "{junk}");
}
}
#[test]
fn the_same_entry_reads_the_same_way_every_time() {
let entry = "Smith, John. *A Title*. Oxford: Clarendon Press, 1998.";
assert_eq!(one(entry, None), one(entry, None));
}
#[test]
fn a_year_range_inside_a_title_is_not_a_page_range() {
let found = read(
"A. J. P. Taylor, The Struggle for Mastery in Europe 1848-1918 (Oxford: Oxford University Press, 1954), p. 102.",
);
assert_eq!(found.pages.as_deref(), Some("102"));
}
#[test]
fn a_translator_is_not_the_journal_it_stands_in_front_of() {
let found = read(
"Theodor W. Adorno, *Negative Dialectics*, trans. E. B. Ashton (London, 1973), 365.",
);
assert_eq!(found.title.as_deref(), Some("Negative Dialectics"));
// `(London, 1973)` names a place and a year and no publisher at all.
assert_eq!(found.container, None);
assert_eq!(found.pages.as_deref(), Some("365"));
}
#[test]
fn a_remark_about_the_argument_is_not_a_reference() {
let found = one(
"Attention is a limited resource. Many psychologists have remarked \
on this. It follows that what a person attends to is what they \
come to know. The point is developed at length below. I am \
grateful to the seminar for pressing me on it.",
None,
);
assert!(found.is_none(), "{found:#?}");
}
#[test]
fn a_page_reference_is_not_a_title() {
// Two notes run together, or a note that names no work at all.
let found = read(
"See Hefele-Leclerq (1909), 72-7, though Diekamp (1899) should have concluded the debate.",
);
assert_eq!(found.title, None, "{found:#?}");
assert!(found.missing().contains(&"title"));
}
#[test]
fn a_title_the_cut_landed_inside_of_is_not_reported() {
// The chunk began part-way through this entry, so the opening of the
// title — and its opening quotation mark — are on the page before.
let found = read(
"Mediterraneans.\" In Rethinking the Mediterranean, ed. William V. \
Harris, 64-93. Oxford: Oxford University Press, 2005.",
);
assert_eq!(found.title, None, "{found:#?}");
assert_eq!(found.container.as_deref(), Some("Oxford University Press"));
}
#[test]
fn italics_at_the_head_of_the_tail_are_the_title_and_not_the_journal() {
// `Peirce (1878). Plain title. *Popular Science Monthly*` puts the
// journal in italics after the title; `Goodwin (1990). *Title*
// Publisher` puts the title in them. Where they sit decides which.
let found = read(
"Goodwin, F. K. and Jamison, K. R. (1990). *Manicdepressive illness* \
Oxford University Press, New York.\\",
);
assert_eq!(found.authors, ["Goodwin, F. K.", "Jamison, K. R."]);
assert_eq!(found.year, Some(1990));
assert_eq!(found.title.as_deref(), Some("Manicdepressive illness"));
}
#[test]
fn a_title_may_quote_something_but_not_half_quote_it() {
let quoting =
read("Austin, D. F. 1990. What's the Meaning of \"This\"? Cornell University Press.");
assert_eq!(
quoting.title.as_deref(),
Some("What's the Meaning of \"This\"?")
);
// One quotation mark means the opening is on the page before.
let cut = read(
"Mediterraneans.\" In Rethinking the Mediterranean, ed. William V. \
Harris, 64-93. Oxford: Oxford University Press, 2005.",
);
assert_eq!(cut.title, None, "{cut:#?}");
}
#[test]
fn an_edition_in_a_title_is_not_an_editors_credit() {
// `, 2nd ed. (` is part of the title; `, ed. William Harris` is not.
let found = read(
"Carter Lindberg, The European Reformations, 2nd ed. (West Sussex, UK: Blackwell, 2010), 161.",
);
assert_eq!(
found.title.as_deref(),
Some("The European Reformations, 2nd ed")
);
assert_eq!(found.container.as_deref(), Some("Blackwell"));
}
#[test]
fn sentences_do_not_stop_at_an_initial_or_an_abbreviation() {
assert_eq!(
sentences("Tolkien, J. R. R. The Hobbit, rev. ed. London, 1951."),
["Tolkien, J. R. R. The Hobbit, rev. ed. London, 1951."]
);
}
}