use std::collections::HashMap;
use crate::config::RefScheme;
pub struct LocusScheme {
name: String,
re: regex::Regex,
pub format: String,
}
impl LocusScheme {
fn is_valid(&self, locus: &str) -> bool {
self.re.is_match(locus.trim())
}
fn canonicalize(&self, locus: &str) -> String {
canonicalize_locus(&self.name, locus)
}
}
fn canonicalize_locus(scheme_name: &str, locus: &str) -> String {
let s: String = locus.split_whitespace().collect::<Vec<_>>().join(" ");
match scheme_name {
"bible" => match split_ref(&s) {
Some((book, cv)) if !book.is_empty() => {
match crate::research::scripture::canonical_bible_book(&book) {
Some(canon) => format!("{canon} {cv}"),
None => format!("{book} {cv}"), }
}
_ => s,
},
"quran" | "book-of-mormon" => match split_ref(&s) {
Some((book, cv)) if !book.is_empty() => format!("{book} {cv}"),
Some((_, cv)) => cv, None => s,
},
_ => s,
}
}
fn split_ref(s: &str) -> Option<(String, String)> {
let (book, tail) = match s.rsplit_once(char::is_whitespace) {
Some((b, t)) => (b.trim().to_string(), t),
None => (String::new(), s),
};
Some((book, normalize_cv(tail)?))
}
fn normalize_cv(t: &str) -> Option<String> {
let (ch, rest) = t.trim().split_once([':', '.'])?;
let (v, range) = match rest.split_once('-') {
Some((v, r)) => (v, Some(r)),
None => (rest, None),
};
let numeric = |x: &str| !x.is_empty() && x.chars().all(|c| c.is_ascii_digit());
if numeric(ch) && numeric(v) && range.map_or(true, numeric) {
Some(match range {
Some(r) => format!("{ch}:{v}-{r}"),
None => format!("{ch}:{v}"),
})
} else {
None
}
}
pub fn builtin_scheme(name: &str) -> Option<RefScheme> {
let (pattern, format) = match name {
"bible" | "book-of-mormon" => {
(r"^([1-4]\s+)?\p{L}[\p{L}. ]*\s+\d+:\d+(-\d+)?$", "{book} {ch}:{v}")
}
"quran" => (r"^\d{1,3}:\d{1,3}(-\d+)?$", "{surah}:{ayah}"),
_ => return None,
};
Some(RefScheme { pattern: pattern.to_string(), format: format.to_string() })
}
pub fn resolve_schemes(
configured: &std::collections::BTreeMap<String, RefScheme>,
declared: &HashMap<String, String>,
keys: &[String],
) -> (HashMap<String, LocusScheme>, Vec<(String, String)>) {
let mut out = HashMap::new();
let mut errs = Vec::new();
let mut seen_err: std::collections::HashSet<String> = std::collections::HashSet::new();
for key in keys {
let name = declared.get(key).cloned().unwrap_or_else(|| key.clone());
let Some(raw) = configured.get(&name).cloned().or_else(|| builtin_scheme(&name)) else {
continue;
};
if raw.pattern.trim().is_empty() {
continue;
}
match regex::Regex::new(&raw.pattern) {
Ok(re) => {
out.insert(key.clone(), LocusScheme { name: name.clone(), re, format: raw.format });
}
Err(e) => {
if seen_err.insert(name.clone()) {
errs.push((name, e.to_string()));
}
}
}
}
(out, errs)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed {
pub key: String,
pub title: String,
pub locus: String,
pub expected: String,
}
pub fn malformed(entries: &[LocorumEntry], schemes: &HashMap<String, LocusScheme>) -> Vec<Malformed> {
let mut out = Vec::new();
for e in entries {
for row in &e.loci {
if !row.valid {
let expected = schemes.get(&e.key).map(|s| s.format.clone()).unwrap_or_default();
out.push(Malformed {
key: e.key.clone(),
title: e.title.clone(),
locus: row.locus.clone(),
expected,
});
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct LocusCitation {
pub key: String,
pub locus: Option<String>,
pub chapter: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocusRow {
pub locus: String,
pub chapters: Vec<String>,
pub valid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocorumEntry {
pub key: String,
pub title: String,
pub loci: Vec<LocusRow>,
}
pub fn build(
cites: &[LocusCitation],
titles: &HashMap<String, String>,
schemes: &HashMap<String, LocusScheme>,
) -> Vec<LocorumEntry> {
let mut keys_order: Vec<String> = Vec::new();
let mut by_key: HashMap<String, (Vec<String>, HashMap<String, Vec<String>>)> = HashMap::new();
for c in cites {
let Some(raw) = c.locus.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) else {
continue;
};
let locus = schemes.get(&c.key).map_or_else(|| raw.to_string(), |s| s.canonicalize(raw));
let entry = by_key.entry(c.key.clone()).or_insert_with(|| {
keys_order.push(c.key.clone());
(Vec::new(), HashMap::new())
});
let (locus_order, chapters) = entry;
let chs = chapters.entry(locus.clone()).or_insert_with(|| {
locus_order.push(locus.clone());
Vec::new()
});
let ch = c.chapter.trim();
if !ch.is_empty() && !chs.iter().any(|x| x == ch) {
chs.push(ch.to_string());
}
}
let mut entries: Vec<LocorumEntry> = keys_order
.into_iter()
.filter_map(|key| {
let (locus_order, mut chapters) = by_key.remove(&key)?;
let scheme = schemes.get(&key);
let mut loci: Vec<LocusRow> = locus_order
.into_iter()
.map(|locus| {
let chs = chapters.remove(&locus).unwrap_or_default();
let valid = scheme.map_or(true, |s| s.is_valid(&locus));
LocusRow { locus, chapters: chs, valid }
})
.collect();
loci.sort_by(|a, b| natural_key(&a.locus).cmp(&natural_key(&b.locus)));
let title = titles.get(&key).cloned().unwrap_or_else(|| key.clone());
Some(LocorumEntry { key, title, loci })
})
.collect();
entries.sort_by(|a, b| {
a.title.to_lowercase().cmp(&b.title.to_lowercase()).then_with(|| a.key.cmp(&b.key))
});
entries
}
fn natural_key(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
let mut digits = String::new();
for c in s.chars() {
if c.is_ascii_digit() {
digits.push(c);
} else {
if !digits.is_empty() {
out.push_str(&format!("{:0>12}", digits));
digits.clear();
}
out.extend(c.to_lowercase());
}
}
if !digits.is_empty() {
out.push_str(&format!("{:0>12}", digits));
}
out
}
pub fn heading_for_language(lang: &str) -> &'static str {
match lang.trim().to_lowercase().as_str() {
"ru" | "russian" | "русский" => "Указатель мест",
"de" | "german" | "deutsch" => "Stellenregister",
_ => "Index Locorum",
}
}
pub fn render_typst(entries: &[LocorumEntry], heading: &str) -> String {
let mut s = format!("= {heading}\n\n");
for e in entries {
s.push_str(&format!("== {} <indexlocorum-{}>\n\n", typst_escape(&e.title), e.key));
for row in &e.loci {
let where_ = if row.chapters.is_empty() {
String::new()
} else {
format!(" #h(1em) #text(gray)[{}]", typst_escape(&row.chapters.join(", ")))
};
s.push_str(&format!("- {}{}\n", typst_escape(&row.locus), where_));
}
s.push('\n');
}
s
}
pub fn render_md(entries: &[LocorumEntry], heading: &str) -> String {
let mut s = format!("# {heading}\n\n");
for e in entries {
s.push_str(&format!("## {} (`@{}`)\n\n", e.title, e.key));
for row in &e.loci {
if row.chapters.is_empty() {
s.push_str(&format!("- {}\n", row.locus));
} else {
s.push_str(&format!("- {} — {}\n", row.locus, row.chapters.join(", ")));
}
}
s.push('\n');
}
s
}
pub fn render_json(entries: &[LocorumEntry]) -> String {
let arr: Vec<_> = entries
.iter()
.map(|e| {
serde_json::json!({
"key": e.key,
"title": e.title,
"loci": e.loci.iter().map(|r| serde_json::json!({
"locus": r.locus,
"chapters": r.chapters,
})).collect::<Vec<_>>(),
})
})
.collect();
let total: usize = entries.iter().map(|e| e.loci.len()).sum();
serde_json::to_string_pretty(
&serde_json::json!({ "index_locorum": arr, "sources": entries.len(), "loci": total }),
)
.unwrap_or_else(|_| "{}".into())
}
fn typst_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '#' | '*' | '_' | '`' | '$' | '@' | '<' | '>' | '\\' | '[' | ']') {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cite(key: &str, locus: Option<&str>, chapter: &str) -> LocusCitation {
LocusCitation { key: key.into(), locus: locus.map(str::to_string), chapter: chapter.into() }
}
fn titles() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("bible".into(), "The Holy Bible".into());
m.insert("kant".into(), "Critique of Pure Reason".into());
m
}
fn no_schemes() -> HashMap<String, LocusScheme> {
HashMap::new()
}
fn bible_schemes() -> HashMap<String, LocusScheme> {
let (s, errs) = resolve_schemes(
&std::collections::BTreeMap::new(),
&HashMap::new(),
&["bible".to_string()],
);
assert!(errs.is_empty());
s
}
#[test]
fn groups_by_source_sorts_loci_naturally_and_dedupes_chapters() {
let cites = vec![
cite("bible", Some("John 3:16"), "Grace"),
cite("bible", Some("John 3:2"), "Grace"),
cite("bible", Some("John 3:16"), "Faith"), cite("bible", Some("John 3:16"), "Grace"), cite("kant", Some("A51/B75"), "Reason"),
cite("plato", None, "Forms"), ];
let idx = build(&cites, &titles(), &no_schemes());
assert_eq!(idx.len(), 2);
assert_eq!(idx[0].key, "kant");
assert_eq!(idx[1].key, "bible");
let bible = &idx[1];
assert_eq!(bible.loci[0].locus, "John 3:2");
assert_eq!(bible.loci[1].locus, "John 3:16");
assert_eq!(bible.loci[1].chapters, vec!["Grace", "Faith"]);
}
#[test]
fn unknown_key_falls_back_to_the_key_as_title() {
let idx = build(&[cite("quran", Some("2:255"), "Unity")], &HashMap::new(), &no_schemes());
assert_eq!(idx.len(), 1);
assert_eq!(idx[0].title, "quran");
}
#[test]
fn natural_key_orders_numbers_numerically() {
let mut v = vec!["10:1", "3:16", "3:2", "1:1"];
v.sort_by(|a, b| natural_key(a).cmp(&natural_key(b)));
assert_eq!(v, vec!["1:1", "3:2", "3:16", "10:1"]);
}
#[test]
fn renders_all_three_formats() {
let idx = build(&[cite("bible", Some("John 3:16"), "Grace")], &titles(), &no_schemes());
let heading = heading_for_language("en");
assert!(render_typst(&idx, heading).starts_with("= Index Locorum"));
assert!(render_md(&idx, heading).contains("## The Holy Bible (`@bible`)"));
let json = render_json(&idx);
assert!(json.contains("\"loci\": 1"));
assert!(json.contains("John 3:16"));
}
#[test]
fn heading_localizes() {
assert_eq!(heading_for_language("ru"), "Указатель мест");
assert_eq!(heading_for_language("de"), "Stellenregister");
assert_eq!(heading_for_language("fr"), "Index Locorum");
assert_eq!(heading_for_language("en"), "Index Locorum");
}
#[test]
fn empty_when_no_loci() {
assert!(build(&[cite("bible", None, "X")], &titles(), &no_schemes()).is_empty());
}
#[test]
fn builtin_bible_scheme_validates_and_flags() {
let cites = vec![
cite("bible", Some("John 3:16"), "I"), cite("bible", Some("1 Corinthians 13:4"), "I"), cite("bible", Some("John 3:sixteen"), "I"), cite("bible", Some("John 3"), "I"), ];
let idx = build(&cites, &titles(), &bible_schemes());
let bible = &idx[0];
let valid: Vec<&str> = bible.loci.iter().filter(|r| r.valid).map(|r| r.locus.as_str()).collect();
let bad: Vec<&str> = bible.loci.iter().filter(|r| !r.valid).map(|r| r.locus.as_str()).collect();
assert!(valid.contains(&"John 3:16") && valid.contains(&"1 Corinthians 13:4"));
assert!(bad.contains(&"John 3:sixteen") && bad.contains(&"John 3"));
}
#[test]
fn malformed_reports_with_expected_format() {
let idx = build(&[cite("bible", Some("John 3:sixteen"), "I")], &titles(), &bible_schemes());
let bad = malformed(&idx, &bible_schemes());
assert_eq!(bad.len(), 1);
assert_eq!(bad[0].key, "bible");
assert_eq!(bad[0].locus, "John 3:sixteen");
assert_eq!(bad[0].expected, "{book} {ch}:{v}");
}
#[test]
fn quran_scheme_matches_surah_ayah_only() {
let (schemes, errs) = resolve_schemes(
&std::collections::BTreeMap::new(),
&HashMap::new(),
&["quran".to_string()],
);
assert!(errs.is_empty());
let idx = build(
&[cite("quran", Some("2:255"), "I"), cite("quran", Some("Al-Baqara 255"), "I")],
&HashMap::new(),
&schemes,
);
let q = &idx[0];
assert!(q.loci.iter().find(|r| r.locus == "2:255").unwrap().valid);
assert!(!q.loci.iter().find(|r| r.locus == "Al-Baqara 255").unwrap().valid);
}
#[test]
fn configured_scheme_via_declared_name() {
let mut cfg = std::collections::BTreeMap::new();
cfg.insert(
"kant-ab".to_string(),
RefScheme { pattern: r"^A\d+(/B\d+)?$".into(), format: "A{n}/B{n}".into() },
);
let mut declared = HashMap::new();
declared.insert("kant-cpr".to_string(), "kant-ab".to_string());
let (schemes, errs) = resolve_schemes(&cfg, &declared, &["kant-cpr".to_string()]);
assert!(errs.is_empty());
let idx = build(
&[cite("kant-cpr", Some("A51/B75"), "I"), cite("kant-cpr", Some("Ak. 5:122"), "I")],
&HashMap::new(),
&schemes,
);
let k = &idx[0];
assert!(k.loci.iter().find(|r| r.locus == "A51/B75").unwrap().valid);
assert!(!k.loci.iter().find(|r| r.locus == "Ak. 5:122").unwrap().valid);
}
#[test]
fn canonicalizes_and_merges_bible_variants() {
let cites = vec![
cite("bible", Some("John 3:16"), "I"),
cite("bible", Some("Jn 3.16"), "II"), cite("bible", Some("иоанна 3:16"), "III"), cite("bible", Some("1 cor 13.4"), "IV"), ];
let idx = build(&cites, &titles(), &bible_schemes());
let bible = &idx[0];
let loci: Vec<&str> = bible.loci.iter().map(|r| r.locus.as_str()).collect();
let john = bible.loci.iter().find(|r| r.locus == "John 3:16").expect("John 3:16 row");
assert!(john.chapters.contains(&"I".to_string()) && john.chapters.contains(&"III".to_string()));
assert!(loci.contains(&"1 Corinthians 13:4"));
assert!(loci.contains(&"Jn 3:16"));
assert!(bible.loci.iter().all(|r| r.valid));
}
#[test]
fn canonicalizes_quran_separator() {
let idx = build(
&[cite("quran", Some("2.255"), "I"), cite("quran", Some("2:255"), "II")],
&HashMap::new(),
&{
let (s, _) = resolve_schemes(&std::collections::BTreeMap::new(), &HashMap::new(), &["quran".into()]);
s
},
);
assert_eq!(idx[0].loci.len(), 1);
assert_eq!(idx[0].loci[0].locus, "2:255");
assert_eq!(idx[0].loci[0].chapters, vec!["I", "II"]);
}
#[test]
fn no_scheme_leaves_loci_verbatim() {
let idx = build(&[cite("charaka", Some("Sutra 1.24"), "I")], &HashMap::new(), &no_schemes());
assert_eq!(idx[0].loci[0].locus, "Sutra 1.24");
}
#[test]
fn no_scheme_leaves_loci_valid_and_bad_pattern_is_reported() {
let idx = build(&[cite("plato", Some("Republic 514a"), "I")], &HashMap::new(), &no_schemes());
assert!(idx[0].loci[0].valid);
let mut cfg = std::collections::BTreeMap::new();
cfg.insert("broken".to_string(), RefScheme { pattern: "(".into(), format: "x".into() });
let mut declared = HashMap::new();
declared.insert("src".to_string(), "broken".to_string());
let (schemes, errs) = resolve_schemes(&cfg, &declared, &["src".to_string()]);
assert!(schemes.is_empty());
assert_eq!(errs.len(), 1);
assert_eq!(errs[0].0, "broken");
}
}