#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SenseRow {
pub label: String,
pub gloss: String,
pub chapters: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SenseUsage {
pub term: String,
pub sense: usize,
pub chapter: String,
}
pub fn sense_tags(text_lc: &str, form: &str) -> Vec<usize> {
let mut out = Vec::new();
if form.is_empty() {
return out;
}
const MARK: &str = "#super[";
let mut from = 0;
while let Some(rel) = text_lc[from..].find(form) {
let start = from + rel;
let end = start + form.len();
let before_ok = text_lc[..start].chars().next_back().map_or(true, |c| !c.is_alphanumeric());
if before_ok {
if let Some(rest) = text_lc[end..].strip_prefix(MARK) {
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if !digits.is_empty() && rest[digits.len()..].starts_with(']') {
if let Ok(n) = digits.parse::<usize>() {
if n >= 1 {
out.push(n);
}
}
}
}
}
from = end.max(start + 1);
}
out
}
#[derive(Debug, Clone)]
pub struct LexTerm {
pub term: String,
pub original_forms: Vec<String>,
pub senses: Vec<SenseRow>,
}
#[derive(Debug, Clone)]
pub struct TermUsage {
pub term: String,
pub chapter: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerbumEntry {
pub term: String,
pub original_forms: Vec<String>,
pub senses: Vec<SenseRow>,
pub chapters: Vec<String>,
}
pub fn build(lexicon: &[LexTerm], usages: &[TermUsage], sense_usages: &[SenseUsage]) -> Vec<VerbumEntry> {
use std::collections::HashMap;
let mut chapters: HashMap<&str, Vec<String>> = HashMap::new();
for u in usages {
let ch = u.chapter.trim();
if ch.is_empty() {
continue;
}
let list = chapters.entry(u.term.as_str()).or_default();
if !list.iter().any(|c| c == ch) {
list.push(ch.to_string());
}
}
let mut sense_ch: HashMap<(&str, usize), Vec<String>> = HashMap::new();
for su in sense_usages {
let ch = su.chapter.trim();
if ch.is_empty() || su.sense == 0 {
continue;
}
let list = sense_ch.entry((su.term.as_str(), su.sense)).or_default();
if !list.iter().any(|c| c == ch) {
list.push(ch.to_string());
}
}
let mut entries: Vec<VerbumEntry> = lexicon
.iter()
.filter_map(|lt| {
let chs = chapters.get(lt.term.as_str())?;
if chs.is_empty() {
return None;
}
let senses = lt
.senses
.iter()
.enumerate()
.map(|(i, s)| SenseRow {
label: s.label.clone(),
gloss: s.gloss.clone(),
chapters: sense_ch.get(&(lt.term.as_str(), i + 1)).cloned().unwrap_or_default(),
})
.collect();
Some(VerbumEntry {
term: lt.term.clone(),
original_forms: lt.original_forms.clone(),
senses,
chapters: chs.clone(),
})
})
.collect();
entries.sort_by(|a, b| a.term.to_lowercase().cmp(&b.term.to_lowercase()));
entries
}
pub fn heading_for_language(lang: &str) -> &'static str {
match lang.trim().to_lowercase().as_str() {
"ru" | "russian" | "русский" => "Указатель терминов",
"de" | "german" | "deutsch" => "Wortregister",
_ => "Index Verborum",
}
}
pub fn render_typst(entries: &[VerbumEntry], heading: &str) -> String {
let mut s = format!("= {heading}\n\n");
for e in entries {
let forms = if e.original_forms.is_empty() {
String::new()
} else {
format!(" #h(0.6em) #text(style: \"italic\")[{}]", typst_escape(&e.original_forms.join(", ")))
};
s.push_str(&format!("== {}{forms} <indexverborum-{}>\n\n", typst_escape(&e.term), slug(&e.term)));
for sr in &e.senses {
let label = if sr.label.trim().is_empty() {
String::new()
} else {
format!(" *{}* — ", typst_escape(sr.label.trim()))
};
let sense_where = if sr.chapters.is_empty() {
String::new()
} else {
format!(" #h(0.5em) #text(gray)[{}]", typst_escape(&sr.chapters.join(", ")))
};
s.push_str(&format!("+ {label}{}{sense_where}\n", typst_escape(sr.gloss.trim())));
}
if !e.chapters.is_empty() {
s.push_str(&format!(
"\n#text(gray)[{}]\n",
typst_escape(&e.chapters.join(", "))
));
}
s.push('\n');
}
s
}
pub fn render_md(entries: &[VerbumEntry], heading: &str) -> String {
let mut s = format!("# {heading}\n\n");
for e in entries {
let forms = if e.original_forms.is_empty() {
String::new()
} else {
format!(" — *{}*", e.original_forms.join(", "))
};
s.push_str(&format!("## {}{forms}\n\n", e.term));
for (i, sr) in e.senses.iter().enumerate() {
let label = if sr.label.trim().is_empty() { String::new() } else { format!("**{}** — ", sr.label.trim()) };
let sense_where = if sr.chapters.is_empty() {
String::new()
} else {
format!(" _({})_", sr.chapters.join(", "))
};
s.push_str(&format!("{}. {label}{}{sense_where}\n", i + 1, sr.gloss.trim()));
}
if !e.chapters.is_empty() {
s.push_str(&format!("\n*Used in:* {}\n", e.chapters.join(", ")));
}
s.push('\n');
}
s
}
pub fn render_json(entries: &[VerbumEntry]) -> String {
let arr: Vec<_> = entries
.iter()
.map(|e| {
serde_json::json!({
"term": e.term,
"original_forms": e.original_forms,
"senses": e.senses.iter().map(|s| serde_json::json!({ "label": s.label, "gloss": s.gloss, "chapters": s.chapters })).collect::<Vec<_>>(),
"chapters": e.chapters,
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({ "index_verborum": arr, "terms": entries.len() }))
.unwrap_or_else(|_| "{}".into())
}
fn slug(term: &str) -> String {
term.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect()
}
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 lt(term: &str, forms: &[&str], senses: &[(&str, &str)]) -> LexTerm {
LexTerm {
term: term.into(),
original_forms: forms.iter().map(|s| s.to_string()).collect(),
senses: senses
.iter()
.map(|(l, g)| SenseRow { label: l.to_string(), gloss: g.to_string(), chapters: Vec::new() })
.collect(),
}
}
fn use_(term: &str, chapter: &str) -> TermUsage {
TermUsage { term: term.into(), chapter: chapter.into() }
}
#[test]
fn indexes_used_terms_with_forms_senses_and_chapters() {
let lexicon = vec![
lt("reason", &["Vernunft"], &[("Vernunft", "the unconditioned"), ("Verstand", "concepts")]),
lt("grace", &["gratia"], &[("charis", "unmerited favor")]),
];
let usages = vec![
use_("reason", "On Reason"),
use_("reason", "On Freedom"),
use_("reason", "On Reason"), ];
let idx = build(&lexicon, &usages, &[]);
assert_eq!(idx.len(), 1);
assert_eq!(idx[0].term, "reason");
assert_eq!(idx[0].original_forms, vec!["Vernunft"]);
assert_eq!(idx[0].senses.len(), 2);
assert_eq!(idx[0].chapters, vec!["On Reason", "On Freedom"]);
}
#[test]
fn sorts_by_term_and_drops_unused() {
let lexicon = vec![
lt("being", &[], &[("existence", "that it is")]),
lt("act", &[], &[("energeia", "actuality")]),
lt("unused", &[], &[]),
];
let usages = vec![use_("being", "II"), use_("act", "I")];
let idx = build(&lexicon, &usages, &[]);
assert_eq!(idx.iter().map(|e| e.term.as_str()).collect::<Vec<_>>(), vec!["act", "being"]);
}
#[test]
fn renders_all_formats() {
let idx = build(
&[lt("reason", &["Vernunft"], &[("Vernunft", "the unconditioned")])],
&[use_("reason", "On Reason")],
&[],
);
let h = heading_for_language("en");
assert!(render_typst(&idx, h).starts_with("= Index Verborum"));
assert!(render_md(&idx, h).contains("## reason — *Vernunft*"));
let json = render_json(&idx);
assert!(json.contains("\"terms\": 1") && json.contains("Vernunft"));
}
#[test]
fn sense_tags_parses_super_markers() {
assert_eq!(sense_tags("pure reason#super[1] and reason#super[2] here", "reason"), vec![1, 2]);
assert_eq!(sense_tags("reason alone", "reason"), Vec::<usize>::new());
assert_eq!(sense_tags("unreason#super[1]", "reason"), Vec::<usize>::new()); assert_eq!(sense_tags("reason#super[]", "reason"), Vec::<usize>::new()); }
#[test]
fn sense_level_usage_attaches_chapters_to_senses() {
let lexicon = vec![lt("reason", &["Vernunft"], &[("Vernunft", "unconditioned"), ("Verstand", "concepts")])];
let usages = vec![use_("reason", "I"), use_("reason", "II")];
let sense_usages = vec![
SenseUsage { term: "reason".into(), sense: 1, chapter: "I".into() },
SenseUsage { term: "reason".into(), sense: 2, chapter: "II".into() },
];
let idx = build(&lexicon, &usages, &sense_usages);
assert_eq!(idx[0].senses[0].chapters, vec!["I"]); assert_eq!(idx[0].senses[1].chapters, vec!["II"]); assert_eq!(idx[0].chapters, vec!["I", "II"]); let md = render_md(&idx, "Index Verborum");
assert!(md.contains("_(I)_") && md.contains("_(II)_"));
}
#[test]
fn heading_localizes() {
assert_eq!(heading_for_language("ru"), "Указатель терминов");
assert_eq!(heading_for_language("de"), "Wortregister");
assert_eq!(heading_for_language("fr"), "Index Verborum");
assert_eq!(heading_for_language("en"), "Index Verborum");
}
}