From 6f39b75d94668f8adc55f237060f467f2c37de98 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sun, 20 Sep 2026 14:22:43 -0400 Subject: [PATCH] citefind: the short note a history book uses for everything after the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- crates/citefind/src/parse.rs | 111 +++++++++++++++++++++++++++- crates/citefind/src/split.rs | 2 +- crates/citefind/tests/references.rs | 28 +++++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/crates/citefind/src/parse.rs b/crates/citefind/src/parse.rs index 059e36c..4b36b0a 100644 --- a/crates/citefind/src/parse.rs +++ b/crates/citefind/src/parse.rs @@ -227,6 +227,18 @@ static NOTE_FORM: LazyLock = 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 = 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 = 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 = 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 { 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, Option, Option 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, Option, Option)> { + 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."); diff --git a/crates/citefind/src/split.rs b/crates/citefind/src/split.rs index a0d9f17..c9b1bde 100644 --- a/crates/citefind/src/split.rs +++ b/crates/citefind/src/split.rs @@ -124,7 +124,7 @@ const MIN_WORK: usize = 25; /// come apart at the one place it must not. static ANOTHER_WORK: LazyLock = 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") }); diff --git a/crates/citefind/tests/references.rs b/crates/citefind/tests/references.rs index 78d2ae1..7fe1a00 100644 --- a/crates/citefind/tests/references.rs +++ b/crates/citefind/tests/references.rs @@ -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::>(), + [ + 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::>(), + [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());