From eeacaf1a25ce99d70cd1b03abc62f9b0ff060f19 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sun, 20 Sep 2026 17:28:28 -0400 Subject: [PATCH] citefind: a topic keyed to short cites, and a title the style marked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mate selection: Buss, 1989; Symons, 1979. Jealousy: Buss, 2000. A notes section written this way keys the author's own words to works given in full in the bibliography. `Mate selection` is not a name and `Buss, 1989` is not a work, so the note points at something it does not name — the same thing `ibid.` does, and it is flagged the same way. It was being stored as a reference whose author was the topic, and the entry below it inherited that as a repeated author. The pattern is deliberately narrow: the part after the colon must be nothing but surnames and years. `Miller, David, On Nationality, Oxford: Clarendon Press, 1995` has a colon and a year too, and is a book. And a run the style itself marked as a title is a title whatever it opens on. The rule against a title beginning with a number is about a page reference picked up by mistake; inside italics there is no mistake to make, and `*1 & 2 Thessalonians*` is two books of the Bible. --- crates/citefind/src/parse.rs | 88 +++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/crates/citefind/src/parse.rs b/crates/citefind/src/parse.rs index 7219dad..34aab72 100644 --- a/crates/citefind/src/parse.rs +++ b/crates/citefind/src/parse.rs @@ -210,6 +210,22 @@ static BARE_CITE: LazyLock = LazyLock::new(|| { .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 = 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. @@ -393,6 +409,13 @@ pub fn one(text: &str, marker: Option<&str>) -> Option { } // 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); @@ -546,7 +569,9 @@ fn span(pattern: &Regex, text: &str) -> (Option, Option<(usize, usize)>) let Some(inner) = (1..=4).find_map(|at| found.get(at)) else { return (None, None); }; - let cleaned = tidy_title(&strip_markup(inner.as_str())); + // 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()))), @@ -802,6 +827,16 @@ fn strip_markup(text: &str) -> String { 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 @@ -948,6 +983,32 @@ fn looks_like_a_year(text: &str) -> bool { } } +/// 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::>().join(" "); + let text = text.trim_end_matches('\\'); + let text = + text.trim_matches(|c: char| ",.;:'\u{2018}\u{2019}".contains(c) || c.is_whitespace()); + let text = TRAILING_VOLUME.replace(text, ""); + 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 = LazyLock::new(|| { + Regex::new(r"(?ix) \bpp?\.\s*\d | ; | \bibid\b | \\ | ,\s+(?:eds?|trans)\.\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::>().join(" "); @@ -1820,6 +1881,31 @@ mod tests { } } + #[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