citefind: the short note a history book uses for everything after the first

`Unger, Dutch Shipbuilding, 33-4` — a surname, a short title, and where in
it. No year, no publisher, nothing but commas. It is how a humanities book
cites a work it has already given in full, which is most of what its notes
are: one book in the corpus has 2,229 such chunks and this read almost none
of them.

The locators trailing a short note are not part of its title — `Sjoerds,
Algemene Beschryvinge, i, f I, 501-2` cites the Beschryvinge, not the
Beschryvinge-comma-i-comma-f-I. An entry ending on a year is an imprint and
goes on being read as one.

Also: a work after a semicolon may open on a quotation mark, and the marks a
style puts round a short title belong to the style.

626 more references over the 202 reference sections sampled from the corpus.
This commit is contained in:
funkpower
2026-09-20 14:22:43 -04:00
parent d31206dd0f
commit 6f39b75d94
3 changed files with 136 additions and 5 deletions
+107 -4
View File
@@ -227,6 +227,18 @@ static NOTE_FORM: LazyLock<Regex> = LazyLock::new(|| {
.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+")
@@ -242,8 +254,15 @@ const PARTICLES: [&str; 20] = [
/// Anything that says this is a reference at all.
static CITATION_SIGNAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\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")
.expect("a valid pattern")
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
@@ -364,7 +383,10 @@ pub fn one(text: &str, marker: Option<&str>) -> Option<Reference> {
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)) {
} 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;
@@ -503,6 +525,54 @@ fn comma_form(text: &str) -> Option<(Vec<String>, Option<String>, Option<String>
Some((names, Some(title), tidy_container(parts[at])))
}
/// 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;
}
let names = authors(parts[0]);
if names.is_empty() {
return None;
}
// The title runs from after the name to the first locator.
let end = parts
.iter()
.skip(1)
.position(|part| LOCATOR.is_match(part.trim_end_matches('.')))
.map(|at| at + 1)
.unwrap_or(parts.len());
if end <= 1 {
return None;
}
let title = tidy_title(&strip_markup(&parts[1..end].join(", ")));
match title.chars().count() >= 4 {
true => Some((names, Some(title), None)),
false => None,
}
}
/// Whether a run of text is names and nothing else.
///
/// Stricter than asking whether it *contains* a name. `Doe, Jane, and Peter
@@ -664,7 +734,10 @@ fn tidy_title(raw: &str) -> String {
// A trailing backslash is a line break conversion left behind, not part
// of anything.
let text = text.trim_end_matches('\\');
let text = text.trim_matches(|c: char| ",.;:".contains(c) || c.is_whitespace());
// 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());
// 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.
@@ -1213,6 +1286,36 @@ mod tests {
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 a_continuation_is_flagged_and_not_resolved() {
let found = read("Ibid., pp. 23-24, 39.");
+1 -1
View File
@@ -124,7 +124,7 @@ const MIN_WORK: usize = 25;
/// come apart at the one place it must not.
static ANOTHER_WORK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#";\s+(?:(?i:see(?:\s+also)?|cf\.|and)\s+)?[\p{Lu}][\p{L}.'’\-]{1,24}(?:\s+[\p{Lu}][\p{L}.'’\-]{1,24}){0,3},\s+[\p{Lu}\*_"“‘]"#,
r#";\s+(?:(?i:see(?:\s+also)?|cf\.|and)\s+)?[\p{Lu}][\p{L}.'’\-]{1,24}(?:\s+[\p{Lu}][\p{L}.'’\-]{1,24}){0,3},\s+[\p{Lu}\*_"“‘']"#,
)
.expect("a valid pattern")
});
+28
View File
@@ -212,6 +212,34 @@ fn the_body_of_a_page_is_not_the_first_of_the_notes_at_its_foot() {
);
}
#[test]
fn a_short_note_citing_two_works_gives_two_records() {
let found = read(
"12 Unger, Dutch Shipbuilding, 33-4; Unger, 'Dutch herring', 256-9.\n\
13 Van Gelder, The Two Reformations, 213, 312; Herwaarden, 'Geloof en geloofsuitingen', 403-4.",
);
assert_eq!(found.len(), 4, "{found:#?}");
assert_eq!(
found
.iter()
.map(|one| one.title.as_deref())
.collect::<Vec<_>>(),
[
Some("Dutch Shipbuilding"),
Some("Dutch herring"),
Some("The Two Reformations"),
Some("Geloof en geloofsuitingen"),
]
);
assert_eq!(
found
.iter()
.map(|one| one.marker.as_deref())
.collect::<Vec<_>>(),
[Some("12"), Some("12"), Some("13"), Some("13")]
);
}
#[test]
fn nothing_citation_shaped_gives_nothing() {
assert!(read("Bibliography\n\nSee below.\n\n233\n").is_empty());