diff options
| author | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-08-11 09:28:57 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2025-10-27 10:26:37 +0100 |
| commit | 76e4c5bd5016347dca083b6932e6adf03f398cad (patch) | |
| tree | 816bd3f24e4f4dbc1f87f93bd91a3c91d88c0f44 | |
| parent | ab60cbfb64b43402e42e7cd823b53b03f651e535 (diff) | |
| download | mihi-76e4c5bd5016347dca083b6932e6adf03f398cad.tar.gz mihi-76e4c5bd5016347dca083b6932e6adf03f398cad.zip | |
Provide initial implementation for word inflection
Word inflection for nouns, adjectives has been added so it can be
initially be used in commands like 'words show'.
Word categories which have no inflections (e.g. adverbs, conjunctions,
et al) are skipped.
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
| -rw-r--r-- | .github/workflows/ci.yml | 7 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | crates/cli/src/inflection.rs | 284 | ||||
| -rw-r--r-- | crates/cli/src/locale.rs | 38 | ||||
| -rw-r--r-- | crates/cli/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/cli/src/run.rs | 37 | ||||
| -rw-r--r-- | crates/cli/src/words.rs | 184 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 358 | ||||
| -rw-r--r-- | lib/mihi/src/migrate.rs | 8 | ||||
| -rw-r--r-- | testdata/test.sqlite3 | bin | 0 -> 2916352 bytes |
10 files changed, 860 insertions, 60 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ec6aa..3e4d8b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,12 @@ jobs: rustup default stable - name: Run tests - run: cargo test --verbose + env: + MIHI_DATABASE: test.sqlite3 + run: | + mkdir -p $HOME/.config/mihi + cp testdata/test.sqlite3 $HOME/.config/mihi/ + cargo test --verbose - name: Run Clippy run: cargo clippy --all-targets --all-features @@ -2,6 +2,8 @@ A self assessment tool for learning languages. **UNDER CONSTRUCTION** +Set the `MIHI_DATABASE` environment variable to `test.sqlite3`. + ## License This repository holds two licenses, as you can also note on the `Cargo.toml` diff --git a/crates/cli/src/inflection.rs b/crates/cli/src/inflection.rs new file mode 100644 index 0000000..1b9d932 --- /dev/null +++ b/crates/cli/src/inflection.rs @@ -0,0 +1,284 @@ +use mihi::{group_declension_inflections, Category, DeclensionInfo, DeclensionTable, Gender, Word}; + +fn get_inflected_from(word: &Word, row: &[DeclensionInfo; 2]) -> String { + if word.is_flag_set("onlysingular") { + row[0].inflected.join("/") + } else if word.is_flag_set("onlyplural") { + row[1].inflected.join("/") + } else { + format!( + "{}, {}", + row[0].inflected.join("/"), + row[1].inflected.join("/") + ) + } +} + +fn get_noun_table(word: &Word) -> Result<DeclensionTable, String> { + let gender = match word.gender { + Gender::MasculineOrFeminine => Gender::Masculine as usize, + _ => word.gender as usize, + }; + group_declension_inflections(word, &word.kind, gender) +} + +fn print_noun_inflection(word: &Word) -> Result<(), String> { + let table = get_noun_table(word)?; + + println!("\n== Inflection ==\n"); + + println!( + "Nominative:\t{}", + get_inflected_from(&word, &table.nominative) + ); + println!("Vocative:\t{}", get_inflected_from(&word, &table.vocative)); + println!( + "Accusative:\t{}", + get_inflected_from(&word, &table.accusative) + ); + println!("Genitive:\t{}", get_inflected_from(&word, &table.genitive)); + println!("Dative:\t\t{}", get_inflected_from(&word, &table.dative)); + println!("Ablative:\t{}", get_inflected_from(&word, &table.ablative)); + if word.locative { + println!("Locative:\t{}", get_inflected_from(&word, &table.locative)); + } + + Ok(()) +} + +fn get_adjective_table(word: &Word) -> Result<[DeclensionTable; 3], String> { + let kind_f = match word.declension_id { + Some(1 | 2) => &"a".to_string(), + _ => &word.kind, + }; + let kind_n = if word.kind == "us" { + &"um".to_owned() + } else { + &word.kind + }; + + Ok([ + group_declension_inflections(word, &word.kind, Gender::Masculine as usize)?, + group_declension_inflections(word, kind_f, Gender::Feminine as usize)?, + group_declension_inflections(word, kind_n, Gender::Neuter as usize)?, + ]) +} + +fn print_adjective_inflection(word: &Word) -> Result<(), String> { + let tables = get_adjective_table(word)?; + + println!("\n== Inflection ==\n"); + + println!( + "Nominative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].nominative), + get_inflected_from(&word, &tables[1].nominative), + get_inflected_from(&word, &tables[2].nominative) + ); + println!( + "Vocative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].vocative), + get_inflected_from(&word, &tables[1].vocative), + get_inflected_from(&word, &tables[2].vocative) + ); + println!( + "Accusative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].accusative), + get_inflected_from(&word, &tables[1].accusative), + get_inflected_from(&word, &tables[2].accusative) + ); + println!( + "Genitive:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].genitive), + get_inflected_from(&word, &tables[1].genitive), + get_inflected_from(&word, &tables[2].genitive) + ); + println!( + "Dative:\t\t{} | {} | {}", + get_inflected_from(&word, &tables[0].dative), + get_inflected_from(&word, &tables[1].dative), + get_inflected_from(&word, &tables[2].dative) + ); + println!( + "Ablative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].ablative), + get_inflected_from(&word, &tables[1].ablative), + get_inflected_from(&word, &tables[2].ablative) + ); + if word.locative { + println!( + "Locative:\t{} | {} | {}", + get_inflected_from(&word, &tables[0].locative), + get_inflected_from(&word, &tables[1].locative), + get_inflected_from(&word, &tables[2].locative) + ); + } + + Ok(()) +} + +pub fn print_full_inflection_for(word: Word) -> Result<(), String> { + if word.is_flag_set("indeclinable") { + return Ok(()); + } + + match word.category { + Category::Noun => print_noun_inflection(&word)?, + Category::Adjective => print_adjective_inflection(&word)?, + Category::Verb => todo!(), + Category::Pronoun => todo!(), + Category::Adverb + | Category::Preposition + | Category::Conjunction + | Category::Interjection + | Category::Determiner + | Category::Unknown => { + // Nothing to do. + } + } + // TODO: on the 'extra' info. + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get_word(enunciated: &str) -> Word { + let words = mihi::select_enunciated(Some(enunciated.to_string())).unwrap(); + + assert_eq!(words.len(), 1); + + mihi::find_by(words.first().unwrap().as_str()).unwrap() + } + + fn stringify_with(word: &Word, table: &DeclensionTable) -> String { + let mut res = get_inflected_from(&word, &table.nominative); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.vocative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.accusative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.genitive).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.dative).as_str()); + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.ablative).as_str()); + if word.locative { + res.push_str(" | "); + res.push_str(get_inflected_from(&word, &table.locative).as_str()); + } + + res + } + + fn assert_noun_table(enunciated: &str, expected: &str) { + let word = get_word(enunciated); + let table = get_noun_table(&word).unwrap(); + + let res = stringify_with(&word, &table); + + assert_eq!(res, expected); + } + + fn assert_adjective_table(enunciated: &str, masculine: &str, feminine: &str, neuter: &str) { + let word = get_word(enunciated); + let tables = get_adjective_table(&word).unwrap(); + + let res = stringify_with(&word, &tables[0]); + assert_eq!(res, masculine); + + let res = stringify_with(&word, &tables[1]); + assert_eq!(res, feminine); + + let res = stringify_with(&word, &tables[2]); + assert_eq!(res, neuter); + } + + #[test] + fn test_nouns() { + assert_noun_table( + "rosa, rosae", + "rosa, rosae | rosa, rosae | rosam, rosās | rosae, rosārum | rosae, rosīs | rosā, rosīs", + ); + assert_noun_table( + "fīlia, fīliae", + "fīlia, fīliae | fīlia, fīliae | fīliam, fīliās | fīliae, fīliārum | fīliae, fīliīs/fīliābus | fīliā, fīliīs/fīliābus", + ); + assert_noun_table( + "dea, deae", + "dea, deae | dea, deae | deam, deās | deae, deārum | deae, deābus | deā, deābus", + ); + assert_noun_table( + "Rōma, Rōmae", + "Rōma | Rōma | Rōmam | Rōmae | Rōmae | Rōmā | Rōmae", + ); + assert_noun_table( + "lupus, lupī", + "lupus, lupī | lupe, lupī | lupum, lupōs | lupī, lupōrum | lupō, lupīs | lupō, lupīs", + ); + assert_noun_table( + "templum, templī", + "templum, templa | templum, templa | templum, templa | templī, templōrum | templō, templīs | templō, templīs", + ); + assert_noun_table( + "vir, virī", + "vir, virī | vir, virī | virum, virōs | virī, virōrum | virō, virīs | virō, virīs", + ); + assert_noun_table( + "liber, librī", + "liber, librī | liber, librī | librum, librōs | librī, librōrum | librō, librīs | librō, librīs", + ); + assert_noun_table( + "fīlius, fīliī", + "fīlius, fīliī | fīlī, fīliī | fīlium, fīliōs | fīlī/fīliī, fīliōrum | fīliō, fīliīs | fīliō, fīliīs", + ); + assert_noun_table( + "leō, leōnis", + "leō, leōnēs | leō, leōnēs | leōnem, leōnēs | leōnis, leōnum | leōnī, leōnibus | leōne, leōnibus", + ); + assert_noun_table( + "ovis, ovis", + "ovis, ovēs | ovis, ovēs | ovem, ovēs | ovis, ovium | ovī, ovibus | ove, ovibus", + ); + assert_noun_table( + "mare, maris", + "mare, maria | mare, maria | mare, maria | maris, marium/marum | marī, maribus | marī/mare, maribus", + ); + assert_noun_table( + "Iuppiter, Iovis", + "Iuppiter | Iuppiter | Iovem | Iovis | Iovī | Iove", + ); + assert_noun_table( + "portus, portūs", + "portus, portūs | portus, portūs | portum, portūs | portūs, portuum | portuī, portibus | portū, portibus", + ); + assert_noun_table( + "cornū, cornūs", + "cornū, cornua | cornū, cornua | cornū, cornua | cornūs, cornuum | cornuī, cornibus | cornū, cornibus", + ); + // TODO: domus + assert_noun_table( + "diēs, diēī", + "diēs, diēs | diēs, diēs | diem, diēs | diēī, diērum | diēī, diēbus | diē, diēbus", + ); + assert_noun_table( + "rēs, reī", + "rēs, rēs | rēs, rēs | rem, rēs | reī, rērum | reī, rēbus | rē, rēbus", + ); + } + + #[test] + fn test_adjectives() { + assert_adjective_table( + "novus, nova, novum", + "novus, novī | nove, novī | novum, novōs | novī, novōrum | novō, novīs | novō, novīs", + "nova, novae | nova, novae | novam, novās | novae, novārum | novae, novīs | novā, novīs", + "novum, nova | novum, nova | novum, nova | novī, novōrum | novō, novīs | novō, novīs", + ); + // TODO: pulcher + // TODO: unus nauta + // TODO: third + } +} diff --git a/crates/cli/src/locale.rs b/crates/cli/src/locale.rs new file mode 100644 index 0000000..f9d42b6 --- /dev/null +++ b/crates/cli/src/locale.rs @@ -0,0 +1,38 @@ +// Locale represents the locales accepted for delivering answers on this +// tool. That is, it's not about i18n on the strings for this application. but +// rather the different translations accepted in places like +// `Word.translations`. +pub enum Locale { + English, + Catalan, +} + +impl Locale { + // Returns the string representation for the locale's code. + pub fn to_code(&self) -> &str { + match self { + Self::English => "en", + Self::Catalan => "ca", + } + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::English => write!(f, "english"), + Self::Catalan => write!(f, "català"), + } + } +} + +/// Fetches the Locale object that is suitable for the current environment. +pub fn current_locale() -> Locale { + let raw_locale = std::env::var("LC_ALL").unwrap_or("en".to_string()); + + if raw_locale.starts_with("ca") { + Locale::Catalan + } else { + Locale::English + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ffbcdae..62ffd45 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,7 @@ mod exercises; +mod inflection; mod init; +mod locale; mod nuke; mod run; mod words; diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 0046d47..3e1b866 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -6,6 +6,8 @@ use std::io::Write; use std::process::Command; use tempfile::NamedTempFile; +use crate::locale::{current_locale, Locale}; + // Maximum number of times a word has to be run in order to increase the number // of successful runs. const MAX_STEPS: usize = 5; @@ -26,34 +28,6 @@ fn help(msg: Option<&str>) { println!(" -k, --kind <KIND>\t\tOnly ask for exercises for the given <KIND>."); } -// Locale represents the locales accepted for delivering answers on this -// tool. That is, it's not about i18n on the strings for this application. but -// rather the different translations accepted in places like -// `Word.translations`. -enum Locale { - English, - Catalan, -} - -impl Locale { - // Returns the string representation for the locale's code. - fn to_code(&self) -> &str { - match self { - Self::English => "en", - Self::Catalan => "ca", - } - } -} - -impl std::fmt::Display for Locale { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::English => write!(f, "english"), - Self::Catalan => write!(f, "català"), - } - } -} - // Run the quiz for all the given `words` while expecting answers to be // delivered in the given `locale`. fn run_words(words: Vec<Word>, locale: &Locale) -> i32 { @@ -323,12 +297,7 @@ pub fn run(args: Vec<String>) { } } - let raw_locale = std::env::var("LC_ALL").unwrap_or("en".to_string()); - let locale = if raw_locale.starts_with("ca") { - Locale::Catalan - } else { - Locale::English - }; + let locale = current_locale(); let words = match category { Some(cat) => select_relevant_words(cat, &flags, 15), diff --git a/crates/cli/src/words.rs b/crates/cli/src/words.rs index 7db84a2..d387f00 100644 --- a/crates/cli/src/words.rs +++ b/crates/cli/src/words.rs @@ -1,3 +1,6 @@ +use crate::inflection::print_full_inflection_for; +use crate::locale::current_locale; + use inquire::{Confirm, Editor, Select, Text}; use mihi::{Category, Gender, Language, Word}; use std::vec::IntoIter; @@ -148,15 +151,22 @@ fn get_initial_guess(value: &str) -> Word { return Word::from( second[0..second.len() - 2].to_string(), Category::Noun, - Some(5), + Some(3), None, Gender::Masculine, - "es".to_string(), + "is".to_string(), ); } } - Word::default() + Word::from( + value.to_string(), + Category::Unknown, + None, + None, + Gender::None, + String::from("-"), + ) } // Remove comments from the "flags" text that was provided. @@ -236,18 +246,32 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> _ => Gender::None, }; - let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else { - return Err("abort!".to_string()); + let inflection_id = match category { + Category::Noun | Category::Adjective => { + let Ok(inflection) = Text::new("Inflection:") + .with_initial_value(word.inflection_id().unwrap_or(0).to_string().as_str()) + .prompt() + else { + return Err("abort!".to_string()); + }; + let Ok(inflection_id) = inflection.parse::<usize>() else { + return Err(format!("bad value for inflection ID '{inflection}'")); + }; + Some(inflection_id) + } + _ => None, }; - let Ok(inflection) = Text::new("Inflection:") - .with_initial_value(word.inflection_id().to_string().as_str()) - .prompt() - else { - return Err("abort!".to_string()); - }; - let Ok(inflection_id) = inflection.parse::<usize>() else { - return Err(format!("bad value for inflection ID '{inflection}'")); + // TODO: refine guess once the inflection is known: select from possible values. + let kind = match category { + Category::Noun | Category::Adjective => { + let Ok(kind) = Text::new("Kind:").with_initial_value(&word.kind).prompt() else { + return Err("abort!".to_string()); + }; + kind.trim().to_string() + } + Category::Verb => String::from("verb"), + _ => String::from("-"), }; let Ok(regular) = Confirm::new("Regular:").with_default(word.regular).prompt() else { @@ -267,7 +291,10 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> return Err("abort!".to_string()); }; let Ok(weight) = raw_weight.parse::<usize>() else { - return Err(format!("bad value for inflection ID '{inflection}'")); + return Err(format!( + "bad value for inflection ID '{}'", + inflection_id.unwrap_or(0) + )); }; if weight > 10 { return Err(format!( @@ -306,10 +333,10 @@ fn ask_for_word_based_on(enunciated: String, word: Word) -> Result<Word, String> declension_id: if matches!(category, Category::Verb) { None } else { - Some(inflection_id) + inflection_id }, conjugation_id: if matches!(category, Category::Verb) { - Some(inflection_id) + inflection_id } else { None }, @@ -409,7 +436,6 @@ fn create(args: IntoIter<String>) -> i32 { } } -// TODO: accept a --raw flag, which is implied on pipe fn ls(mut args: IntoIter<String>) -> i32 { if args.len() > 1 { help(Some("error: words: too many filters")); @@ -509,8 +535,128 @@ fn edit(mut args: IntoIter<String>) -> i32 { } } -fn show(mut _args: IntoIter<String>) -> i32 { - todo!() +// Returns a string with a more human-readable declension kind. +fn humanize_kind(kind: &str) -> &str { + match kind { + // Noun + "a" => "-a", + "us" => "-us", + "er/ir" => "-er/-ir", + "um" => "-um", + "ius" => "-ius; like 'fīlius'", + "is" => "-is", + "istem" => "i-stem; '-i-' also in the genitive plural", + "pureistem" => "pure i-stem; '-i-' also in the ablative singular", + "visvis" => "irregular 'vīs, vīs'", + "sussuis" => "irregular 'sūs, suis'", + "bosbovis" => "irregular 'bōs, bovis'", + "iuppiteriovis" => "irregular 'Iuppiter, Iovis'", + "fus" => "-u-", + "domusdomus" => "irregular 'domus, domūs/domī'", + "ies" => "-iēs; like 'diēs, diēī'", + "es" => "-ēs; like 'rēs, reī'", + "indeclinable" => "indeclinable", + + // Adjective + "one" => "one termination adjective", + "onenonistem" => "one termination adjective; non i-stem like 'melior, melius'", + "two" => "two termination adjective", + "three" => "three termination adjective", + "unusnauta" => "'ūnus nauta' like 'ūnus, ūna, ūnum'", + "unusnautaer/ir" => "'ūnus nauta' like 'neuter, neutra, neutrum'", + "duo" => "number 'duo, duae, duo'", + "tres" => "number 'trēs, trēs, tria'", + "mille" => "number 'mīlle, mīlle'", + + // Others + "egonos" => "'ego, nōs'", + "demonstrative-weak" => "weak demonstrative", + "demonstrative-proximal" => "proximal demonstrative", + "demonstrative-distal" => "distal demonstrative", + "demonstrative-medial" => "medial demonstrative", + "demonstrative-idem" => "'īdem, eadem, idem' demonstrative", + "tuvos" => "'tū, vōs'", + "sesui" => "'sē, suī'", + + _ => kind, + } +} + +fn show_info(word: Word) -> Result<(), String> { + // Title. + match word.gender { + Gender::None => println!("Word: {} ({})", word.enunciated, word.category), + _ => println!( + "Word: {} ({} {})", + word.enunciated, + word.gender.abbrev(), + word.category + ), + } + + // Conjugation, declension + kind. + match word.conjugation_id { + Some(id) => println!("Conjugation: {}", id), + None => match word.declension_id { + Some(did) => { + if did > 5 { + println!("Declension: {}", humanize_kind(&word.kind)); + } else { + println!( + "Declension: {} ({})", + word.declension_id.unwrap(), + humanize_kind(&word.kind) + ); + } + } + None => {} + }, + }; + + // Show translation if available. + let locale = current_locale(); + if let Some(translation) = word.translation.get(locale.to_code()) { + let s = translation.as_str().unwrap_or(""); + if !s.is_empty() { + println!("Translation ({}): {}.", locale.to_code(), s); + } + } + + print_full_inflection_for(word)?; + + Ok(()) +} + +fn show(mut args: IntoIter<String>) -> i32 { + if args.len() > 1 { + help(Some( + "error: words: only one argument. If it's an enunciate, wrap it in double quotes", + )); + return 1; + } + + let enunciated = match select_single_word(args.next()) { + Ok(word) => word, + Err(e) => { + println!("error: words: {e}."); + return 1; + } + }; + + let word = match mihi::find_by(enunciated.as_str()) { + Ok(word) => word, + Err(e) => { + println!("error: words: {e}."); + return 1; + } + }; + + if let Err(e) = show_info(word) { + println!("error: words: {e}."); + return 1; + } + + 0 } fn rm(mut args: IntoIter<String>) -> i32 { diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 439e92e..12953c3 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -70,6 +70,19 @@ pub enum Gender { None, } +impl Gender { + /// Returns a string containing the abbreviation for this gender. + pub fn abbrev(&self) -> &str { + match self { + Self::Masculine => "m.", + Self::Feminine => "f.", + Self::MasculineOrFeminine => "m./f.", + Self::Neuter => "n.", + Self::None => "(genderless)", + } + } +} + impl TryFrom<usize> for Gender { type Error = &'static str; @@ -174,7 +187,8 @@ pub fn add_language(language: String) -> Result<(), String> { /// Ensure that in the config path there is a fully initialized database. pub fn init_database() -> Result<(), String> { - let path = get_config_path()?.join("database.sqlite3"); + let name = &std::env::var("MIHI_DATABASE").unwrap_or("database.sqlite3".to_string()); + let path = get_config_path()?.join(name); let conn = match Connection::open(path) { Ok(handle) => handle, Err(e) => return Err(format!("could not initialize the database: {e}")), @@ -186,7 +200,7 @@ pub fn init_database() -> Result<(), String> { } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct Word { pub id: i32, pub enunciated: String, @@ -237,11 +251,25 @@ impl Word { } } - pub fn inflection_id(&self) -> usize { + pub fn inflection_id(&self) -> Option<usize> { if matches!(self.category, Category::Verb) { - return self.conjugation_id.unwrap(); + return Some(self.conjugation_id.unwrap()); + } + self.declension_id + } + + /// Returns whether the given flag is set to true on this word. + pub fn is_flag_set(&self, flag: &str) -> bool { + match self.flags.get(flag) { + Some(value) => { + if let Some(b) = value.as_bool() { + b + } else { + false + } + } + None => false, } - self.declension_id.unwrap() } } @@ -347,6 +375,15 @@ pub fn create_word(word: Word) -> Result<(), String> { )) } }, + Category::Adverb + | Category::Preposition + | Category::Conjunction + | Category::Interjection + | Category::Determiner => { + if word.declension_id.is_some() || word.conjugation_id.is_some() { + return Err(format!("no inflection allowed for '{}'", word.category)); + } + } // TODO _ => { return Err(format!( @@ -584,7 +621,9 @@ pub fn delete_word(enunciated: &String) -> Result<(), String> { } fn get_connection() -> Result<rusqlite::Connection, String> { - let path = get_config_path()?.join("database.sqlite3"); + let name = &std::env::var("MIHI_DATABASE").unwrap_or("database.sqlite3".to_string()); + let path = get_config_path()?.join(name); + match Connection::open(path) { Ok(handle) => Ok(handle), Err(_) => Err( @@ -812,3 +851,310 @@ pub fn select_relevant_exercises( } Ok(res) } + +#[derive(Debug, Default)] +pub struct DeclensionInfo { + pub inflected: Vec<String>, +} + +#[derive(Debug, Default)] +pub struct DeclensionTable { + pub nominative: [DeclensionInfo; 2], + pub vocative: [DeclensionInfo; 2], + pub accusative: [DeclensionInfo; 2], + pub genitive: [DeclensionInfo; 2], + pub dative: [DeclensionInfo; 2], + pub ablative: [DeclensionInfo; 2], + pub locative: [DeclensionInfo; 2], +} + +impl DeclensionTable { + pub fn consume_blob( + &mut self, + case: usize, + blob: &Value, + word: &Word, + gender: usize, + add: bool, + ) { + if let Some(singular) = blob.get("singular") { + let values = singular.as_array().unwrap(); + for v in values { + let s = v.as_str().unwrap(); + if add { + self.add(word, case, 0, gender, s); + } else { + self.set(word, case, 0, gender, s); + } + } + } + + if let Some(plural) = blob.get("plural") { + let values = plural.as_array().unwrap(); + for v in values { + let s = v.as_str().unwrap(); + if add { + self.add(word, case, 1, gender, s); + } else { + self.set(word, case, 1, gender, s); + } + } + } + } + + pub fn set(&mut self, word: &Word, case: usize, number: usize, gender: usize, term: &str) { + match case { + 0 => { + self.nominative[number].inflected = inflect_from(word, case, number, gender, term); + } + 1 => { + self.vocative[number].inflected = inflect_from(word, case, number, gender, term); + } + 2 => { + self.accusative[number].inflected = inflect_from(word, case, number, gender, term); + } + 3 => { + self.genitive[number].inflected = inflect_from(word, case, number, gender, term); + } + 4 => { + self.dative[number].inflected = inflect_from(word, case, number, gender, term); + } + 5 => { + self.ablative[number].inflected = inflect_from(word, case, number, gender, term); + } + 6 => { + self.locative[number].inflected = inflect_from(word, case, number, gender, term); + } + _ => {} + } + } + + pub fn add(&mut self, word: &Word, case: usize, number: usize, gender: usize, term: &str) { + match case { + 0 => { + self.nominative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 1 => { + self.vocative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 2 => { + self.accusative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 3 => { + self.genitive[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 4 => { + self.dative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 5 => { + self.ablative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + 6 => { + self.locative[number] + .inflected + .append(&mut inflect_from(word, case, number, gender, term)); + } + _ => {} + } + } +} + +fn contract_root(word: &Word, case: usize, number: usize, gender: usize) -> bool { + // First off, check out that this is a word explicitely marked as to be + // contracted by either the flag or the kind. + if !word.is_flag_set("contracted_root") { + return false; + } + if word.kind != "er/ir" && word.kind != "unusnautaer/ir" { + return false; + } + + // All plurals have to be contracted. + if number == 1 { + return true; + } + + // Nominative/vocative singular are never contracted. The accusative is + // only not contracted on neuter words. + match case { + 0 | 1 => false, + 2 => gender != 3, + _ => true, + } +} + +fn should_use_first_root(word: &Word, case: usize, number: usize, gender: usize) -> bool { + // All plurals always follow `word.particle`. + if number == 1 { + return false; + } + + match case { + 0 | 1 => { + word.kind == "is" + || word.kind == "istem" + || word.kind == "pureistem" + || word.kind == "one" + || word.kind == "onenonistem" + } + 2 => { + // Only neuter words should consider this on the accusative. + if gender != 3 { + return false; + } + word.kind == "is" + || word.kind == "istem" + || word.kind == "pureistem" + || word.kind == "one" + || word.kind == "onenonistem" + } + _ => false, + } +} + +fn inflect_from(word: &Word, case: usize, number: usize, gender: usize, term: &str) -> Vec<String> { + let mut inflections = vec![]; + + if !word.regular { + inflections.push(term.to_owned()); + } else if contract_root(word, case, number, gender) { + inflections.push( + word.particle[0..word.particle.len() - 2].to_string() + + &"r".to_owned() + + &term.to_owned(), + ); + } else if should_use_first_root(word, case, number, gender) { + let parts: Vec<&str> = word.enunciated.split(',').collect(); + inflections.push(parts.first().unwrap().to_string() + &term.to_owned()); + } else if word.kind == "ius" && number == 0 { + // Words of this kind are a bit troublesome on the singular, let's + // handle them now. + if case == 1 && word.is_flag_set("contracted_vocative") { + inflections + .push(word.particle[0..word.particle.len() - 1].to_string() + &term.to_owned()); + } else { + if case == 3 { + inflections + .push(word.particle[0..word.particle.len() - 1].to_string() + &term.to_owned()); + } + inflections.push(word.particle.to_string() + &term.to_owned()); + } + } else { + inflections.push(word.particle.clone() + &term.to_owned()); + } + + inflections +} + +fn case_str_to_i(key: &str) -> Result<usize, String> { + match key { + "nominative" => Ok(0), + "vocative" => Ok(1), + "accusative" => Ok(2), + "genitive" => Ok(3), + "dative" => Ok(4), + "ablative" => Ok(5), + "locative" => Ok(6), + _ => Err(format!("bad key '{}' for a case", key)), + } +} + +pub fn group_declension_inflections( + word: &Word, + kind: &String, + gender: usize, +) -> Result<DeclensionTable, String> { + let conn = get_connection()?; + let mut stmt = conn + .prepare( + "SELECT id, number, gender, \"case\", value, declension_id, \ + kind, tense, mood, voice, person, conjugation_id \ + FROM forms \ + WHERE kind = ?1 AND gender = ?2", + ) + .unwrap(); + let mut it = stmt.query([kind, &gender.to_string()]).unwrap(); + + let mut table = DeclensionTable::default(); + + while let Some(row) = it.next().unwrap() { + // Fetch the number and account for defectives on number. + let number: usize = row.get(1).unwrap(); + if number == 0 && word.is_flag_set("onlyplural") { + continue; + } else if number == 1 && word.is_flag_set("onlysingular") { + continue; + } + + let case = row.get(3).unwrap(); + let term: String = row.get(4).unwrap(); + + table.add(word, case, number, gender, &term); + } + + if let Some(sets) = word.flags.get("sets") { + let object = sets.as_object().unwrap(); + + for (case_gender, blob) in object.iter() { + let case_gender_str = case_gender.as_str(); + match case_gender_str { + "masculine" | "feminine" | "neuter" => { + if (gender == 0 && case_gender_str == "masculine") + || (gender == 1 && case_gender_str == "feminine") + || (gender == 2 && case_gender_str == "neuter") + { + let inner = blob.as_object().unwrap(); + for (case, blob) in inner.iter() { + let case_i = case_str_to_i(case)?; + table.consume_blob(case_i, blob, word, gender, false); + } + } + } + _ => { + let case_i = case_str_to_i(case_gender)?; + table.consume_blob(case_i, blob, word, gender, false); + } + } + } + } + + if let Some(adds) = word.flags.get("adds") { + let object = adds.as_object().unwrap(); + + for (case_gender, blob) in object.iter() { + let case_gender_str = case_gender.as_str(); + match case_gender_str { + "masculine" | "feminine" | "neuter" => { + if (gender == 0 && case_gender_str == "masculine") + || (gender == 1 && case_gender_str == "feminine") + || (gender == 2 && case_gender_str == "neuter") + { + let inner = blob.as_object().unwrap(); + for (case, blob) in inner.iter() { + let case_i = case_str_to_i(case)?; + table.consume_blob(case_i, blob, word, gender, true); + } + } + } + _ => { + let case_i = case_str_to_i(case_gender)?; + table.consume_blob(case_i, blob, word, gender, true); + } + } + } + } + + Ok(table) +} diff --git a/lib/mihi/src/migrate.rs b/lib/mihi/src/migrate.rs index 97f32c9..f8fcc8d 100644 --- a/lib/mihi/src/migrate.rs +++ b/lib/mihi/src/migrate.rs @@ -1,5 +1,13 @@ use rusqlite::{Connection, Result}; +// TODO: not just the definition, but also the bootstrapping (into Git as there is nothing sensitive?). +// - conjugations +// - declensions +// - forms +// - languages +// - language_cases +// - word_relations + /// Use the given `connection` in order to initialize the database. pub fn init(connection: Connection) -> Result<usize> { connection.execute( diff --git a/testdata/test.sqlite3 b/testdata/test.sqlite3 Binary files differnew file mode 100644 index 0000000..371ca7f --- /dev/null +++ b/testdata/test.sqlite3 |
