Files
catena/crates/citefind/src/parse.rs
T
funkpower b53ef4329c citefind: a note that remarks first and cites second
The bar a guessed title has to clear was waived when the entry also gave up
a container, on the reasoning that a container vouches for the rest of the
reading. Auditing the corpus rather than the reference chapters showed that
is wrong, and wrong on the commonest note there is:

    As Lewis White Beck has rightly put it. See his Early German
    Philosophy (Cambridge, Mass., 1969), p. 439.

The remark opens, the citation follows, and the second sentence becomes the
container — so the first was stored as the work and the book named in it was
lost. Neither a siglum nor a remark is the name of a work now, whatever else
came off the entry.

Measured where these notes actually live, which is flagged body chunks
rather than bibliography chapters: on the corpus the class ran at 1.43% of
rows naming a work. The cost is 46 rows in 34,359 on the reference holdout,
where the share naming an author rises from 88.1% to 88.2%.
2026-09-20 18:11:12 -04:00

2360 lines
95 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
"#,
)
.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* $
# The navigation strip a PDF reader leaves in the text, and the
# local path it points at.
| (?: < \s* previous \s+ page | next \s+ page \s* > )
| file:///
",
)
.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+and)\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 note that keys a topic to a short cite: `Mate selection: Buss, 1989;
/// Symons, 1979.` The topic is the author's own words about their own
/// argument; the cites name works given in full in the bibliography.
static KEYED_CITE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) ^ [^:;,]{3,60} : # the topic, in the note's own words
(?: \s* (?:and\s+|see\s+)?
\p{Lu}[\p{L}'\u{2019}\-]{1,24} # a surname
(?: \s+ (?:and|&) \s+ \p{Lu}[\p{L}'\u{2019}\-]{1,24}
| \s+ et \s+ al\.? )? # or two, or et al.
\s*,\s* (?:1[4-9]|20)\d{2} [a-z]? # and the year
\s* [;,]? )+ \.? \s* $",
)
.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")
});
/// What a note that thanks people opens with. These sit among the endnotes,
/// name a dozen people, and cite nothing.
static ACKNOWLEDGEMENT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^(?:(?:my\s+|with\s+)?thanks\b|i\s+(?:am\s+(?:grateful|indebted)|thank|should\s+like\s+to\s+thank|owe)|this\s+(?:paper|chapter|essay|paper)\s+(?:was|is|grew)|an\s+earlier\s+version|parts?\s+of\s+this)",
)
.expect("a valid pattern")
});
/// How a translator or an editor signs a note of their own: an em dash and
/// their role, at the very end. `Latin: "nature of the thing." — Ed.` is a
/// gloss on the text in front of it and cites nothing.
static GLOSS_SIGNATURE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)[—–-]{1,2}\s*(?:ed|tr|trans|transl|a\.\s*n|au)\.?\s*$")
.expect("a valid pattern")
});
/// `Hoyt, Edwin P. The Supersalesmen.` is one segment, because the stop
/// after an initial is not a sentence stop. An inverted name whose forenames
/// end in an initial, with more words after it, ends at that initial — what
/// follows is the title. Leaving the two joined cost both: the name came
/// back with a title stuck to it, and the imprint after it became the work.
static NAME_THEN_TITLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^([^,]{2,40},(?:\s+[A-Z][A-Za-z.'\-]*)*?\s+[A-Z]\.)\s+([A-Z]\S*(?:\s+\S+)+)$")
.expect("a valid pattern")
});
/// A segment that is nothing but where the book was published, which is not
/// what it is called. A place of up to three words, a colon, and then either
/// a bare year — `New York: 1928` — or a publisher and a year: `Leiden:
/// Brill, 2003`. Asked that narrowly because a title may have a colon in it
/// too, and `Russian Popular Culture: Entertainment and Society since 1900`
/// has to survive it.
static IMPRINT_ALONE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
^ [A-Z][A-Za-z.\-']* (?: \ [A-Za-z.\-']+ ){0,2} \s* : \s*
(?:
(?: 1[5-9]\d\d | 20[0-2]\d ) \.? $
| .{0,60} , \s* (?: 1[5-9]\d\d | 20[0-2]\d ) [a-z]? \.? $
)",
)
.expect("a valid pattern")
});
/// A finite verb, which a remark has and the name of a work does not.
/// `Quaestiones Parisienses` and `The Lord of the Rings` are noun phrases;
/// `The Commentator seems to have based himself on the Bible` is a note
/// about the argument, and a page of endnotes is mostly those.
static PREDICATE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix) \b (?:
is|are|was|were|be|been|has|have|had|does|did|do
| can|could|may|might|must|shall|should|will|would
| seems?|says?|remarks?|argues?|holds?|means?|refers?|thinks?
| shows?|gives?|takes?|makes?|enters?|shed|follows?|explains?
) \b",
)
.expect("a valid pattern")
});
/// An abbreviation standing for a work, and where in it: `H, 272`,
/// `FR,120-122`, `GNO`, `Kgs 17:21`. The key that expands it is a page of
/// its own that the entry does not carry, so on its own it names nothing.
static SIGLUM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Z][A-Za-z]{0,4}[\s,.]*(?:\d|$)").expect("a valid pattern"));
/// An editor's or translator's credit where an author's name would be:
/// `Barratt, Alexandra, ed. Women's Writing in Middle English.` The role is
/// not a sentence stop, so the name and the title run together into one
/// segment and neither is read.
static EDITOR_FORM: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^([^.]{2,60}),\s+(?:eds?|trans|comps?|rev)\.\s+(\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(|| {
// A page range after a comma counts even with no `pp.` in front of it:
// `edited by H.J. Störig, 38-70. Darmstadt:` is a chapter in a book, and
// six sentences long because German titles are.
Regex::new(r"(?i)\bpp?\.\s*\d|\bvol\.\s*\d|\bno\.\s*\d|,\s*\d{1,5}\s*[-–—]\s*\d{1,5}\b")
.expect("a valid pattern")
});
/// How many words a reference runs to before it is prose.
///
/// The longest real entry in the corpus sample — a chapter in an edited
/// volume, with two titles, an editor, an imprint and a page range — is
/// under sixty. A hundred and twenty is generous and still nowhere near a
/// paragraph of body text.
const MAX_WORDS: usize = 120;
/// 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(|| {
// Pandoc escapes an underscore, so a rule of them arrives as
// `\_\_\_\_\_` rather than `_____`.
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)
|| ACKNOWLEDGEMENT.is_match(text)
|| GLOSS_SIGNATURE.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.
// A keyed cite names no author of its own — `Mate selection:` is the
// note's own words — so nothing is carried off it but the year.
if is_keyed_cite(text) {
out.continuation = true;
out.year = year(text);
return Some(out);
}
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 past a few sentences, or past a couple of hundred
// words, is a remark about the argument. The LLM path this replaces was
// told to skip those; a parser has to decide.
//
// A page reference or an identifier buys an exemption, because a note
// that cites a work and then discusses it is still a citation. An
// imprint does not: a colon in running prose — `his 1945 report,
// Science: The Endless Frontier` — is shaped exactly like `Place:
// Publisher`, and used to let whole paragraphs of body text through.
let exempt = CITATION_ANCHOR.is_match(text) || !ids(text).is_empty();
if !exempt
&& (sentences(text).len() > MAX_SENTENCES || text.split_whitespace().count() > MAX_WORDS)
{
return None;
}
out.ids = ids(text);
out.year = year(text);
out.pages = pages(text);
// Whether the title was read off something the entry marked as one, or
// guessed at by cutting the entry at its sentence stops. The guess is
// held to a higher bar below, because nothing in the entry vouched for
// it.
let mut guessed = false;
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));
// Quotation marks count as the title only where they open the tail.
// Further in they are a quotation inside a title, not the title.
// Whether a span opens the tail: it begins after the year, and what
// sits between the two is punctuation and space. Asked that way
// rather than by counting bytes — a curly quotation mark is three of
// them, so a tolerance of two silently missed every author-date
// entry that set its title in curly quotes, which is most of them.
let opens = |at: Option<(usize, usize)>| {
at.is_some_and(|at| {
at.0 >= year_at.1
&& text[year_at.1..at.0]
.chars()
.all(|c| c.is_whitespace() || ".,:;'\u{2018}\u{201c}\"".contains(c))
})
};
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) = editor_form(text)
.or_else(|| 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);
guessed = found_title.is_some();
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;
}
// `See R. Hirzel, Die Person (1914), p. 7.` reads as an author-date entry
// — the head is all capitalised words and the year is in brackets — and
// then yields no title, because the title is sitting inside what was
// taken for the names. A note that names a work is the better reading of
// the same line, and having a title is how you can tell.
if out.title.is_none() {
if let Some((names, title, container)) = note_form(text) {
if title.is_some() {
out.authors = names;
out.title = title;
out.container = container.or(out.container.take());
}
}
}
// A guessed title is only as good as the entry being a reference at all.
// Where nothing else was read off it — no author, no identifier, no
// container — the guess is the whole of what the record would carry, and
// a remark about the argument or a siglum dressed as a title is worse
// than no answer, because it is stored and looks settled.
if guessed && out.authors.is_empty() && out.ids.is_empty() {
let refused = out.title.as_deref().is_some_and(|title| {
// Neither a siglum nor a remark is the name of a work, whatever
// else came off the entry. A container does not vouch for the
// title here: the commonest note of all remarks first and cites
// second — `As Lewis White Beck has rightly put it. See his
// Early German Philosophy (Cambridge, Mass., 1969), p. 439.` —
// and reading that as a work with a container stored the remark
// and lost the book.
SIGLUM.is_match(title) || PREDICATE.is_match(title)
});
if refused {
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);
};
// A span the style marked as a title is a title, whatever it opens on:
// `*1 & 2 Thessalonians*` is a book of the Bible, not a page number.
let cleaned = tidy_marked_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(|| {
// `Schleiermacher, Friedrich. 1813/1963.` gives the year it was written
// and the year of the edition used. The first is the one that dates the
// work; the second still has to be got past.
Regex::new(r"^(.{2,80}?)[,.]?\s+((?:1[4-9]|20)\d{2})[a-z]?(?:\s*/\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];
// A rule of dashes stands in for the author of the entry above, and an
// author-date entry may open on one: `—— (1994), Christianizing Homer`.
// Only a bare rule, though: `\_\_\_\_\_. "Die Frage nach der Echtheit."
// *Theologische Blätter* 20 (1941)` opens on one too, and everything
// between it and the year is the title and the journal.
let bare_rule = REPEATED_AUTHOR.find(head).is_some_and(|found| {
head[found.end()..]
.trim_matches(|c: char| ".,; ".contains(c))
.is_empty()
});
if head.chars().count() > 120 || !(reads_as_names(head) || bare_rule) {
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;
}
// `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() {
return None;
}
// The imprint is whichever comma-part carries the colon, counted in
// commas rather than matched by a pattern: 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 such
// part, because a title has a colon in it as often as an imprint does —
// and if the only one is inside the title, the colon says nothing about
// where the imprint is and the year does.
let colon = parts
.iter()
.enumerate()
.rev()
.find(|(at, part)| *at > used && part.contains(':'))
.map(|(at, _)| at);
let at = match colon {
Some(at) => at,
None => {
// `Cheyne, T. K., Founders of Old Testament Criticism, London,
// 1893.` — the year is last and the place before it, which is
// not a publisher and is not kept.
let last = parts.last()?.trim().trim_end_matches('.');
if !looks_like_a_year(last) {
return None;
}
parts.len() - 2
}
};
if 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.
let publisher = match parts[at].contains(':') {
true => tidy_container(parts[at]),
false => None,
};
Some((names, Some(title), publisher))
}
/// Read an entry whose author is credited as its editor.
///
/// `Barratt, Alexandra, ed. Women's Writing in Middle English. London:
/// Longman, 1992.` — `ed.` is an abbreviation rather than a sentence stop,
/// so the name and the title are one segment until the role is taken out.
fn editor_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>)> {
let found = EDITOR_FORM.captures(text.trim())?;
let names = authors(&found[1]);
if names.is_empty() {
return None;
}
let rest = found[2].trim();
let title = tidy_title(&strip_markup(sentences(rest).first()?));
if title.chars().count() < 4 {
return None;
}
Some((names, Some(title), container(text, rest)))
}
/// 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 a note is one or more topics keyed to short cites and nothing
/// else: `Mate selection: Buss, 1989; Symons, 1979. Jealousy: Buss, 2000.`
fn is_keyed_cite(text: &str) -> bool {
let parts = sentences(text);
!parts.is_empty()
&& parts
.iter()
.all(|part| KEYED_CITE.is_match(part.trim_end_matches('.')))
}
/// 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,
}
}
/// The same, for a run the style itself marked as a title.
///
/// The rule against a title opening on a number is about a page reference
/// picked up by mistake. Inside quotation marks or italics there is no
/// mistake to make: `*1 & 2 Thessalonians*` is what the entry says the work
/// is called.
fn tidy_marked_title(raw: &str) -> String {
let text = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let text = text.trim_end_matches('\\');
let text =
text.trim_matches(|c: char| ",.;:'\u{2018}\u{2019}".contains(c) || c.is_whitespace());
let without_volume = TRAILING_VOLUME.replace(text, "").into_owned();
let text = TRAILING_CREDIT.replace(&without_volume, "").into_owned();
let text = text.as_str();
let text = text.trim_end_matches(|c: char| ",.;: ".contains(c));
match MARKED_NOT_A_TITLE.is_match(text) || !quotes_balance(text) {
true => String::new(),
false => text.to_string(),
}
}
/// What a marked-up title still may not be: two entries run together, or a
/// reference to where a work is rather than what it is.
static MARKED_NOT_A_TITLE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?ix) \bpp?\.\s*\d | ; | \bibid\b | \\").expect("a valid pattern")
});
/// An editor's or translator's credit following a title: `Saturae XIV, ed.
/// J. D. Duff`. It belongs to the entry, not to the work's name.
static TRAILING_CREDIT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i),\s+(?:eds?|trans|translated\s+by|comp)\.?\s+\p{Lu}.*$")
.expect("a valid pattern")
});
/// 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('\\');
// A conversion that lost an opening quotation mark leaves its partner at
// the end. That is a stray, not evidence the cut went wrong — which is
// what one in the middle of a run means.
let text = match text.matches('"').count() == 1 && text.ends_with('"') {
true => text.trim_end_matches('"'),
false => text,
};
// 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 without_volume = TRAILING_VOLUME.replace(text, "").into_owned();
let text = TRAILING_CREDIT.replace(&without_volume, "").into_owned();
let text = text.as_str();
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;
}
// A bracket or a colon belongs to an imprint or a title, never to a
// name: `Philosophy (Bloomington: Indiana University Press)` is not one.
if text.contains(['(', ')', ':', '[', ']']) {
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 mut segments: Vec<String> = sentences(text)
.iter()
.map(|segment| segment.trim().to_string())
.collect();
// Put back the cut the stop after an initial did not make.
if PAREN_YEAR.find(text).is_none() {
if let Some(caught) = segments
.first()
.and_then(|first| NAME_THEN_TITLE.captures(first))
{
let parts = [caught[1].to_string(), caught[2].to_string()];
segments.splice(0..1, parts);
}
}
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)
&& !IMPRINT_ALONE.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_title_may_be_set_in_curly_quotes() {
// A curly quotation mark is three bytes, and a tolerance counted in
// bytes missed every entry set this way — which is most of them.
let chapter = read(
"Shaw, Joseph W., and Marjatta Luton. 2000. \u{201c}The Foreshore at \
Akrotiri.\u{201d} In *Wall Paintings of Thera*, 453-66.",
);
assert_eq!(chapter.authors, ["Shaw, Joseph W.", "Marjatta Luton"]);
assert_eq!(chapter.title.as_deref(), Some("The Foreshore at Akrotiri"));
assert_eq!(
chapter.container.as_deref(),
Some("Wall Paintings of Thera")
);
let article = read(
"James, Liz. 2003. \u{201c}Color and Meaning in Byzantium,\u{201d} \
*Journal of Early Christian Studies* 11.2: 223-233.",
);
assert_eq!(
article.title.as_deref(),
Some("Color and Meaning in Byzantium")
);
assert_eq!(
article.container.as_deref(),
Some("Journal of Early Christian Studies")
);
}
#[test]
fn an_author_date_entry_may_open_on_a_rule_of_dashes() {
let found = read(
"\u{2014}\u{2014} (1994), Christianizing Homer: The Odyssey, Plato, \
and the Acts of Andrew (New York: Oxford University Press).",
);
assert_eq!(found.year, Some(1994));
assert_eq!(
found.title.as_deref(),
Some("Christianizing Homer: The Odyssey, Plato, and the Acts of Andrew")
);
}
#[test]
fn a_rule_of_dashes_stands_for_the_author_and_nothing_else() {
// The dash opens the entry, and everything between it and the year
// is the title and the journal — not more of the author.
let found = read(
"\\_\\_\\_\\_\\_. \u{201c}Die Frage nach der Echtheit von Matth. 16, \
17-19.\u{201d} *Theologische Blatter* 20 (1941): 26-30.",
);
assert_eq!(
found.title.as_deref(),
Some("Die Frage nach der Echtheit von Matth. 16, 17-19")
);
assert_eq!(found.container.as_deref(), Some("Theologische Blatter"));
assert_eq!(found.year, Some(1941));
}
#[test]
fn a_reprint_gives_two_years_and_an_editor_takes_the_author_slot() {
// `1813/1963` is the year it was written and the year of the
// edition used; the page range says it is a chapter in a book, which
// is why six sentences of German do not make it prose.
let reprint = read(
"Schleiermacher, Friedrich. 1813/1963. \"Ueber die verschiedenen \
Methoden des Uebersezens\". In Das Problem des Uebersetzens, \
edited by H.J. Stoerig, 38-70. Darmstadt: Wissenschaftliche \
Buchgesellschaft.",
);
assert_eq!(reprint.authors, ["Schleiermacher, Friedrich"]);
assert_eq!(
reprint.title.as_deref(),
Some("Ueber die verschiedenen Methoden des Uebersezens")
);
assert_eq!(reprint.year, Some(1813));
// `ed.` is an abbreviation rather than a stop, so the name and the
// title stayed one segment and neither was read.
let editor = read(
"Barratt, Alexandra, ed. Women's Writing in Middle English. \
London: Longman, 1992.",
);
assert_eq!(editor.authors, ["Barratt, Alexandra"]);
assert_eq!(
editor.title.as_deref(),
Some("Women's Writing in Middle English")
);
assert_eq!(editor.container.as_deref(), Some("Longman"));
}
#[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_translators_note_signs_itself_and_cites_nothing() {
for gloss in [
"Latin: \"nature of the thing.\" — Ed.",
"Reference to the wars between Athens and Sparta, 431-404 BC. — Ed.",
"Meaning here names, dates and numbers, in the usual sense. — Tr.",
] {
assert!(one(gloss, None).is_none(), "{gloss}");
}
// A note that cites a work is still a citation, sign-off or not.
let real = read("See R. Hirzel, Die Person (1914), p. 7.");
assert_eq!(real.authors, ["R. Hirzel"]);
assert_eq!(real.title.as_deref(), Some("Die Person"));
assert_eq!(real.year, Some(1914));
}
#[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_colon_inside_the_title_does_not_hide_the_imprint() {
// The only colon here is the title's own, so it says nothing about
// where the imprint is — and the year at the end does.
let found = read(
"Kymlicka, Will, Politics in the Vernacular: Nationalism, \
Multiculturalism and Citizenship, Oxford, 2001",
);
assert_eq!(found.authors, ["Kymlicka, Will"]);
assert_eq!(
found.title.as_deref(),
Some("Politics in the Vernacular: Nationalism, Multiculturalism and Citizenship")
);
assert_eq!(found.year, Some(2001));
}
#[test]
fn an_editors_credit_is_trimmed_off_a_title_not_grounds_to_drop_it() {
let found = read("Juvenal, Saturae XIV, ed. J. D. Duff (Cambridge, 1962), p. 9.");
assert_eq!(found.authors, ["Juvenal"]);
assert_eq!(found.title.as_deref(), Some("Saturae XIV"));
assert_eq!(found.pages.as_deref(), Some("9"));
}
#[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_stray_quotation_mark_at_the_end_is_not_a_cut_title() {
// Conversion lost the opening mark and left its partner behind.
let found = read(
"McClelland, J.L, D.E. Rumelhart, & G.E. Hinton 1986. The appeal \
of parallel distributed processing\". In Parallel Distributed \
Processing, MIT Press.",
);
assert_eq!(
found.title.as_deref(),
Some("The appeal of parallel distributed processing")
);
// One in the middle still 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 a_note_that_thanks_people_cites_nothing() {
for note in [
"Thanks to Ned Block, Frank Jackson, David Kaplan and David Lewis \
for discussion of these matters over many years.",
"I am grateful to the seminar at Leiden for pressing me on this, \
and to two anonymous readers for the Press.",
"An earlier version of this chapter was given at the 1998 meeting \
of the Society, and I thank those present.",
] {
assert!(one(note, None).is_none(), "{note}");
}
}
#[test]
fn a_topic_keyed_to_short_cites_names_no_work_and_no_author() {
// A notes section that reads `Mate selection: Buss, 1989` is keying
// the author's own words to works given in full elsewhere. The topic
// is not a name and there is nothing here to resolve.
let found = read("Mate selection: Buss, 1989; Symons, 1979. Jealousy: Buss, 2000.");
assert!(found.continuation);
assert!(found.authors.is_empty(), "{found:#?}");
assert!(found.title.is_none());
assert!(found.missing().is_empty());
// An entry held together by commas with an imprint at the end looks
// similar and is a reference.
let real = read("Miller, David, On Nationality, Oxford: Clarendon Press, 1995");
assert_eq!(real.authors, ["Miller, David"]);
assert_eq!(real.title.as_deref(), Some("On Nationality"));
}
#[test]
fn a_title_the_style_marked_may_open_on_a_number() {
// `*1 & 2 Thessalonians*` is two books of the Bible, not a page.
let found = read("Bruce, F. F. (1982), *1 & 2 Thessalonians*, Waco, TX: Word.");
assert_eq!(found.title.as_deref(), Some("1 & 2 Thessalonians"));
}
#[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"]);
// Pandoc escapes an underscore, so a rule of them arrives escaped.
let escaped = read(
"\\_\\_\\_\\_\\_. *The Community of the Beloved Disciple*. New York: Paulist, 1979.",
);
assert_eq!(
escaped.title.as_deref(),
Some("The Community of the Beloved Disciple")
);
assert!(repeats_author(&escaped.text));
// One name, two years: `Spade 1975 and 1980.`
let years = read("Spade 1975 and 1980.");
assert!(years.continuation);
assert_eq!(years.authors, ["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_colon_in_running_prose_is_not_an_imprint() {
// Six sentences of body text, with `his 1945 report, Science: The
// Endless Frontier` in the middle of it — shaped exactly like
// `Place: Publisher`, which used to buy the whole paragraph an
// exemption from the prose test.
let prose = "Thirdly, not all fields of science were seen as equally \
relevant to the war effort. Bush himself was an electrical \
engineer; his chief lieutenants were drawn from the physical \
sciences. Weapons such as radar drew heavily upon such \
disciplines. OSRD was busily creating an electronic environment \
for war. At any rate, physics emerged from the war as the most \
prestigious discipline. And finally, Bush proposed - most \
famously in his 1945 report, Science: The Endless Frontier - \
that Washington continue to play a leading role.";
assert!(one(prose, None).is_none(), "{prose}");
// A long entry that cites something is still a citation: the page
// range is what buys the exemption, not the colon.
let chapter = read(
"Heder, Stephen P. 'The Kampuchean-Vietnamese Conflict.' In David \
W.P. Elliott, ed. *The Third Indochina Conflict.* Boulder: \
Westview Press. 1981. pp. 21-67.",
);
assert_eq!(
chapter.title.as_deref(),
Some("The Third Indochina Conflict")
);
}
#[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."]
);
}
#[test]
fn a_note_that_remarks_on_the_argument_names_no_work() {
// The commonest thing on a page of endnotes, and until the guessed
// title was held to a bar, every one of them was stored as a work.
for remark in [
"Admittedly, the lot may enter into the appointment of priests \
(759b-c). But priesthood is not in itself a qualification.",
"The Commentator seems to have based himself on the Bible. \
Compare the passage at Isidore, Etym. XIX.xxi.5.",
"Dewey remarks that we experience most things as temporal. \
He is aware of the practical aspects of the thought of Plato.",
] {
let named = one(remark, Some("12")).and_then(|read| read.title);
assert_eq!(named, None, "stored a remark as a title: {remark}");
}
}
#[test]
fn a_note_that_remarks_first_and_cites_second_keeps_neither_as_a_title() {
// The remark opened the note and the citation followed it, so the
// second sentence became a container and vouched for the first.
// Nothing in this names a work the record can carry.
let read = one(
"As Lewis White Beck has rightly put it. See his Early German \
Philosophy (Cambridge, Mass., 1969), p. 439.",
None,
)
.expect("a record");
assert_eq!(read.title, None);
assert_eq!(read.pages.as_deref(), Some("439"));
}
#[test]
fn a_title_the_entry_marked_up_is_taken_at_its_word() {
// The other side of the same bar, and why it is drawn at the guess
// rather than at the title: an entry that marked its own title is
// not guessing, so nothing here is asked of it. Patristic and
// medieval works are cited by title alone, and holding those to the
// bar would cost more than it saved.
for (text, want) in [
(
"*Quaestiones Parisienses* (LW I:37-83 \\[Questions 1-5\\])",
"Quaestiones Parisienses",
),
(
"H, 302. \\ ***The Lord of the Rings*** \\ INTRODUCTION (p. 117)",
"The Lord of the Rings",
),
] {
let read = one(text, None).expect("a record");
assert_eq!(read.title.as_deref(), Some(want));
}
}
#[test]
fn a_siglum_and_a_page_name_nothing() {
// `H, 272` is an abbreviation whose key is a page of its own that the
// entry does not carry. An atlas of Middle-earth is a thousand of
// them, and the heading each one sits under is not its title.
for siglum in [
"H, 272. THE BATTLE OF FIVE ARMIES (p. 112)",
"FR,120-122. ON THE BARROW-DOWNS (p. 122)",
] {
let named = one(siglum, None).and_then(|read| read.title);
assert_eq!(named, None, "stored a siglum as a title: {siglum}");
}
}
#[test]
fn a_name_that_ends_in_an_initial_does_not_run_on_into_the_title() {
// The stop after `P.` is an initial, not a sentence stop, so the
// entry never got cut there: the name came back with the title on
// it and the imprint after it became the work.
for (text, name, title) in [
(
"Hoyt, Edwin P. The Supersalesmen. New York: World Publishing, 1962.",
"Hoyt, Edwin P.",
"The Supersalesmen",
),
(
"Heckscher, E. The Continental System. Oxford: Clarendon Press, 1922.",
"Heckscher, E.",
"The Continental System",
),
] {
let read = one(text, None).expect("a record");
assert_eq!(read.authors, vec![name.to_string()], "{text}");
assert_eq!(read.title.as_deref(), Some(title), "{text}");
}
}
#[test]
fn an_initial_before_a_forename_is_still_one_name() {
// `M. Philips Price` inverts to `Price, M. Philips`, and cutting at
// the initial there would take the forename for a title. One word
// after the initial is a name; two are a title.
for (text, name) in [
(
"Price, M. Philips. Dispatches from the Revolution. Durham: Duke, 1997.",
"Price, M. Philips",
),
(
"Drexler, K. Eric. Nanosystems, Molecular Machinery. New York: Wiley, 1992.",
"Drexler, K. Eric",
),
] {
let read = one(text, None).expect("a record");
assert_eq!(read.authors, vec![name.to_string()], "{text}");
}
}
#[test]
fn where_a_book_was_published_is_not_what_it_is_called() {
// An imprint standing alone as a segment is skipped, and the reader
// goes on looking. A title with a colon in it is not an imprint.
let read = one("Stites, Richard. Russian Popular Culture: Entertainment and Society since 1900. Cambridge, 1992.", None).expect("a record");
assert_eq!(
read.title.as_deref(),
Some("Russian Popular Culture: Entertainment and Society since 1900")
);
}
#[test]
fn the_readers_own_navigation_strip_is_not_a_reference() {
for furniture in [
"572 next page > < previous page page",
"574 next page > file:///C:/Users/User/AppData/Local/Temp/Rar$EX00/files/page",
] {
assert!(
one(furniture, None).is_none(),
"read furniture: {furniture}"
);
}
}
}