#[derive(Debug, Clone)]
pub struct IndexUnit {
pub chapter: String,
pub anchor: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexLocation {
pub chapter: String,
pub anchor: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexEntry {
pub term: String,
pub locations: Vec<IndexLocation>,
pub see: Option<String>,
}
pub fn build(terms: &[String], see_refs: &[(String, String)], units: &[IndexUnit]) -> Vec<IndexEntry> {
let lc: Vec<(String, &IndexUnit)> = units.iter().map(|u| (u.text.to_lowercase(), u)).collect();
let mut entries: Vec<IndexEntry> = Vec::new();
for term in terms {
let needle = term.trim().to_lowercase();
if needle.is_empty() {
continue;
}
let mut locations: Vec<IndexLocation> = Vec::new();
for (text_lc, u) in &lc {
if contains_word(text_lc, &needle)
&& !locations.iter().any(|l| l.chapter == u.chapter)
{
locations.push(IndexLocation { chapter: u.chapter.clone(), anchor: u.anchor.clone() });
}
}
if !locations.is_empty() {
entries.push(IndexEntry { term: term.trim().to_string(), locations, see: None });
}
}
for (synonym, canonical) in see_refs {
let s = synonym.trim();
if s.is_empty() || s.eq_ignore_ascii_case(canonical.trim()) {
continue;
}
if entries.iter().any(|e| e.term.eq_ignore_ascii_case(canonical.trim()) && e.see.is_none()) {
entries.push(IndexEntry {
term: s.to_string(),
locations: Vec::new(),
see: Some(canonical.trim().to_string()),
});
}
}
entries.sort_by(|a, b| a.term.to_lowercase().cmp(&b.term.to_lowercase()));
entries.dedup_by(|a, b| a.term.eq_ignore_ascii_case(&b.term) && a.see == b.see);
entries
}
fn contains_word(hay: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let mut from = 0;
while let Some(rel) = hay[from..].find(needle) {
let i = from + rel;
let before_ok = hay[..i].chars().next_back().map_or(true, |c| !c.is_alphanumeric());
let after = &hay[i + needle.len()..];
let after_ok = after.chars().next().map_or(true, |c| !c.is_alphanumeric());
if before_ok && after_ok {
return true;
}
from = i + needle.len();
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn unit(chapter: &str, anchor: &str, text: &str) -> IndexUnit {
IndexUnit { chapter: chapter.into(), anchor: anchor.into(), text: text.into() }
}
#[test]
fn locates_terms_dedupes_by_chapter_and_alphabetises() {
let units = vec![
unit("The Numbers", "ch1.html#a", "The long peace held. Deaths fell."),
unit("The Numbers", "ch1.html#b", "War is the exception, peace the rule."),
unit("Origins", "ch2.html#a", "The origins of war run deep."),
];
let idx = build(&["peace".into(), "war".into(), "unicorn".into()], &[], &units);
assert_eq!(idx.len(), 2);
assert_eq!(idx[0].term, "peace");
assert_eq!(idx[0].locations.len(), 1);
assert_eq!(idx[0].locations[0].chapter, "The Numbers");
assert_eq!(idx[1].term, "war");
assert_eq!(idx[1].locations.len(), 2);
}
#[test]
fn whole_word_only() {
let units = vec![unit("C", "c#a", "The artist made a start; art matters.")];
let idx = build(&["art".into()], &[], &units);
assert_eq!(idx.len(), 1);
assert_eq!(idx[0].locations.len(), 1);
assert!(build(&["cat".into()], &[], &units).is_empty());
}
#[test]
fn see_reference_points_at_a_present_term() {
let units = vec![unit("C", "c#a", "Homicide fell across the peace.")];
let idx = build(
&["peace".into()],
&[("calm".into(), "peace".into()), ("dragon".into(), "wyrm".into())],
&units,
);
let calm = idx.iter().find(|e| e.term == "calm").expect("calm see-ref");
assert_eq!(calm.see.as_deref(), Some("peace"));
assert!(idx.iter().all(|e| e.term != "dragon"));
}
}