use rusqlite::{Connection, OpenFlags, OptionalExtension};
#[cfg(feature = "embedded")]
use rust_embed::Embed;
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug)]
pub struct BdbEntry {
pub headword: String,
pub root: String,
pub gloss: String,
pub content_json: String,
pub pos: String,
pub is_root: bool,
}
impl BdbEntry {
pub fn is_proper_noun(&self) -> bool {
self.pos.starts_with("n.pr")
}
pub fn pos_category(&self) -> &'static str {
let p: String = self
.pos
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_lowercase();
if p.starts_with("n.pr") {
"proper"
} else if p.starts_with("vb") {
"verb"
} else if p.starts_with("adv") {
"adverb"
} else if p.starts_with("adj") {
"adjective"
} else if p.starts_with('n') {
"noun"
} else if self.is_root {
"root"
} else {
"other"
}
}
fn has_content(&self) -> bool {
!self.gloss.is_empty()
|| serde_json::from_str::<serde_json::Value>(&self.content_json)
.ok()
.and_then(|v| {
v.get("senses")
.map(|s| s.as_array().is_some_and(|a| !a.is_empty()))
})
.unwrap_or(false)
}
}
#[derive(Debug, Default, Clone)]
pub struct HebrewWord {
pub word: String,
pub root: String,
pub gloss: String,
pub form: Option<String>,
pub tense: Option<String>,
pub person: Option<String>,
pub gender: Option<String>,
pub number: Option<String>,
pub state: Option<String>,
pub prefix: Option<String>,
pub vav_con: bool,
pub obj_suffix: Option<String>,
pub is_name: bool,
}
#[derive(Debug)]
pub struct VocabEntry {
pub surface: String,
pub occurrences: u32,
pub lexical_class: Option<String>,
pub root: String,
pub gloss: String,
pub morph: String,
}
#[derive(Debug)]
pub struct SedraEntry {
pub lexeme: String,
pub root: String,
pub meaning: String,
}
#[derive(Debug, Default)]
pub struct SedraWord {
pub word: String,
pub consonantal: String,
pub lexeme: String,
pub root: String,
pub key_lexeme: i64,
pub key_root: i64,
pub meanings: Vec<String>,
pub gender: Option<String>,
pub person: Option<String>,
pub number: Option<String>,
pub state: Option<String>,
pub tense: Option<String>,
pub form: Option<String>,
pub suffix: Option<String>,
}
#[derive(Debug, Default)]
pub struct SedraLexemeSummary {
pub lexeme: String,
pub meanings: Vec<String>,
pub is_current: bool,
}
fn decode_gender(k: i64) -> Option<String> {
Some(
match k {
1 => "Common",
2 => "Masculine",
3 => "Feminine",
_ => return None,
}
.to_string(),
)
}
fn decode_person(k: i64) -> Option<String> {
Some(
match k {
1 => "Third",
2 => "Second",
3 => "First",
_ => return None,
}
.to_string(),
)
}
fn decode_number(k: i64) -> Option<String> {
Some(
match k {
1 => "Singular",
2 => "Plural",
_ => return None,
}
.to_string(),
)
}
fn decode_state(k: i64) -> Option<String> {
Some(
match k {
1 => "Absolute",
2 => "Construct",
3 => "Emphatic",
_ => return None,
}
.to_string(),
)
}
fn decode_tense(k: i64) -> Option<String> {
Some(
match k {
1 => "Perfect",
2 => "Imperfect",
3 => "Imperative",
4 => "Infinitive",
5 => "Active participle",
6 => "Passive participle",
7 => "Participle",
_ => return None,
}
.to_string(),
)
}
fn decode_form(k: i64) -> Option<String> {
Some(
match k {
1 => "Peal",
2 => "Ethpeal",
3 => "Pael",
4 => "Ethpaal",
5 => "Aphel",
6 => "Ettaphal",
7 => "Shaphel",
8 => "Eshtaphal",
9 => "Saphel",
10 => "Estaphal",
11 => "Pauel",
12 => "Ethpaual",
13 => "Paiel",
14 => "Ethpaial",
15 => "Palpal",
16 => "Ethpalpal",
17 => "Palpel",
18 => "Ethpalpal",
19 => "Pamel",
20 => "Ethpamal",
21 => "Parel",
22 => "Ethparal",
23 => "Pali",
24 => "Ethpali",
25 => "Pahli",
26 => "Ethpahli",
27 => "Taphel",
28 => "Ethaphal",
_ => return None,
}
.to_string(),
)
}
fn decode_suffix(person: i64, gender: i64, number: i64) -> Option<String> {
if person == 0 {
return None;
}
let p = match person {
1 => "3",
2 => "2",
3 => "1",
_ => "?",
};
let g = match gender {
1 => "m",
2 => "f",
_ => "c",
};
let n = if number == 1 { "p" } else { "s" };
Some(format!("{p}{g}{n} suffix"))
}
pub(crate) fn decode_pgn(pgn: &str) -> (Option<String>, Option<String>, Option<String>) {
let mut person = None;
let mut gender = None;
let mut number = None;
for c in pgn.chars() {
match c {
'1' => person = Some("First".to_string()),
'2' => person = Some("Second".to_string()),
'3' => person = Some("Third".to_string()),
'm' => gender = Some("Masculine".to_string()),
'f' => gender = Some("Feminine".to_string()),
'c' => gender = Some("Common".to_string()),
's' => number = Some("Singular".to_string()),
'p' => number = Some("Plural".to_string()),
'd' => number = Some("Dual".to_string()),
_ => {}
}
}
(person, gender, number)
}
pub(crate) fn decode_noun_label(label: &str) -> (Option<String>, Option<String>) {
if let Some((num, rest)) = label.split_once(' ')
&& matches!(num, "Singular" | "Plural" | "Dual")
{
let state = (!rest.is_empty()).then(|| rest.to_string());
return (Some(num.to_string()), state);
}
let state = (!label.is_empty()).then(|| label.to_string());
(None, state)
}
#[derive(Debug)]
pub struct WordOccurrence {
pub book: u8,
pub chapter: u8,
pub verse: u8,
}
#[derive(Debug)]
pub struct HebrewOccurrence {
pub book: u8,
pub chapter: u8,
pub verse: u8,
pub form: String,
}
#[derive(Debug)]
pub struct SedraOccurrence {
pub book: u8,
pub chapter: u8,
pub verse: u8,
pub lexeme_index: u32,
pub words: Vec<String>,
}
fn normalize_hebrew_combining(text: &str) -> String {
let mut chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i + 1 < chars.len() {
if is_heb_vowel(chars[i]) && is_heb_dot(chars[i + 1]) {
chars.swap(i, i + 1);
} else {
i += 1;
}
}
chars.into_iter().collect()
}
fn is_heb_vowel(c: char) -> bool {
let n = c as u32;
(0x05B0..=0x05BD).contains(&n) && n != 0x05BC || n == 0x05C7
}
fn is_heb_dot(c: char) -> bool {
matches!(c as u32, 0x05BC | 0x05C1 | 0x05C2)
}
fn display_hebrew(book: u8, words: &str) -> String {
if book >= 40 {
crate::transliterate::hebrew_display(words)
} else {
words.to_owned()
}
}
fn display(s: String) -> String {
crate::transliterate::hebrew_display(&s)
}
fn fold_consonants(word: &str) -> String {
word.chars()
.filter_map(|c| {
let n = c as u32;
if !(0x05D0..=0x05EA).contains(&n) {
return None;
}
Some(match c {
'\u{05DA}' => '\u{05DB}',
'\u{05DD}' => '\u{05DE}',
'\u{05DF}' => '\u{05E0}',
'\u{05E3}' => '\u{05E4}',
'\u{05E5}' => '\u{05E6}',
other => other,
})
})
.collect()
}
const PROCLITICS: [(&str, &str); 16] = [
("וְ", "and"),
("וּ", "and"),
("וַ", "and"),
("הַ", "the"),
("הָ", "the"),
("בְּ", "in"),
("בַּ", "in the"),
("בָּ", "in the"),
("לְ", "to"),
("לַ", "to the"),
("לָ", "to the"),
("לֵ", "to"),
("לִ", "to"),
("מִ", "from"),
("מֵ", "from"),
("כְּ", "like"),
];
fn unfinalize(s: &str) -> String {
s.chars()
.map(|c| match c {
'\u{05DA}' => '\u{05DB}', '\u{05DD}' => '\u{05DE}', '\u{05DF}' => '\u{05E0}', '\u{05E3}' => '\u{05E4}', '\u{05E5}' => '\u{05E6}', c => c,
})
.collect()
}
fn has_plural_tail(surface: &str) -> bool {
const TAILS: &[&str] = &[
"\u{05B4}\u{05D9}\u{05DD}", "\u{05B4}\u{05DD}", "\u{05D5}\u{05B9}\u{05EA}", "\u{05B9}\u{05EA}", "\u{05B7}\u{05D9}\u{05B4}\u{05DD}", ];
let undotted: String = surface
.chars()
.filter(|&c| !matches!(c as u32, 0x05BC | 0x05BD | 0x05C1 | 0x05C2))
.collect();
TAILS.iter().any(|t| undotted.ends_with(t))
}
pub(crate) fn strip_proclitic(surface: &str, proclitic: &str) -> Option<String> {
let rest = surface.strip_prefix(proclitic)?;
let mut chars: Vec<char> = rest.chars().collect();
if chars.len() < 2 {
return None;
}
for i in 1..chars.len() {
if !(0x0591..=0x05C7).contains(&(chars[i] as u32)) {
break;
}
if chars[i] == '\u{05BC}' {
chars.remove(i);
break;
}
}
Some(chars.into_iter().collect())
}
fn strip_accents(word: &str) -> String {
word.chars()
.filter(|&c| {
let n = c as u32;
!(0x0591..=0x05AF).contains(&n) && n != 0x05BD
})
.collect()
}
fn curated_gloss(surface: &str) -> Option<(String, String)> {
let canonical = normalize_hebrew_combining(&strip_accents(surface));
crate::lexicon_overlay::lexicon_entries().find_map(|entry| {
(normalize_hebrew_combining(&strip_accents(entry.surface)) == canonical)
.then(|| (entry.root.to_string(), entry.gloss.to_string()))
})
}
fn display_bdb_entry(mut entry: BdbEntry) -> BdbEntry {
if entry.pos_category() == "root" {
entry.headword = normalize_hebrew_combining(&strip_accents(&entry.headword));
}
if let Some((root, gloss)) = curated_gloss(&entry.headword)
&& (root.is_empty() || root == entry.root)
{
entry.gloss = gloss;
}
entry
}
pub(crate) fn lexicon_fallback(db: &Connection, surface: &str) -> Option<(String, String, String)> {
if let Some((root, gloss)) = curated_gloss(surface).or_else(|| bdb_exact(db, surface)) {
return Some((root, gloss, String::new()));
}
for (proclitic, _) in PROCLITICS {
if let Some(rest) = strip_proclitic(surface, proclitic) {
let matched = curated_gloss(&rest)
.or_else(|| bdb_exact(db, &rest))
.or_else(|| {
(fold_consonants(&rest).chars().count() >= 3)
.then(|| bdb_cons(db, &rest))
.flatten()
});
if let Some((root, gloss)) = matched {
return Some((root, gloss, proclitic.to_string()));
}
}
}
bdb_cons(db, surface).map(|(root, gloss)| (root, gloss, String::new()))
}
fn cross_reference_gloss(gloss: &str) -> bool {
let hebrew_char = |c: char| matches!(c as u32, 0x0590..=0x05FF | 0xFB1D..=0xFB4F);
let hebrew_word = |w: &str| w.chars().any(hebrew_char);
let mut words = gloss.split_whitespace().skip_while(|w| {
w.chars()
.all(|c| hebrew_char(c) || c.is_ascii_punctuation())
});
matches!(
words
.next()
.map(|w| w.trim_matches(|c: char| c.is_ascii_punctuation())),
Some("see" | "under")
) && words.any(hebrew_word)
}
fn root_stub_gloss(gloss: &str) -> bool {
if !gloss.starts_with('(') {
return false;
}
let mut depth = 0usize;
for (i, ch) in gloss.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
let rest = &gloss[i + 1..];
return !rest.chars().any(|c| c.is_ascii_alphabetic());
}
}
_ => {}
}
}
false
}
fn name_pos(pos: &str) -> bool {
pos.starts_with("n.pr") || pos.starts_with("adj.gent")
}
fn bdb_exact(db: &Connection, surface: &str) -> Option<(String, String)> {
let canonical = normalize_hebrew_combining(surface);
bdb_rows(db, surface)?
.into_iter()
.find(|(word, ..)| normalize_hebrew_combining(&strip_accents(word)) == canonical)
.map(|(_, root, gloss, _)| (root, gloss))
}
fn bdb_cons(db: &Connection, surface: &str) -> Option<(String, String)> {
bdb_rows(db, surface)?
.into_iter()
.next()
.map(|(_, root, gloss, _)| (root, gloss))
}
fn bdb_rows(db: &Connection, surface: &str) -> Option<Vec<(String, String, String, String)>> {
let cons = fold_consonants(surface);
if cons.is_empty() {
return None;
}
let mut stmt = db
.prepare(
"SELECT word, root, gloss, pos FROM lexdb.bdb \
WHERE cons = ?1 AND gloss IS NOT NULL AND gloss <> '' \
ORDER BY bdb_id",
)
.ok()?;
let mut rows = stmt
.query_map([&cons], |row| {
Ok((
row.get::<_, Option<String>>(0)?.unwrap_or_default(),
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?.unwrap_or_default(),
))
})
.ok()?
.collect::<rusqlite::Result<Vec<_>>>()
.ok()?;
rows.retain(|(_, _, gloss, _)| !cross_reference_gloss(gloss) && !root_stub_gloss(gloss));
rows.sort_by_key(|(_, _, gloss, _)| {
gloss
.chars()
.next()
.is_some_and(|c| matches!(c as u32, 0x0590..=0x05FF))
});
Some(rows)
}
fn morph_summary(info: &HebrewWord) -> String {
let body = if let Some(binyan) = &info.form {
let mut s = binyan.clone();
if let Some(tense) = &info.tense {
s.push(' ');
s.push_str(&tense.to_lowercase());
}
let pgn: String = [
info.person.as_deref().map(|p| match p {
"First" => "1",
"Second" => "2",
_ => "3",
}),
info.gender.as_deref().map(|g| match g {
"Masculine" => "m",
"Feminine" => "f",
_ => "c",
}),
info.number.as_deref().map(|n| match n {
"Singular" => "s",
"Plural" => "p",
_ => "d",
}),
]
.into_iter()
.flatten()
.collect();
if !pgn.is_empty() {
s.push(' ');
s.push_str(&pgn);
}
s
} else {
let mut parts = vec!["noun".to_string()];
if let Some(number) = &info.number {
parts.push(number.to_lowercase());
}
if let Some(state) = &info.state {
parts.push(state.to_lowercase());
}
parts.join(" ")
};
match &info.prefix {
Some(prefix) => format!("{prefix}־ + {body}"),
None => body,
}
}
const IRREGULAR_PAST: &[(&str, &str)] = &[
("say", "said"),
("go", "went"),
("come", "came"),
("see", "saw"),
("give", "gave"),
("take", "took"),
("make", "made"),
("know", "knew"),
("eat", "ate"),
("do", "did"),
("find", "found"),
("hear", "heard"),
("tell", "told"),
("become", "became"),
("build", "built"),
("send", "sent"),
("keep", "kept"),
("stand", "stood"),
("fall", "fell"),
("bring", "brought"),
("buy", "bought"),
("seek", "sought"),
("fight", "fought"),
("put", "put"),
("set", "set"),
("cut", "cut"),
("let", "let"),
("sit", "sat"),
("speak", "spoke"),
("write", "wrote"),
("bear", "bore"),
("break", "broke"),
("choose", "chose"),
("rise", "rose"),
("fear", "feared"),
("hold", "held"),
("lay", "laid"),
("lead", "led"),
("leave", "left"),
("meet", "met"),
("read", "read"),
("run", "ran"),
("show", "showed"),
("shut", "shut"),
("sell", "sold"),
("throw", "threw"),
("draw", "drew"),
("dwell", "dwelt"),
("weep", "wept"),
("bind", "bound"),
("wear", "wore"),
("swear", "swore"),
("smite", "smote"),
("slay", "slew"),
("flee", "fled"),
("hide", "hid"),
("shake", "shook"),
("swim", "swam"),
("drink", "drank"),
];
const IRREGULAR_PLURAL: &[(&str, &str)] = &[
("man", "men"),
("woman", "women"),
("child", "children"),
("foot", "feet"),
("tooth", "teeth"),
("ox", "oxen"),
("person", "people"),
("life", "lives"),
("wife", "wives"),
("knife", "knives"),
("leaf", "leaves"),
];
pub(crate) fn is_name_gloss(gloss: &str) -> bool {
gloss.contains("n.pr")
}
pub(crate) fn name_description(gloss: &str) -> String {
let mut s = gloss.to_string();
for marker in ["n.pr", "adj.gent"] {
while let Some(i) = s.find(marker) {
let end = s[i..]
.char_indices()
.find(|&(_, c)| c.is_whitespace() || matches!(c, ')' | ']' | '—' | ',' | ';'))
.map_or(s.len(), |(j, _)| i + j);
s.replace_range(i..end, "");
}
}
s.trim_matches(|c: char| {
c.is_whitespace()
|| matches!(c as u32, 0x0590..=0x05FF)
|| matches!(c, '(' | ')' | '—' | '-' | '.' | ',' | ';' | ':')
})
.to_string()
}
pub(crate) fn prefixed_name_gloss(surface: &str) -> Option<(String, String)> {
type Chain = Vec<(&'static str, &'static str)>;
fn strip_names(surface: &str, depth: u8) -> Option<(Chain, String, &'static str)> {
for (proclitic, sense) in PROCLITICS {
let Some(rest) = strip_proclitic(surface, proclitic) else {
continue;
};
let sense = sense.trim_end_matches(" the");
if crate::vocab_gloss::curated_name(&rest)
&& let Some(c) = crate::vocab_gloss::curated_gloss(&rest)
{
return Some((vec![(proclitic, sense)], rest, c.gloss));
}
if depth > 0
&& let Some((mut chain, stem, gloss)) = strip_names(&rest, depth - 1)
{
chain.insert(0, (proclitic, sense));
return Some((chain, stem, gloss));
}
}
None
}
let (chain, stem, gloss) = strip_names(surface, 1)?;
let senses: Vec<&str> = chain.iter().map(|&(_, s)| s).collect();
let note = chain
.iter()
.map(|&(p, s)| format!("{p} ({s})"))
.chain([format!("{stem} ({gloss})")])
.collect::<Vec<_>>()
.join(" + ");
Some((format!("{} {gloss}", senses.join(" ")), note))
}
fn sense_clauses(gloss: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0u32;
let mut start = 0;
for (i, c) in gloss.char_indices() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
';' | ',' if depth == 0 => {
out.push(&gloss[start..i]);
start = i + c.len_utf8();
}
_ => {}
}
}
out.push(&gloss[start..]);
out
}
pub(crate) fn leading_sense(gloss: &str) -> String {
sense_clauses(gloss)
.into_iter()
.map(str::trim)
.find(|c| !c.is_empty())
.unwrap_or_else(|| gloss.trim())
.to_string()
}
fn primary_sense(gloss: &str) -> String {
for clause in sense_clauses(gloss) {
let c = clause.trim();
if c.is_empty() {
continue;
}
let has_hebrew = c.chars().any(|ch| ('\u{0590}'..='\u{05FF}').contains(&ch));
let lower = c.to_lowercase();
let is_ref = lower.starts_with("see ")
|| lower.starts_with("cf")
|| lower.starts_with("id.")
|| lower.contains("n.pr")
|| c.starts_with('√')
|| c.contains('(');
if has_hebrew || is_ref {
continue;
}
return c.trim_start_matches("to ").trim().to_string();
}
String::new()
}
fn past_tense(verb: &str) -> String {
if let Some((_, past)) = IRREGULAR_PAST.iter().find(|(v, _)| *v == verb) {
return (*past).to_string();
}
if verb == "be" {
return "was".to_string();
}
regular_suffix(verb, "ed")
}
fn ing_form(verb: &str) -> String {
if let Some(stem) = verb.strip_suffix('e')
&& !verb.ends_with("ee")
&& verb.len() > 2
{
return format!("{stem}ing");
}
format!("{verb}ing")
}
fn regular_suffix(word: &str, suffix: &str) -> String {
let ed = suffix == "ed";
if let Some(stem) = word.strip_suffix('y')
&& !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
&& !stem.is_empty()
{
return format!("{stem}i{suffix}");
}
if ed && word.ends_with('e') {
return format!("{word}d");
}
format!("{word}{suffix}")
}
fn pluralize(noun: &str) -> String {
if let Some((_, pl)) = IRREGULAR_PLURAL.iter().find(|(s, _)| *s == noun) {
return (*pl).to_string();
}
if noun.ends_with(['s', 'x', 'z']) || noun.ends_with("ch") || noun.ends_with("sh") {
return format!("{noun}es");
}
regular_suffix(noun, "s")
}
fn subject_pronoun(w: &HebrewWord) -> Option<&'static str> {
let plural = matches!(w.number.as_deref(), Some("Plural") | Some("Dual"));
match w.person.as_deref()? {
"First" => Some(if plural { "we" } else { "I" }),
"Second" => Some("you"),
"Third" => Some(match (w.gender.as_deref(), plural) {
(_, true) => "they",
(Some("Feminine"), false) => "she",
_ => "he",
}),
_ => None,
}
}
fn object_pronoun(pgn: &str) -> Option<&'static str> {
Some(match pgn {
"3ms" => "him",
"3fs" => "her",
"3mp" | "3fp" | "3cp" => "them",
"1cs" => "me",
"1cp" => "us",
s if s.starts_with('2') => "you",
_ => return None,
})
}
fn let_subject(w: &HebrewWord) -> &'static str {
let plural = matches!(w.number.as_deref(), Some("Plural") | Some("Dual"));
match w.person.as_deref() {
Some("First") => {
if plural {
"us"
} else {
"me"
}
}
Some("Second") => "you",
_ => match (w.gender.as_deref(), plural) {
(_, true) => "them",
(Some("Feminine"), false) => "her",
_ => "him",
},
}
}
fn proclitic_words(prefix: &str, infer_article: bool) -> Vec<&'static str> {
let chars: Vec<char> = prefix.chars().collect();
let mut out = Vec::new();
for (i, &c) in chars.iter().enumerate() {
let word = match c {
'\u{05D5}' => "and", '\u{05DC}' => "to", '\u{05D1}' => "in", '\u{05DB}' | '\u{05DA}' => "like", '\u{05DE}' | '\u{05DD}' => "from", '\u{05D4}' => "the", _ => continue,
};
out.push(word);
if infer_article && matches!(word, "to" | "in" | "like") {
let vowel = chars[i + 1..]
.iter()
.take_while(|&&v| (0x0591..=0x05C7).contains(&(v as u32)))
.find(|&&v| matches!(v as u32, 0x05B0..=0x05BB | 0x05C7));
if vowel.is_some_and(|&v| matches!(v as u32, 0x05B7 | 0x05B8)) {
out.push("the");
}
}
}
out
}
pub(crate) fn inflected_gloss(w: &HebrewWord) -> String {
let base = primary_sense(&w.gloss);
if base.is_empty() {
return w.gloss.clone();
}
if w.form.is_some() {
inflect_verb(w, &base)
} else if w.tense.is_none() && (w.number.is_some() || w.state.is_some()) {
inflect_noun(w, &base)
} else {
let mut words = w
.prefix
.as_deref()
.map_or(Vec::new(), |p| proclitic_words(p, false));
let mut first = leading_sense(&w.gloss);
if first.starts_with("the ") || first.starts_with("The ") {
words.retain(|&p| p != "the");
}
if words
.iter()
.any(|&p| matches!(p, "to" | "in" | "like" | "from"))
{
if let Some(obj) = object_form(&first) {
first = obj.to_string();
} else if !preposition_governable(&first) {
return w.gloss.clone();
}
}
if words.is_empty() || first.is_empty() {
w.gloss.clone()
} else {
format!("{} {first}", words.join(" "))
}
}
}
fn object_form(sense: &str) -> Option<&'static str> {
Some(match sense {
"I" => "me",
"we" => "us",
"he" => "him",
"she" => "her",
"they" => "them",
"you" => "you",
"it" => "it",
_ => return None,
})
}
fn preposition_governable(sense: &str) -> bool {
matches!(
sense,
"this" | "that" | "these" | "those" | "who" | "whom" | "which" | "all" | "here" | "there"
)
}
fn inflect_verb(w: &HebrewWord, base: &str) -> String {
let obj = w.obj_suffix.as_deref().and_then(object_pronoun);
let with_obj = |s: String| match obj {
Some(o) => format!("{s} {o}"),
None => s,
};
let and = w.vav_con
|| w.prefix
.as_deref()
.is_some_and(|p| proclitic_words(p, false).first() == Some(&"and"));
let subj = subject_pronoun(w);
let clause = |verb: String| {
let mut s = String::new();
if and {
s.push_str("and ");
}
if let Some(su) = subj {
s.push_str(su);
s.push(' ');
}
s.push_str(&verb);
s
};
match w.tense.as_deref() {
Some("Perfect") => with_obj(clause(past_tense(base))),
Some("Wayyiqtol") => {
let mut s = String::from("and ");
if let Some(su) = subj {
s.push_str(su);
s.push(' ');
}
s.push_str(&past_tense(base));
with_obj(s)
}
Some("Imperfect") => with_obj(clause(format!("will {base}"))),
Some("Cohortative") => with_obj(format!("let {} {base}", let_subject(w))),
Some("Jussive") => with_obj(format!("let {} {base}", let_subject(w))),
Some("Imperative") => with_obj(format!("{base}!")),
Some("Inf. Construct") | Some("Inf. Absolute") if and => format!("and to {base}"),
Some("Inf. Construct") | Some("Inf. Absolute") => format!("to {base}"),
Some("Participle (act.)") | Some("Participle") => with_obj(ing_form(base)),
Some("Participle (pas.)") | Some("Participle (pass.)") => past_tense(base),
_ => with_obj(clause(base.to_string())),
}
}
pub(crate) fn form_distractors(w: &HebrewWord) -> Vec<String> {
let correct = inflected_gloss(w);
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
seen.insert(correct.to_lowercase());
let mut consider = |variant: &HebrewWord, out: &mut Vec<String>| {
let g = inflected_gloss(variant);
if !g.is_empty() && seen.insert(g.to_lowercase()) {
out.push(g);
}
};
if w.form.is_some() && w.person.is_some() {
for (p, g, n) in [
("Third", "Masculine", "Singular"),
("Third", "Feminine", "Singular"),
("Third", "Masculine", "Plural"),
("First", "Common", "Singular"),
("Second", "Masculine", "Singular"),
("First", "Common", "Plural"),
] {
let mut v = w.clone();
v.person = Some(p.to_string());
v.gender = Some(g.to_string());
v.number = Some(n.to_string());
consider(&v, &mut out);
if out.len() >= 3 {
break;
}
}
} else if w.form.is_some() {
for tense in [
"Perfect",
"Imperfect",
"Imperative",
"Participle",
"Inf. Construct",
] {
let mut v = w.clone();
v.tense = Some(tense.to_string());
if matches!(tense, "Perfect" | "Imperfect") {
v.person = Some("Third".to_string());
v.gender = Some("Masculine".to_string());
v.number = Some("Singular".to_string());
}
consider(&v, &mut out);
if out.len() >= 3 {
break;
}
}
} else if w.form.is_none() {
let state = w.state.as_deref().unwrap_or("");
if let Some((num, _)) = state.split_once('+') {
let num = num.trim();
for sfx in ["3ms", "3fs", "3mp", "1cs", "2ms", "1cp"] {
let mut v = w.clone();
v.state = Some(format!("{num} + {sfx}"));
consider(&v, &mut out);
if out.len() >= 3 {
break;
}
}
} else {
for (num, st) in [
("Singular", "Absolute"),
("Plural", "Absolute"),
("Singular", "Construct"),
] {
let mut v = w.clone();
v.number = Some(num.to_string());
v.state = Some(st.to_string());
consider(&v, &mut out);
if out.len() >= 3 {
break;
}
}
}
}
out.truncate(3);
out
}
fn inflect_noun(w: &HebrewWord, base: &str) -> String {
let state = w.state.as_deref().unwrap_or("");
let plural =
matches!(w.number.as_deref(), Some("Plural") | Some("Dual")) || state.starts_with("Pl");
let head = if plural {
pluralize(base)
} else {
base.to_string()
};
let head = if let Some((_, sfx)) = state.split_once('+') {
let sfx = sfx.trim();
let poss = match sfx.get(..3).unwrap_or(sfx) {
"3ms" => "his",
"3fs" => "her",
"3mp" | "3fp" | "3cp" => "their",
"2ms" | "2fs" | "2mp" | "2fp" => "your",
"1cs" => "my",
"1cp" => "our",
_ => "",
};
if poss.is_empty() {
head
} else {
format!("{poss} {head}")
}
} else if state == "Construct" {
format!("{head} of")
} else {
head
};
let mut words = w
.prefix
.as_deref()
.map_or(Vec::new(), |p| proclitic_words(p, true));
if head.starts_with("the ") || head.starts_with("The ") {
words.retain(|&p| p != "the");
}
if words.is_empty() {
head
} else {
format!("{} {head}", words.join(" "))
}
}
#[cfg(feature = "embedded")]
#[derive(Embed)]
#[folder = "../../data/"]
struct Asset;
const ATTACHED_DBS: [(&str, &str); 4] = [
("bible.db", "bibledb"),
("sedra.db", "sedradb"),
("hebrew.db", "hebrewdb"),
("lexicon.db", "lexdb"),
];
#[derive(Debug)]
pub struct Bible {
db: Connection,
}
#[cfg(feature = "embedded")]
impl Default for Bible {
fn default() -> Self {
let mut db = Connection::open_in_memory().unwrap();
for (file, schema) in ATTACHED_DBS {
db.execute_batch(&format!("ATTACH DATABASE ':memory:' AS {schema}"))
.unwrap();
let asset = Asset::get(file).unwrap();
let data = Box::new(asset.data.into_owned());
db.deserialize_bytes(schema, Box::leak(data)).unwrap();
}
register_sql_functions(&db).unwrap();
Bible { db }
}
}
fn register_sql_functions(db: &Connection) -> rusqlite::Result<()> {
use rusqlite::functions::{Aggregate, Context, FunctionFlags};
let flags = FunctionFlags::SQLITE_UTF8
| FunctionFlags::SQLITE_DETERMINISTIC
| FunctionFlags::SQLITE_INNOCUOUS;
db.create_scalar_function("popcount", 1, flags, |ctx| {
Ok(ctx
.get::<Option<i64>>(0)?
.map_or(0i64, |n| (n as u64).count_ones() as i64))
})?;
struct BitOr;
impl Aggregate<i64, i64> for BitOr {
fn init(&self, _: &mut Context<'_>) -> rusqlite::Result<i64> {
Ok(0)
}
fn step(&self, ctx: &mut Context<'_>, acc: &mut i64) -> rusqlite::Result<()> {
if let Some(n) = ctx.get::<Option<i64>>(0)? {
*acc |= n;
}
Ok(())
}
fn finalize(&self, _: &mut Context<'_>, acc: Option<i64>) -> rusqlite::Result<i64> {
Ok(acc.unwrap_or(0))
}
}
db.create_aggregate_function("bit_or", 1, flags, BitOr)
}
impl Bible {
pub fn open<P: AsRef<Path>>(data_dir: P) -> rusqlite::Result<Self> {
let dir = data_dir.as_ref();
let db = Connection::open_with_flags(
":memory:",
OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_URI
| OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
for (file, schema) in ATTACHED_DBS {
db.execute(
&format!("ATTACH DATABASE ?1 AS {schema}"),
[db_uri(dir, file)],
)?;
}
register_sql_functions(&db)?;
Ok(Bible { db })
}
pub fn attach_progress<P: AsRef<Path>>(&self, progress_db: P) -> rusqlite::Result<()> {
self.db.execute(
"ATTACH DATABASE ?1 AS progress",
[progress_db.as_ref().to_string_lossy().as_ref()],
)?;
crate::tutor::init_progress_schema(&self.db)
}
pub(crate) fn conn(&self) -> &Connection {
&self.db
}
}
fn db_uri(dir: &Path, file: &str) -> String {
format!("file:{}?immutable=1", dir.join(file).display())
}
impl Bible {
pub fn get(&self, book: u8, chapter: u8, verse: u8) -> rusqlite::Result<String> {
let words: String = self.db.query_row(
"SELECT words FROM bibledb.bible WHERE book == ?1 AND chapter == ?2 AND verse == ?3",
[book, chapter, verse],
|row| row.get(0),
)?;
Ok(display_hebrew(book, &words))
}
pub fn verse_glosses(&self, book: u8, chapter: u8, verse: u8) -> rusqlite::Result<Vec<String>> {
let mut stmt = self.db.prepare("SELECT s.text FROM hebrewdb.verse_word vw JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3 ORDER BY vw.position")?;
stmt.query_map([book, chapter, verse], |r| {
let word: String = r.get(0)?;
if let Some(curated) = crate::vocab_gloss::curated_gloss(&word) {
return Ok(curated.gloss.to_string());
}
Ok(self.hebrew_word_info(&word).map_or_else(String::new, |w| {
let gloss = inflected_gloss(&w);
if gloss.is_empty() { w.gloss } else { gloss }
}))
})?
.collect()
}
pub fn get_chapter(
&self,
book: u8,
chapter: u8,
syriac: bool,
) -> rusqlite::Result<Vec<(u8, String)>> {
let mut stmt = self.db.prepare(
"SELECT verse, words FROM bibledb.bible WHERE book = ?1 AND chapter = ?2 ORDER BY verse",
)?;
let verses = stmt
.query_map([book, chapter], |row| {
let verse: u8 = row.get(0)?;
let words: String = row.get(1)?;
let words = if syriac {
crate::transliterate::hebrew_to_syriac(&words)
} else {
display_hebrew(book, &words)
};
Ok((verse, words))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(verses)
}
pub fn hebrew_word_info(&self, word: &str) -> Option<HebrewWord> {
let norm = crate::normalize_surface(word);
let surface_id: i64 = self
.db
.query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = ?1",
[&norm],
|r| r.get(0),
)
.optional()
.ok()??;
self.hebrew_word_by_surface_id(surface_id, norm)
}
pub(crate) fn hebrew_word_by_surface_id(
&self,
surface_id: i64,
norm: String,
) -> Option<HebrewWord> {
let verb = self
.db
.query_row(
"SELECT a.root, a.binyan, a.form, a.pgn, a.prefix, a.vav_consecutive, \
a.obj_suffix, a.attested, \
EXISTS(SELECT 1 FROM lexdb.bdb b WHERE b.root = a.root) AS has_bdb \
FROM hebrewdb.analyses a \
WHERE a.surface_id = ?1 \
ORDER BY EXISTS(SELECT 1 FROM lexdb.primary_analysis_overrides p \
WHERE p.surface = ?2 AND p.analysis_type = 'verb' \
AND p.root = a.root AND p.binyan = a.binyan \
AND p.form = a.form AND p.pgn = a.pgn AND p.prefix = a.prefix \
AND p.vav_consecutive = a.vav_consecutive \
AND p.obj_suffix = a.obj_suffix) DESC, a.analysis_id ASC \
LIMIT 1",
rusqlite::params![surface_id, norm],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, i64>(5)? != 0,
row.get::<_, String>(6)?,
row.get::<_, i64>(7)? != 0,
row.get::<_, i64>(8)? != 0,
))
},
)
.optional()
.ok()?;
let noun_rows = {
let mut stmt = self
.db
.prepare(
"SELECT n.kind, n.label, n.prefix, n.stem, \
EXISTS(SELECT 1 FROM lexdb.primary_analysis_overrides p \
WHERE p.surface = ?2 AND p.analysis_type = 'noun' \
AND p.stem = n.stem AND p.kind = n.kind \
AND p.label = n.label AND p.prefix = n.prefix) AS forced \
FROM hebrewdb.noun_analyses n \
WHERE n.surface_id = ?1 ORDER BY forced DESC, n.analysis_id ASC",
)
.ok()?;
stmt.query_map(rusqlite::params![surface_id, norm], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, i64>(4)? != 0,
))
})
.ok()?
.collect::<rusqlite::Result<Vec<_>>>()
.ok()?
};
struct NounReading {
kind: String,
label: String,
prefix: String,
stem: String,
root: String,
gloss: String,
resolved: bool,
curated: bool,
is_name: bool,
forced: bool,
}
let noun: Option<NounReading> = {
let mut chosen: Option<NounReading> = None;
for (kind, label, prefix, stem, forced) in noun_rows {
let curated = curated_gloss(&stem);
let is_curated = curated.is_some();
let resolved = curated
.map(|(root, gloss)| (root, gloss, false))
.or_else(|| self.hebrew_cons_root(&stem));
let resolves = resolved.is_some();
let (root, gloss, is_name) = resolved.unwrap_or_default();
let reading = NounReading {
kind,
label,
prefix: unfinalize(&prefix),
stem,
root,
gloss,
resolved: resolves,
curated: is_curated,
is_name,
forced,
};
if forced || resolves {
chosen = Some(reading);
break;
}
chosen.get_or_insert(reading);
}
chosen
};
let noun_resolves = noun.as_ref().is_some_and(|n| n.resolved);
let noun_curated = noun.as_ref().is_some_and(|n| n.curated);
let noun_forced = noun.as_ref().is_some_and(|n| n.forced);
let verb_forced = self
.db
.query_row(
"SELECT EXISTS(SELECT 1 FROM lexdb.primary_analysis_overrides \
WHERE surface = ?1 AND analysis_type = 'verb')",
[&norm],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
!= 0;
let article_pointed = |prefix: &str| {
let mut cs = prefix.chars();
cs.next() == Some('\u{05D4}')
&& matches!(cs.next(), Some('\u{05B7}' | '\u{05B8}' | '\u{05B6}'))
};
let article_noun =
noun_resolves && noun.as_ref().is_some_and(|n| article_pointed(&n.prefix));
let verb_shadows_article = verb.as_ref().is_some_and(|v| {
let participle = v.2.contains("Participle");
(!participle && v.4.starts_with('\u{05D4}')) || (!v.7 && v.0.starts_with('\u{05D4}'))
});
let verb_resolves = verb.as_ref().is_some_and(|v| v.8)
&& !(article_noun && verb_shadows_article)
&& !noun_curated;
if let Some((root, binyan, tense, pgn, prefix, vav_con, obj_suffix, _, _)) = verb
.as_ref()
.filter(|_| verb_forced || (!noun_forced && (verb_resolves || !noun_resolves)))
{
let (person, gender, number) = decode_pgn(pgn);
let gloss = self.hebrew_root_gloss(root);
return Some(HebrewWord {
word: norm,
root: root.clone(),
gloss,
form: (!binyan.is_empty()).then(|| binyan.clone()),
tense: (!tense.is_empty()).then(|| tense.clone()),
person,
gender,
number,
state: None,
prefix: (!prefix.is_empty()).then(|| prefix.clone()),
vav_con: *vav_con,
obj_suffix: (!obj_suffix.is_empty()).then(|| obj_suffix.clone()),
is_name: false,
});
}
if let Some(n) = noun {
let (mut number, mut state) = decode_noun_label(&n.label);
let opaque = number.is_none()
&& state
.as_deref()
.is_some_and(|s| s.starts_with("Irregular (") || s.starts_with("Noun ("));
let stem_cons = fold_consonants(&n.stem);
let inflected =
fold_consonants(&norm) != format!("{}{stem_cons}", fold_consonants(&n.prefix));
if opaque && inflected {
let splits: Vec<(&str, String)> =
crate::pronoun_suffix::pronoun_suffix_splits(&norm)
.into_iter()
.map(|sp| (sp.key, fold_consonants(&sp.stem)))
.collect();
let fem_cons = stem_cons
.strip_suffix('\u{05D4}')
.map(|s| format!("{s}\u{05EA}"));
let fem_plural_cons = stem_cons
.strip_suffix('\u{05D4}')
.map(|s| format!("{s}\u{05D5}\u{05EA}"));
let he_dropped = stem_cons.strip_suffix('\u{05D4}').filter(|s| !s.is_empty());
let anchored = |rest: &str| {
rest.ends_with(&stem_cons)
|| rest.starts_with(&stem_cons)
|| fem_cons
.as_deref()
.is_some_and(|f| rest.ends_with(f) || rest.starts_with(f))
|| fem_plural_cons
.as_deref()
.is_some_and(|f| rest.ends_with(f) || rest.starts_with(f))
|| he_dropped.is_some_and(|d| rest.ends_with(d))
};
let split = splits
.iter()
.find(|(_, rest)| anchored(rest))
.or_else(|| {
splits.iter().find(|(_, rest)| {
rest.len() >= 4 && stem_cons.starts_with(rest.as_str())
})
});
if let Some((key, rest)) = split {
state = state.map(|s| format!("{s} + {key}"));
if rest.len() > stem_cons.len() + fold_consonants(&n.prefix).len() {
number = Some("Plural".to_string());
}
} else if has_plural_tail(&norm) {
number = Some("Plural".to_string());
}
}
return Some(HebrewWord {
word: norm,
root: n.root,
gloss: n.gloss,
form: None,
tense: None,
person: None,
gender: (!n.kind.is_empty()).then_some(n.kind),
number,
state,
prefix: (!n.prefix.is_empty()).then_some(n.prefix),
vav_con: false,
obj_suffix: None,
is_name: n.is_name,
});
}
if let Some((root, gloss)) = curated_gloss(&norm) {
return Some(HebrewWord {
word: norm,
root,
gloss,
form: None,
tense: None,
person: None,
gender: None,
number: None,
state: None,
prefix: None,
vav_con: false,
obj_suffix: None,
is_name: false,
});
}
let bridge = self
.db
.query_row(
"SELECT la.root, la.gloss, la.prefix \
FROM hebrewdb.lexical_analyses la \
WHERE la.surface_id = ?1",
[surface_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.optional()
.ok()
.flatten();
if let Some((root, gloss, prefix)) = bridge {
return Some(HebrewWord {
word: norm,
root,
gloss,
form: None,
tense: None,
person: None,
gender: None,
number: None,
state: None,
prefix: (!prefix.is_empty()).then(|| unfinalize(&prefix)),
vav_con: false,
obj_suffix: None,
is_name: false,
});
}
None
}
fn hebrew_cons_root(&self, stem: &str) -> Option<(String, String, bool)> {
if let Some(rows) = bdb_rows(&self.db, stem) {
let canonical = normalize_hebrew_combining(&strip_accents(stem));
let exact = |(word, ..): &&(String, String, String, String)| {
normalize_hebrew_combining(&strip_accents(word)) == canonical
};
if let Some((_, root, gloss, pos)) = rows
.iter()
.find(|row| exact(row) && !row.3.starts_with("vb") && !name_pos(&row.3))
.or_else(|| {
rows.iter()
.find(|row| exact(row) && !row.3.starts_with("vb"))
})
.or_else(|| rows.iter().find(exact))
.or_else(|| rows.first())
{
return Some((root.clone(), gloss.clone(), name_pos(pos)));
}
}
let cons = fold_consonants(stem);
if cons.is_empty() {
return None;
}
self.db
.query_row(
"SELECT root, pos FROM lexdb.bdb \
WHERE cons = ?1 AND (gloss IS NULL OR gloss = '' OR gloss LIKE '(%') \
ORDER BY bdb_id LIMIT 1",
[cons],
|row| {
Ok((
row.get::<_, String>(0)?,
String::new(),
name_pos(&row.get::<_, Option<String>>(1)?.unwrap_or_default()),
))
},
)
.optional()
.ok()
.flatten()
}
pub(crate) fn bdb_exact_vocab_match(&self, surface: &str, prefix: Option<&str>) -> bool {
if let Some(stem) = prefix.and_then(|p| strip_proclitic(surface, p))
&& self.bdb_exact_vocab_match(&stem, None)
{
return true;
}
let cons = fold_consonants(surface);
if cons.is_empty() {
return false;
}
let Ok(mut stmt) = self
.db
.prepare("SELECT word, pos FROM lexdb.bdb WHERE cons = ?1")
else {
return false;
};
let Ok(rows) = stmt
.query_map([&cons], |row| {
Ok((
row.get::<_, Option<String>>(0)?.unwrap_or_default(),
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
))
})
.and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
else {
return false;
};
let canonical = normalize_hebrew_combining(&strip_accents(surface));
rows.iter().any(|(word, pos)| {
!pos.is_empty()
&& !name_pos(pos)
&& normalize_hebrew_combining(&strip_accents(word)) == canonical
})
}
fn hebrew_root_gloss(&self, root: &str) -> String {
let Ok(mut stmt) = self.db.prepare(
"SELECT word, gloss FROM lexdb.bdb \
WHERE root = ?1 AND gloss IS NOT NULL AND gloss <> '' \
ORDER BY bdb_id",
) else {
return String::new();
};
let Ok(rows) = stmt
.query_map([root], |row| {
Ok((
row.get::<_, Option<String>>(0)?.unwrap_or_default(),
row.get::<_, String>(1)?,
))
})
.and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
else {
return String::new();
};
let mut glosses: Vec<String> = rows
.into_iter()
.map(|(word, imported)| {
curated_gloss(&word)
.filter(|(curated_root, _)| curated_root == root)
.map(|(_, gloss)| gloss)
.unwrap_or(imported)
})
.filter(|gloss| !cross_reference_gloss(gloss) && !root_stub_gloss(gloss))
.collect();
glosses.sort_by_key(|gloss| {
gloss
.chars()
.next()
.is_some_and(|c| matches!(c as u32, 0x0590..=0x05FF))
});
glosses.into_iter().next().unwrap_or_default()
}
pub fn hebrew_bdb_for_surface(
&self,
word: &str,
prefix: &str,
) -> rusqlite::Result<Vec<BdbEntry>> {
let target = if prefix.is_empty() {
word.to_string()
} else {
strip_proclitic(word, prefix).unwrap_or_else(|| word.to_string())
};
let cons = fold_consonants(&target);
if cons.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.db.prepare(
"SELECT word, root, gloss, content_json, pos, type FROM lexdb.bdb \
WHERE cons = ?1 ORDER BY bdb_id",
)?;
let rows = stmt
.query_map([&cons], |row| {
Ok((
row.get::<_, Option<String>>(0)?.unwrap_or_default(),
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?.unwrap_or_default(),
row.get::<_, Option<String>>(3)?.unwrap_or_default(),
row.get::<_, Option<String>>(4)?.unwrap_or_default(),
row.get::<_, Option<String>>(5)?.as_deref() == Some("root"),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let canonical = normalize_hebrew_combining(&strip_accents(&target));
let has_exact = rows
.iter()
.any(|(w, ..)| normalize_hebrew_combining(&strip_accents(w)) == canonical);
Ok(rows
.into_iter()
.filter(|(w, ..)| {
!has_exact || normalize_hebrew_combining(&strip_accents(w)) == canonical
})
.map(|(word, root, gloss, content_json, pos, is_root)| {
display_bdb_entry(BdbEntry {
headword: normalize_hebrew_combining(&word),
root,
gloss,
content_json,
pos,
is_root,
})
})
.filter(BdbEntry::has_content)
.collect())
}
pub fn hebrew_bdb_by_root(&self, root: &str) -> rusqlite::Result<Vec<BdbEntry>> {
if root.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.db.prepare(
"SELECT word, root, gloss, content_json, pos, type FROM lexdb.bdb \
WHERE root = ?1 ORDER BY bdb_id",
)?;
let entries = stmt
.query_map([root], |row| {
Ok(display_bdb_entry(BdbEntry {
headword: normalize_hebrew_combining(
row.get::<_, Option<String>>(0)?
.unwrap_or_default()
.as_str(),
),
root: row.get(1)?,
gloss: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
content_json: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
pos: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
is_root: row.get::<_, Option<String>>(5)?.as_deref() == Some("root"),
}))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut seen_root_rows = HashSet::new();
Ok(entries
.into_iter()
.filter(BdbEntry::has_content)
.filter(|entry| {
entry.pos_category() != "root"
|| seen_root_rows.insert((entry.headword.clone(), entry.gloss.clone()))
})
.collect())
}
pub fn hebrew_bdb_by_id(&self, bdb_id: &str) -> rusqlite::Result<Option<BdbEntry>> {
if bdb_id.is_empty() {
return Ok(None);
}
self.db
.query_row(
"SELECT word, root, gloss, content_json, pos, type FROM lexdb.bdb \
WHERE bdb_id = ?1",
[bdb_id],
|row| {
Ok(display_bdb_entry(BdbEntry {
headword: normalize_hebrew_combining(
row.get::<_, Option<String>>(0)?
.unwrap_or_default()
.as_str(),
),
root: row.get(1)?,
gloss: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
content_json: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
pos: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
is_root: row.get::<_, Option<String>>(5)?.as_deref() == Some("root"),
}))
},
)
.optional()
}
pub fn vocab(&self, limit: u32, offset: u32) -> rusqlite::Result<Vec<VocabEntry>> {
let mut stmt = self.db.prepare(
"SELECT text, occurrences, lexical_class FROM hebrewdb.surface \
WHERE language IS NULL \
ORDER BY occurrences DESC, surface_id \
LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt
.query_map([limit, offset], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, u32>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows
.into_iter()
.map(|(surface, occurrences, lexical_class)| {
let (root, gloss, morph) = self.vocab_resolve(&surface);
VocabEntry {
surface,
occurrences,
lexical_class,
root,
gloss,
morph,
}
})
.collect())
}
fn vocab_resolve(&self, surface: &str) -> (String, String, String) {
if let Some((root, gloss)) = curated_gloss(surface).or_else(|| bdb_exact(&self.db, surface))
{
return (root, gloss, String::new());
}
for (proclitic, meaning) in PROCLITICS {
if let Some(rest) = strip_proclitic(surface, proclitic) {
let matched = curated_gloss(&rest)
.or_else(|| bdb_exact(&self.db, &rest))
.or_else(|| {
(fold_consonants(&rest).chars().count() >= 3)
.then(|| bdb_cons(&self.db, &rest))
.flatten()
});
if let Some((root, gloss)) = matched {
return (root, gloss, format!("{proclitic}־ ({meaning}) + {rest}"));
}
}
}
if let Some(info) = self
.hebrew_word_info(surface)
.filter(|i| !i.gloss.is_empty())
{
let morph = morph_summary(&info);
return (info.root, info.gloss, morph);
}
if let Some((root, gloss)) = bdb_cons(&self.db, surface) {
return (root, gloss, String::new());
}
(String::new(), String::new(), String::new())
}
pub fn hebrew_surface_occurrences(&self, word: &str) -> rusqlite::Result<Vec<WordOccurrence>> {
let norm = crate::normalize_surface(word);
let mut stmt = self.db.prepare(
"SELECT o.book, o.chapter, o.verse FROM hebrewdb.occurrences o \
JOIN hebrewdb.surface s ON s.surface_id = o.surface_id \
WHERE s.text = ?1 ORDER BY o.book, o.chapter, o.verse",
)?;
stmt.query_map([&norm], |row| {
Ok(WordOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
})
})?
.collect()
}
pub fn hebrew_root_occurrences(&self, root: &str) -> rusqlite::Result<Vec<WordOccurrence>> {
if root.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.db.prepare(
"SELECT DISTINCT o.book, o.chapter, o.verse FROM hebrewdb.occurrences o \
WHERE o.surface_id IN ( \
SELECT a.surface_id FROM hebrewdb.analyses a WHERE a.root = ?1 \
UNION \
SELECT n.surface_id FROM hebrewdb.noun_analyses n \
JOIN lexdb.bdb b ON b.word = n.stem AND b.root = ?1 \
) \
ORDER BY o.book, o.chapter, o.verse",
)?;
stmt.query_map([root], |row| {
Ok(WordOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
})
})?
.collect()
}
pub fn hebrew_root_occurrences_detailed(
&self,
root: &str,
) -> rusqlite::Result<Vec<HebrewOccurrence>> {
if root.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.db.prepare(
"SELECT DISTINCT o.book, o.chapter, o.verse, s.text \
FROM hebrewdb.occurrences o \
JOIN hebrewdb.surface s ON s.surface_id = o.surface_id \
WHERE o.surface_id IN ( \
SELECT a.surface_id FROM hebrewdb.analyses a WHERE a.root = ?1 \
UNION \
SELECT n.surface_id FROM hebrewdb.noun_analyses n \
JOIN lexdb.bdb b ON b.word = n.stem AND b.root = ?1 \
) \
ORDER BY o.book, o.chapter, o.verse, s.text",
)?;
stmt.query_map([root], |row| {
Ok(HebrewOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
form: row.get(3)?,
})
})?
.collect()
}
pub fn sedra_word_info(&self, vocalised: &str) -> rusqlite::Result<Vec<SedraWord>> {
let mut stmt = self.db.prepare(
"SELECT w.keyLexeme, l.keyRoot, w.strWord, w.strVocalised, l.strLexeme, r.strRoot, \
w.keyGender, w.keyPerson, w.keyNumber, w.keyState, w.keyTense, w.keyForm, \
w.keySuffixPerson, w.keySuffixGender, w.keySuffixNumber \
FROM sedradb.words w \
JOIN sedradb.lexemes l ON w.keyLexeme = l.keyLexeme \
JOIN sedradb.roots r ON l.keyRoot = r.keyRoot \
WHERE replace(replace(w.strVocalised, char(1471), ''), char(95), '') = ?1 \
ORDER BY w.keyWord",
)?;
let key = crate::transliterate::lookup_key(vocalised);
let mut words = stmt
.query_map([key], |row| {
Ok(SedraWord {
key_lexeme: row.get(0)?,
key_root: row.get(1)?,
consonantal: display(row.get::<_, String>(2)?),
word: display(row.get::<_, String>(3)?),
lexeme: display(row.get::<_, String>(4)?),
root: display(row.get::<_, String>(5)?),
gender: decode_gender(row.get(6)?),
person: decode_person(row.get(7)?),
number: decode_number(row.get(8)?),
state: decode_state(row.get(9)?),
tense: decode_tense(row.get(10)?),
form: decode_form(row.get(11)?),
suffix: decode_suffix(row.get(12)?, row.get(13)?, row.get(14)?),
meanings: Vec::new(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
for word in words.iter_mut() {
word.meanings = self.sedra_meanings(word.key_lexeme)?;
}
Ok(words)
}
fn sedra_meanings(&self, key_lexeme: i64) -> rusqlite::Result<Vec<String>> {
let mut stmt = self.db.prepare(
"SELECT strBefore, strMeaning, strAfter FROM sedradb.english \
WHERE keyLexeme = ?1 ORDER BY keyEnglish",
)?;
stmt.query_map([key_lexeme], |row| {
let before: String = row.get(0)?;
let meaning: String = row.get(1)?;
let after: String = row.get(2)?;
Ok([before, meaning, after]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" "))
})?
.collect()
}
pub fn sedra_root_tree(
&self,
key_root: i64,
current_key_lexeme: i64,
) -> rusqlite::Result<Vec<SedraLexemeSummary>> {
let mut stmt = self.db.prepare(
"SELECT keyLexeme, strLexeme FROM sedradb.lexemes \
WHERE keyRoot = ?1 ORDER BY keyLexeme",
)?;
let lexemes = stmt
.query_map([key_root], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut tree = Vec::with_capacity(lexemes.len());
for (key_lexeme, lexeme) in lexemes {
tree.push(SedraLexemeSummary {
lexeme: display(lexeme),
meanings: self.sedra_meanings(key_lexeme)?,
is_current: key_lexeme == current_key_lexeme,
});
}
Ok(tree)
}
pub fn sedra_lexeme_occurrences(
&self,
key_lexeme: i64,
) -> rusqlite::Result<Vec<WordOccurrence>> {
let mut stmt = self.db.prepare(
"SELECT DISTINCT o.book, o.chapter, o.verse FROM sedradb.occurrences o \
JOIN sedradb.words w ON o.keyWord = w.keyWord \
WHERE w.keyLexeme = ?1 ORDER BY o.book, o.chapter, o.verse",
)?;
stmt.query_map([key_lexeme], |row| {
Ok(WordOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
})
})?
.collect()
}
pub fn sedra_root_occurrences(&self, key_root: i64) -> rusqlite::Result<Vec<WordOccurrence>> {
let mut stmt = self.db.prepare(
"SELECT DISTINCT o.book, o.chapter, o.verse FROM sedradb.occurrences o \
JOIN sedradb.words w ON o.keyWord = w.keyWord \
JOIN sedradb.lexemes l ON w.keyLexeme = l.keyLexeme \
WHERE l.keyRoot = ?1 ORDER BY o.book, o.chapter, o.verse",
)?;
stmt.query_map([key_root], |row| {
Ok(WordOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
})
})?
.collect()
}
pub fn ot_root_occurrences(
&self,
sedra_key_root: i64,
) -> rusqlite::Result<Vec<WordOccurrence>> {
let root: String = self.db.query_row(
"SELECT strRoot FROM sedradb.roots WHERE keyRoot = ?1",
[sedra_key_root],
|row| row.get(0),
)?;
let key = crate::transliterate::lookup_key(&root);
if key.is_empty() {
return Ok(Vec::new());
}
let mut stmt = self.db.prepare(
"SELECT DISTINCT o.book, o.chapter, o.verse FROM hebrewdb.occurrences o \
WHERE o.surface_id IN ( \
SELECT a.surface_id FROM hebrewdb.analyses a WHERE a.root = ?1 \
UNION \
SELECT n.surface_id FROM hebrewdb.noun_analyses n \
JOIN lexdb.bdb b ON b.word = n.stem \
WHERE b.root = ?1 OR b.cons = ?1 \
) \
ORDER BY o.book, o.chapter, o.verse",
)?;
stmt.query_map([key], |row| {
Ok(WordOccurrence {
book: row.get(0)?,
chapter: row.get(1)?,
verse: row.get(2)?,
})
})?
.collect()
}
pub fn sedra_root_occurrences_detailed(
&self,
key_root: i64,
) -> rusqlite::Result<Vec<SedraOccurrence>> {
let mut idx_stmt = self.db.prepare(
"SELECT keyLexeme FROM sedradb.lexemes WHERE keyRoot = ?1 ORDER BY keyLexeme",
)?;
let mut lexeme_index = HashMap::new();
let keys = idx_stmt
.query_map([key_root], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (i, key) in keys.into_iter().enumerate() {
lexeme_index.insert(key, i as u32);
}
let mut stmt = self.db.prepare(
"SELECT o.book, o.chapter, o.verse, w.keyLexeme, w.strVocalised \
FROM sedradb.occurrences o \
JOIN sedradb.words w ON o.keyWord = w.keyWord \
JOIN sedradb.lexemes l ON w.keyLexeme = l.keyLexeme \
WHERE l.keyRoot = ?1 \
ORDER BY o.book, o.chapter, o.verse, w.keyLexeme",
)?;
let rows = stmt
.query_map([key_root], |row| {
Ok((
row.get::<_, u8>(0)?,
row.get::<_, u8>(1)?,
row.get::<_, u8>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut out: Vec<SedraOccurrence> = Vec::new();
for (book, chapter, verse, key_lexeme, word) in rows {
let index = *lexeme_index.get(&key_lexeme).unwrap_or(&0);
match out.last_mut() {
Some(last)
if last.book == book
&& last.chapter == chapter
&& last.verse == verse
&& last.lexeme_index == index =>
{
if !last.words.contains(&word) {
last.words.push(word);
}
}
_ => out.push(SedraOccurrence {
book,
chapter,
verse,
lexeme_index: index,
words: vec![word],
}),
}
}
Ok(out)
}
pub fn sedra_lookup(&self, word: &str) -> rusqlite::Result<Vec<SedraEntry>> {
let words = self.sedra_word_info(word)?;
let mut entries = Vec::new();
for w in &words {
for meaning in &w.meanings {
entries.push(SedraEntry {
lexeme: w.lexeme.clone(),
root: w.root.clone(),
meaning: meaning.clone(),
});
}
}
Ok(entries)
}
pub fn chapter_count(&self, book: u8) -> rusqlite::Result<u8> {
self.db.query_row(
"SELECT MAX(chapter) FROM bibledb.bible WHERE book = ?1",
[book],
|row| row.get(0),
)
}
}
#[cfg(all(test, feature = "embedded"))]
mod embedded_tests {
use super::*;
#[test]
fn test_embedded_database_open() {
if Asset::get("bible.db").is_none() {
eprintln!("skipping: data/*.db not embedded in this build");
return;
}
let bible = Bible::default();
assert!(bible.get(1, 1, 1).unwrap().starts_with('ב'));
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! require_data {
() => {
if !Path::new("data/bible.db").exists() {
eprintln!("skipping: data/*.db not generated in this checkout");
return;
}
};
}
#[test]
fn test_database_open() {
require_data!();
let bible = Bible::open("data").unwrap();
let ot = bible.get(1, 1, 1).unwrap();
assert!(ot.starts_with('ב'));
assert!(!bible.sedra_word_info("כּתָבָא").unwrap().is_empty());
assert!(bible.hebrew_word_info("בָּרָא").is_some());
assert!(!bible.hebrew_bdb_by_root("ברא").unwrap().is_empty());
}
#[test]
fn plural_tantum_nouns_resolve_as_nouns() {
require_data!();
let bible = Bible::open("data").unwrap();
for (surface, gloss) in [
("מַיִם", "water; waters"),
("הַמַּיִם", "water; waters"),
("שָׁמַיִם", "heavens; sky"),
("הַשָּׁמָיִם", "heavens; sky"), ("פָּנִים", "face; faces"),
] {
let w = bible.hebrew_word_info(surface).unwrap();
assert_eq!(w.gloss, gloss, "wrong gloss for {surface}: {w:?}");
assert!(w.tense.is_none(), "verb reading won for {surface}: {w:?}");
}
}
#[test]
fn test_get_reads_bible_table() {
require_data!();
let bible = Bible::open("data").unwrap();
let ot = bible.get(1, 1, 1).unwrap();
assert_eq!(ot.split(' ').count(), 7);
assert!(ot.starts_with('ב'));
assert!(ot.ends_with('׃'));
let matt = bible.get(40, 1, 1).unwrap();
assert_eq!(matt.split(' ').count(), 8);
assert!(matt.starts_with('כ'));
}
#[test]
fn nt_hebrew_round_trips_through_syriac() {
require_data!();
let bible = Bible::open("data").unwrap();
let mut stmt = bible
.db
.prepare("SELECT words FROM bibledb.bible WHERE book >= 40")
.unwrap();
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert_eq!(rows.len(), 7958);
for hebrew in rows {
let syriac = crate::transliterate::hebrew_to_syriac(&hebrew);
let back = crate::transliterate::syriac_to_hebrew(&syriac);
assert_eq!(back, hebrew, "round trip failed for NT verse");
}
}
#[test]
fn test_chapter_count() {
require_data!();
let bible = Bible::open("data").unwrap();
assert_eq!(bible.chapter_count(1).unwrap(), 50); }
#[test]
fn test_sedra_word_info() {
require_data!();
let bible = Bible::open("data").unwrap();
let matt = bible.get(40, 1, 1).unwrap();
let first = matt.split(' ').next().unwrap();
let info = bible.sedra_word_info(first).unwrap();
assert!(!info.is_empty(), "no SEDRA match for {first}");
assert!(!info[0].root.is_empty());
assert!(!info[0].lexeme.is_empty());
assert!(
info.iter()
.any(|w| w.meanings.iter().any(|m| m.contains("book"))),
"expected a 'book' gloss"
);
let entries = bible.sedra_lookup(first).unwrap();
assert!(!entries.is_empty());
let w = &info[0];
let tree = bible.sedra_root_tree(w.key_root, w.key_lexeme).unwrap();
assert!(tree.len() > 1, "root should have several lexemes");
assert_eq!(tree.iter().filter(|l| l.is_current).count(), 1);
let ot_occ = bible.ot_root_occurrences(w.key_root).unwrap();
assert!(!ot_occ.is_empty(), "expected OT occurrences for root כתב");
assert!(ot_occ.iter().all(|o| o.book < 40));
let lex_occ = bible.sedra_lexeme_occurrences(w.key_lexeme).unwrap();
let root_occ = bible.sedra_root_occurrences(w.key_root).unwrap();
assert!(!lex_occ.is_empty());
assert!(root_occ.len() >= lex_occ.len());
assert!(root_occ.iter().all(|o| o.book >= 40));
let detailed = bible.sedra_root_occurrences_detailed(w.key_root).unwrap();
assert!(!detailed.is_empty());
assert!(detailed.iter().all(|o| o.book >= 40));
assert!(
detailed
.iter()
.all(|o| (o.lexeme_index as usize) < tree.len())
);
assert!(detailed.iter().all(|o| !o.words.is_empty()));
let distinct_verses: std::collections::HashSet<_> = detailed
.iter()
.map(|o| (o.book, o.chapter, o.verse))
.collect();
assert_eq!(distinct_verses.len(), root_occ.len());
}
#[test]
fn opaque_irregular_labels_recover_suffix_and_plural_cells() {
require_data!();
let bible = Bible::open("data").unwrap();
let w = bible.hebrew_word_info("שְׁמוֹ").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 3ms"),
"שְׁמוֹ (his name) should carry a 3ms suffix cell, got {:?}",
w.state
);
let w = bible.hebrew_word_info("אֲבֹתָם").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 3mp"),
"אֲבֹתָם (their fathers) should carry a 3mp suffix cell, got {:?}",
w.state
);
let w = bible.hebrew_word_info("אָבִינוּ").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 1cp"),
"אָבִינוּ (our father) should carry a 1cp suffix cell, got {:?}",
w.state
);
assert_eq!(inflected_gloss(&w), "our father");
assert!(
crate::grammar::concepts_for_surface("אָבִינוּ", Some(&w)).contains(&"suffix-possessive"),
"אָבִינוּ should gate behind suffix-possessive"
);
let w = bible.hebrew_word_info("עֲלִילוֹתָיו").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 3ms"),
"עֲלִילוֹתָיו (his deeds) should carry a 3ms suffix cell, got {:?}",
w.state
);
assert_eq!(w.number.as_deref(), Some("Plural"));
assert!(inflected_gloss(&w).starts_with("his "));
assert!(
crate::grammar::concepts_for_surface("עֲלִילוֹתָיו", Some(&w))
.contains(&"suffix-possessive"),
"עֲלִילוֹתָיו should gate behind suffix-possessive"
);
let w = bible.hebrew_word_info("אָבִיהָ").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 3fs"),
"אָבִיהָ (her father) should carry a 3fs suffix cell, got {:?}",
w.state
);
let w = bible.hebrew_word_info("פִּיו").unwrap();
assert!(
w.state.as_deref().unwrap_or("").contains("+ 3ms"),
"פִּיו (his mouth) should carry a 3ms suffix cell, got {:?}",
w.state
);
let w = bible.hebrew_word_info("אֲנָשִׁים").unwrap();
assert_eq!(
w.number.as_deref(),
Some("Plural"),
"אֲנָשִׁים (men) should recover its plural number"
);
let w = bible.hebrew_word_info("חַי").unwrap();
assert!(
!w.state.as_deref().unwrap_or("").contains('+'),
"the bare lemma חַי must not read its ־ַי as a pronoun, got {:?}",
w.state
);
let w = bible.hebrew_word_info("מֵאֶרֶץ").unwrap();
assert!(
w.prefix.as_deref().unwrap_or("").starts_with('\u{05DE}'),
"מֵאֶרֶץ's prefix should fold to a regular mem, got {:?}",
w.prefix
);
assert!(
crate::grammar::concepts_for_surface("מֵאֶרֶץ", Some(&w)).contains(&"prep-min"),
"מֵאֶרֶץ should gate behind prep-min"
);
assert_eq!(
crate::grammar::concepts_for_surface("וָמַעְלָה", None),
vec!["conj-ve"]
);
}
#[test]
#[ignore]
fn inspect_real_inflected_glosses() {
require_data!();
let bible = Bible::open("data").unwrap();
for surface in [
"בָּרָא", "וַיֹּאמֶר", "וַיַּרְא", "יִשְׁלַח", "שְׁמַע", "דְּבָרִים", "דְּבָרוֹ", "הַמֶּלֶךְ", "מְלָכִים", ] {
match bible.hebrew_word_info(surface) {
Some(w) => eprintln!(
"{surface:14} [{}] -> {}",
morph_summary(&w),
inflected_gloss(&w)
),
None => eprintln!("{surface:14} -> (no parse)"),
}
}
}
#[test]
fn inflected_gloss_renders_forms_in_english() {
let verb = |tense: &str, pgn: (&str, &str, &str), gloss: &str| HebrewWord {
gloss: gloss.to_string(),
form: Some("Qal".to_string()),
tense: Some(tense.to_string()),
person: (!pgn.0.is_empty()).then(|| pgn.0.to_string()),
gender: (!pgn.1.is_empty()).then(|| pgn.1.to_string()),
number: (!pgn.2.is_empty()).then(|| pgn.2.to_string()),
..Default::default()
};
assert_eq!(
inflected_gloss(&verb("Perfect", ("Third", "Masculine", "Singular"), "say")),
"he said"
);
assert_eq!(
inflected_gloss(&verb(
"Perfect",
("Third", "Feminine", "Singular"),
"utter; say"
)),
"she uttered"
);
assert_eq!(
inflected_gloss(&verb("Perfect", ("First", "Common", "Singular"), "keep")),
"I kept"
);
assert_eq!(
inflected_gloss(&verb(
"Wayyiqtol",
("Third", "Masculine", "Singular"),
"love"
)),
"and he loved"
);
assert_eq!(
inflected_gloss(&verb(
"Imperfect",
("Second", "Masculine", "Singular"),
"send"
)),
"you will send"
);
assert_eq!(
inflected_gloss(&verb(
"Imperative",
("Second", "Masculine", "Singular"),
"hear"
)),
"hear!"
);
assert_eq!(
inflected_gloss(&verb("Inf. Construct", ("", "", ""), "keep")),
"to keep"
);
assert_eq!(
inflected_gloss(&verb(
"Participle (act.)",
("", "Masculine", "Singular"),
"make"
)),
"making"
);
let mut struck = verb("Wayyiqtol", ("Third", "Masculine", "Singular"), "smite");
struck.obj_suffix = Some("3ms".to_string());
assert_eq!(inflected_gloss(&struck), "and he smote him");
let noun = |number: Option<&str>, state: Option<&str>, gloss: &str| HebrewWord {
gloss: gloss.to_string(),
number: number.map(str::to_string),
state: state.map(str::to_string),
..Default::default()
};
assert_eq!(
inflected_gloss(&noun(Some("Plural"), Some("Absolute"), "king")),
"kings"
);
assert_eq!(
inflected_gloss(&noun(Some("Plural"), Some("Absolute"), "man")),
"men"
);
assert_eq!(
inflected_gloss(&noun(Some("Singular"), Some("Construct"), "word")),
"word of"
);
assert_eq!(
inflected_gloss(&noun(None, Some("Sg + 3ms"), "word")),
"his word"
);
let mut the_king = noun(Some("Singular"), Some("Absolute"), "king");
the_king.prefix = Some("הַ".to_string());
assert_eq!(inflected_gloss(&the_king), "the king");
let mut and_to_the_house = noun(Some("Singular"), Some("Absolute"), "house");
and_to_the_house.prefix = Some("וְלַ".to_string());
assert_eq!(inflected_gloss(&and_to_the_house), "and to the house");
let mut in_the_day = noun(Some("Singular"), Some("Absolute"), "day");
in_the_day.prefix = Some("בַּ".to_string());
assert_eq!(inflected_gloss(&in_the_day), "in the day");
let mut to_a_king = noun(Some("Singular"), Some("Absolute"), "king");
to_a_king.prefix = Some("לְ".to_string());
assert_eq!(inflected_gloss(&to_a_king), "to king");
let mut from_the_land = noun(Some("Singular"), Some("Absolute"), "land");
from_the_land.prefix = Some("מֵהָ".to_string());
assert_eq!(inflected_gloss(&from_the_land), "from the land");
let mut the_carmelite = noun(
Some("Singular"),
Some("Absolute"),
"the Carmelite; the Carmelitess",
);
the_carmelite.prefix = Some("הַ".to_string());
assert_eq!(inflected_gloss(&the_carmelite), "the Carmelite");
let particle = HebrewWord {
gloss: "that; because".to_string(),
..Default::default()
};
assert_eq!(inflected_gloss(&particle), "that; because");
let and_who = HebrewWord {
gloss: "who; which; that".to_string(),
prefix: Some("וַ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&and_who), "and who");
let and_to_me = HebrewWord {
gloss: "to me; unto me".to_string(),
prefix: Some("וְ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&and_to_me), "and to me");
let to_them = HebrewWord {
gloss: "they".to_string(),
prefix: Some("לָ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&to_them), "to them");
let in_this = HebrewWord {
gloss: "this; here".to_string(),
prefix: Some("בָּ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&in_this), "in this");
let forever = HebrewWord {
gloss: "until; as far as; while".to_string(),
prefix: Some("לָ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&forever), "until; as far as; while");
let and_until = HebrewWord {
gloss: "until; as far as; while".to_string(),
prefix: Some("וְ".to_string()),
..Default::default()
};
assert_eq!(inflected_gloss(&and_until), "and until");
}
#[test]
fn form_distractors_contrasts_tense_for_participle_and_infinitive() {
let verb = |tense: &str, pgn: (&str, &str, &str), gloss: &str| HebrewWord {
gloss: gloss.to_string(),
form: Some("Qal".to_string()),
tense: Some(tense.to_string()),
person: (!pgn.0.is_empty()).then(|| pgn.0.to_string()),
gender: (!pgn.1.is_empty()).then(|| pgn.1.to_string()),
number: (!pgn.2.is_empty()).then(|| pgn.2.to_string()),
..Default::default()
};
let participle = verb("Participle (act.)", ("", "Masculine", "Singular"), "say");
let d = form_distractors(&participle);
assert!(
!d.is_empty(),
"participle should get form distractors, got none"
);
assert!(
!d.contains(&"saying".to_string()),
"must not include its own gloss"
);
let infinitive = verb("Inf. Construct", ("", "", ""), "say");
let d = form_distractors(&infinitive);
assert!(
!d.is_empty(),
"infinitive should get form distractors, got none"
);
assert!(
!d.contains(&"to say".to_string()),
"must not include its own gloss"
);
}
#[test]
fn test_hebrew_word_info_verb() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible.hebrew_word_info("בָּרָא").expect("verb should parse");
assert_eq!(info.root, "ברא");
assert!(info.gloss.to_lowercase().contains("create"));
assert_eq!(info.tense.as_deref(), Some("Perfect"));
assert_eq!(info.person.as_deref(), Some("Third"));
let was = bible
.hebrew_word_info("הָיְתָה")
.expect("3fs perfect of היה should parse");
assert_eq!(was.root, "היה");
assert_eq!(was.gloss, "be");
assert_eq!(was.tense.as_deref(), Some("Perfect"));
assert_eq!(was.gender.as_deref(), Some("Feminine"));
assert_eq!(inflected_gloss(&was), "she was");
let tree = bible.hebrew_bdb_by_root(&info.root).unwrap();
assert!(!tree.is_empty());
assert!(tree.iter().all(|e| e.root == "ברא"));
assert!(tree.iter().any(|e| !e.content_json.is_empty()));
let form = bible.hebrew_surface_occurrences("בָּרָא").unwrap();
let root = bible.hebrew_root_occurrences(&info.root).unwrap();
assert!(!form.is_empty());
assert!(root.len() >= form.len());
assert!(root.iter().all(|o| o.book < 40));
}
#[test]
fn verse_glosses_prefer_intext_override_to_lexicon_gloss() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible
.hebrew_word_info("אֵת")
.expect("object marker resolves");
assert_eq!(info.gloss, "mark of the accusative");
let glosses = bible.verse_glosses(1, 1, 1).unwrap();
assert_eq!(glosses[3], "←");
assert_eq!(glosses[5], "and ←");
}
#[test]
fn test_hebrew_bdb_proper_noun_grouping() {
require_data!();
let bible = Bible::open("data").unwrap();
let tree = bible.hebrew_bdb_by_root("שמע").unwrap();
let (common, proper): (Vec<_>, Vec<_>) = tree.iter().partition(|e| !e.is_proper_noun());
assert!(common.iter().any(|e| e.gloss == "hear"));
assert!(
proper
.iter()
.any(|e| e.gloss.contains("second son of Jacob"))
);
assert!(proper.iter().all(|e| e.pos.starts_with("n.pr")));
assert!(common.iter().all(|e| !e.pos.starts_with("n.pr")));
}
#[test]
fn test_hebrew_bdb_pos_category() {
require_data!();
let bible = Bible::open("data").unwrap();
let tree = bible.hebrew_bdb_by_root("אבה").unwrap();
let cat = |id: &str| {
tree.iter()
.find(|e| e.gloss.starts_with(id) || e.headword == id)
.map(BdbEntry::pos_category)
};
assert_eq!(cat("be willing"), Some("verb"));
assert_eq!(cat("my father is joy"), Some("proper")); let abugil = bible.hebrew_bdb_by_id("a.ae.bd").unwrap().unwrap();
assert!(abugil.gloss.starts_with("see"));
assert_eq!(abugil.pos_category(), "proper");
let header = bible.hebrew_bdb_by_id("a.ae.aa").unwrap().unwrap();
assert!(header.is_root && header.pos.is_empty());
assert_eq!(header.pos_category(), "root");
let verb = bible.hebrew_bdb_by_id("a.ad.aa").unwrap().unwrap();
assert!(verb.is_root);
assert_eq!(verb.pos_category(), "verb");
}
#[test]
fn test_hebrew_bdb_xref_navigation() {
require_data!();
let bible = Bible::open("data").unwrap();
let stub = bible
.hebrew_bdb_by_id("n.cr.am")
.unwrap()
.expect("stub entry exists");
assert!(stub.content_json.contains("\"xref\":\"a.ef.ac\""));
let target = bible
.hebrew_bdb_by_id("a.ef.ac")
.unwrap()
.expect("xref target exists");
assert!(!target.root.is_empty());
assert!(!bible.hebrew_bdb_by_root(&target.root).unwrap().is_empty());
assert!(bible.hebrew_bdb_by_id("").unwrap().is_none());
assert!(bible.hebrew_bdb_by_id("no.such.id").unwrap().is_none());
}
#[test]
fn test_hebrew_bdb_root_tree_hides_empty_section_headers() {
require_data!();
let bible = Bible::open("data").unwrap();
let tree = bible.hebrew_bdb_by_root("אבה").unwrap();
assert!(!tree.is_empty());
assert!(
tree.iter().all(BdbEntry::has_content),
"root tree must not list content-less section headers"
);
let stub = bible
.hebrew_bdb_by_id("xa.ac.aa")
.unwrap()
.expect("section header still resolvable by id");
assert!(!stub.has_content());
}
#[test]
fn test_hebrew_word_info_noun() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible.hebrew_word_info("אֱלֹהִים").expect("noun should parse");
assert_eq!(info.root, "אלה");
assert_eq!(info.gloss, "God; gods");
assert_eq!(info.gender.as_deref(), Some("Masculine"));
let tree = bible.hebrew_bdb_by_root(&info.root).unwrap();
assert!(!tree.is_empty());
let elohim = tree
.iter()
.find(|entry| entry.headword == "אֱלֹהִים")
.expect("Elohim should appear in its root tree");
assert_eq!(elohim.gloss, "God; gods");
let these: Vec<_> = tree
.iter()
.filter(|entry| entry.pos_category() == "root" && entry.gloss == "these")
.collect();
assert_eq!(these.len(), 1);
assert_eq!(these[0].headword, "אֵלֶּה");
assert_eq!(strip_accents(&these[0].headword), these[0].headword);
let earth = bible.hebrew_word_info("הָאָרֶץ").expect("noun should parse");
assert_eq!(earth.root, "ארצ");
assert!(!bible.hebrew_bdb_by_root(&earth.root).unwrap().is_empty());
assert!(
!bible
.hebrew_root_occurrences(&earth.root)
.unwrap()
.is_empty()
);
let and_earth = bible
.hebrew_word_info("וְהָאָרֶץ")
.expect("conjunctive noun should parse");
assert_eq!(and_earth.root, "ארצ");
assert!(and_earth.form.is_none());
assert!(and_earth.tense.is_none());
assert_eq!(inflected_gloss(&and_earth), "and the earth");
let verb_rows: i64 = bible
.conn()
.query_row(
"SELECT COUNT(*) FROM hebrewdb.analyses a \
JOIN hebrewdb.surface s USING(surface_id) WHERE s.text = ?1",
["וְהָאָרֶץ"],
|row| row.get(0),
)
.unwrap();
assert_eq!(verb_rows, 0);
}
#[test]
fn test_hebrew_word_info_noun_verb_headword_tie() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible.hebrew_word_info("הָאוֹר").expect("noun should parse");
assert_eq!(info.gloss, "light");
assert_eq!(inflected_gloss(&info), "the light");
}
#[test]
fn test_cons_bridge_demotes_name_on_exact_headword_tie() {
require_data!();
let bible = Bible::open("data").unwrap();
let (_, gloss, is_name) = bible.hebrew_cons_root("גּוּר").expect("גּוּר bridges");
assert_eq!(gloss, "whelp; young");
assert!(!is_name);
}
#[test]
fn test_hebrew_word_info_noun_homograph_curated() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible.hebrew_word_info("סוּס").expect("noun should parse");
assert_eq!(info.gloss, "horse");
assert_eq!(info.root, "סוס");
}
#[test]
fn test_hebrew_word_info_function_word() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible
.hebrew_word_info("וְעַתָּה")
.expect("function word should resolve via lexicon");
assert!(info.gloss.to_lowercase().contains("now"));
assert!(info.prefix.is_some());
assert!(info.form.is_none());
assert!(info.tense.is_none());
}
#[test]
fn test_curated_gloss_overrides_homograph() {
assert_eq!(
curated_gloss("כִּי"),
Some((String::new(), "for".to_string()))
);
let (_, asher) = curated_gloss("אֲשֶׁר").expect("relative particle is curated");
assert_eq!(asher, "that");
assert!(curated_gloss("אֲשֶׁ\u{0596}ר").is_some());
assert_eq!(curated_gloss("מֶלֶךְ"), None);
}
#[test]
fn test_cross_reference_gloss() {
assert!(cross_reference_gloss("see עלה"));
assert!(cross_reference_gloss("see sub I. כלל."));
assert!(cross_reference_gloss("אֻלַי see אוּלַי"));
assert!(cross_reference_gloss("עֵלָּא see עלה"));
assert!(cross_reference_gloss("under אול"));
assert!(cross_reference_gloss("חִיאֵל under חיה"));
assert!(!cross_reference_gloss("see"));
assert!(!cross_reference_gloss("seeing"));
assert!(!cross_reference_gloss("the under part; underneath; below"));
assert!(!cross_reference_gloss("עָ֑ל subst. height"));
assert!(!cross_reference_gloss(
"n.pr.loc. pass in Naphtali, see נקב."
));
}
#[test]
fn test_root_stub_gloss() {
assert!(root_stub_gloss(
"(√ of following; meaning dubious; compare Lag BN 55 Anm)."
));
assert!(root_stub_gloss("(meaning unknown)."));
assert!(root_stub_gloss("(= בקק)."));
assert!(root_stub_gloss(
"(quadrilit. √ of following; see reff. below)"
));
assert!(!root_stub_gloss("(he)-ass"));
assert!(!root_stub_gloss(
"(less oft. שַׁלֻּם) n.pr.m. king of N. Israel"
));
assert!(!root_stub_gloss("(† אֱדֹם n.pr.m. Edom"));
assert!(!root_stub_gloss("gold"));
assert!(!root_stub_gloss(
"n.pr.m. (√ & meaning unknown) king of Gomorrah"
));
}
#[test]
fn test_cons_bridge_skips_root_header_stubs() {
require_data!();
let bible = Bible::open("data").unwrap();
let (root, gloss, is_name) = bible.hebrew_cons_root("זהב").expect("זהב bridges");
assert_eq!(root, "זהב");
assert!(gloss.starts_with("gold"), "got {gloss:?}");
assert!(!is_name);
let (root, gloss, _) = bible.hebrew_cons_root("לשכ").expect("לשכ names a root");
assert_eq!(root, "לשכ");
assert_eq!(gloss, "");
}
#[test]
fn test_cons_bridge_prefers_exact_pointed_headword() {
require_data!();
let bible = Bible::open("data").unwrap();
let (_, gloss, is_name) = bible.hebrew_cons_root("מֶלֶךְ").expect("מֶלֶךְ bridges");
assert!(gloss.starts_with("king"), "got {gloss:?}");
assert!(!is_name);
let (root, _, _) = bible.hebrew_cons_root("זהב").expect("bare cons bridges");
assert_eq!(root, "זהב");
}
#[test]
fn test_cons_bridge_prefers_noun_on_exact_headword_tie() {
require_data!();
let bible = Bible::open("data").unwrap();
let (root, gloss, _) = bible.hebrew_cons_root("אוֹר").expect("אוֹר bridges");
assert_eq!(root, "אור");
assert!(gloss.starts_with("light"), "got {gloss:?}");
let (_, gloss, _) = bible.hebrew_cons_root("אָלָה").expect("אָלָה bridges");
assert!(gloss.starts_with("oath"), "got {gloss:?}");
}
#[test]
fn test_lexicon_fallback_skips_cross_reference_stubs() {
require_data!();
let bible = Bible::open("data").unwrap();
let (_, gloss, _) = lexicon_fallback(bible.conn(), "עַל").expect("עַל bridges");
assert!(gloss.starts_with("upon"), "got {gloss:?}");
let (_, gloss, _) = lexicon_fallback(bible.conn(), "גַּם").expect("גַּם bridges");
assert!(gloss.starts_with("also"), "got {gloss:?}");
}
#[test]
fn test_hebrew_word_info_curated_function_word() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible
.hebrew_word_info("כִּי")
.expect("כִּי should resolve via the lexicon bridge");
assert!(info.gloss.contains("because"));
assert!(!info.gloss.to_lowercase().contains("burn"));
}
#[test]
fn test_hebrew_bdb_for_surface_function_word() {
require_data!();
let bible = Bible::open("data").unwrap();
let info = bible.hebrew_word_info("מִי").expect("מִי should bridge");
assert!(info.root.is_empty());
assert!(bible.hebrew_bdb_by_root(&info.root).unwrap().is_empty());
let entries = bible
.hebrew_bdb_for_surface(&info.word, info.prefix.as_deref().unwrap_or(""))
.unwrap();
assert!(
!entries.is_empty(),
"function word should have a lexicon entry"
);
assert!(entries.iter().any(|e| e.gloss.contains("who")));
assert!(
entries.iter().all(|e| !e.gloss.contains("waters")),
"exact headword match must exclude מַי (waters)"
);
assert!(
entries.iter().any(|e| !e.content_json.is_empty()),
"the Lexicon tab needs definition content"
);
}
}