use std::collections::HashMap;
use serde::Serialize;
use crate::conlang::types::Phonology;
use crate::conlang::types::morphology::{AffixPosition, MorphProcess, Morphology};
use crate::conlang::types::phoneme::PhonemeKind;
use crate::language_entry::DictionaryEntry;
type AblautForm = (String, String, String);
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Parse {
pub root: String,
pub gloss: String,
pub affixes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct ParseReport {
pub surface: String,
pub segments: Vec<String>,
pub parses: Vec<Parse>,
}
const MAX_AFFIXES: usize = 6;
pub fn parse(phon: &Phonology, morph: &Morphology, entries: &[DictionaryEntry], surface: &str) -> ParseReport {
let segs = phon.segment(&surface.to_lowercase());
let mut report = ParseReport { surface: surface.to_string(), segments: segs.clone(), ..Default::default() };
if segs.is_empty() {
return report;
}
let mut roots: HashMap<Vec<String>, String> = HashMap::new();
for e in entries {
let rs = phon.segment(&e.word.to_lowercase());
if !rs.is_empty() {
roots.entry(rs).or_insert_with(|| e.translation.clone());
}
}
let affixes: Vec<(Vec<String>, String, bool)> = morph
.morphemes
.iter()
.filter_map(|m| match m.position {
Some(AffixPosition::Suffix) => Some((phon.segment(&m.form), affix_label(&m.gloss, &m.id), false)),
Some(AffixPosition::Prefix) => Some((phon.segment(&m.form), affix_label(&m.gloss, &m.id), true)),
_ => None,
})
.filter(|(f, _, _)| !f.is_empty())
.collect();
let mut ablaut_forms: HashMap<Vec<String>, Vec<AblautForm>> = HashMap::new();
for m in &morph.morphemes {
if !matches!(m.process, Some(MorphProcess::Ablaut)) || m.rules.is_empty() {
continue;
}
let label = affix_label(&m.gloss, &m.id);
for e in entries {
let base = phon.segment(&e.word.to_lowercase());
if base.is_empty() {
continue;
}
let image = crate::conlang::phonology::rewrite::apply_ordered(&base, &m.rules, &phon.classes);
if image != base && !image.is_empty() {
ablaut_forms.entry(image).or_default().push((
render(phon, &base),
e.translation.clone(),
label.clone(),
));
}
}
}
let mut applied: Vec<String> = Vec::new();
strip(&segs, phon, &roots, &affixes, &ablaut_forms, &mut applied, &mut report.parses);
report.parses.sort_by(|a, b| a.affixes.len().cmp(&b.affixes.len()).then_with(|| a.root.cmp(&b.root)));
report.parses.dedup();
report
}
#[allow(clippy::too_many_arguments)]
fn strip(
segs: &[String],
phon: &Phonology,
roots: &HashMap<Vec<String>, String>,
affixes: &[(Vec<String>, String, bool)],
ablaut_forms: &HashMap<Vec<String>, Vec<AblautForm>>,
applied: &mut Vec<String>,
out: &mut Vec<Parse>,
) {
if let Some(gloss) = roots.get(segs) {
out.push(Parse { root: render(phon, segs), gloss: gloss.clone(), affixes: applied.clone() });
}
if let Some(forms) = ablaut_forms.get(segs) {
for (root, gloss, label) in forms {
let mut affixes = applied.clone();
affixes.push(label.clone());
out.push(Parse { root: root.clone(), gloss: gloss.clone(), affixes });
}
}
if applied.len() >= MAX_AFFIXES {
return;
}
if segs.len() >= 2 && segs.len() % 2 == 0 {
let half = segs.len() / 2;
if segs[..half] == segs[half..] {
applied.push("REDUP".to_string());
strip(&segs[..half], phon, roots, affixes, ablaut_forms, applied, out);
applied.pop();
}
}
if let Some(cv) = first_syllable_len(phon, segs) {
let cvc = if segs.get(cv).map(|s| phon.kind_of(s) == Some(PhonemeKind::Consonant)).unwrap_or(false) {
Some(cv + 1)
} else {
None
};
for k in [Some(cv), cvc].into_iter().flatten() {
if k >= 1 && segs.len() > 2 * k && segs[..k] == segs[k..2 * k] {
applied.push("REDUP~".to_string());
strip(&segs[k..], phon, roots, affixes, ablaut_forms, applied, out);
applied.pop();
}
}
}
for (form, label, is_prefix) in affixes {
let stripped: Option<&[String]> = if *is_prefix {
segs.strip_prefix(form.as_slice())
} else {
segs.strip_suffix(form.as_slice())
};
if let Some(rem) = stripped {
if rem.len() < segs.len() && !rem.is_empty() {
applied.push(label.clone());
strip(rem, phon, roots, affixes, ablaut_forms, applied, out);
applied.pop();
}
}
}
}
fn first_syllable_len(phon: &Phonology, segs: &[String]) -> Option<usize> {
segs.iter().position(|s| phon.kind_of(s) == Some(PhonemeKind::Vowel)).map(|v| v + 1)
}
fn affix_label(gloss: &str, id: &str) -> String {
if gloss.trim().is_empty() { id.to_string() } else { gloss.to_string() }
}
fn render(phon: &Phonology, segs: &[String]) -> String {
segs.iter().map(|ipa| phon.phoneme(ipa).map(|p| p.grapheme()).unwrap_or(ipa.as_str())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn phon() -> Phonology {
let body = r#"{
phonemes: [
{ ipa: "k", kind: "consonant" }, { ipa: "t", kind: "consonant" },
{ ipa: "n", kind: "consonant" }, { ipa: "s", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "i", kind: "vowel" }
]
}"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn morph() -> Morphology {
let body = r#"{
morphemes: [
{ id: "pl", gloss: "PL", form: "i", position: "suffix" }
{ id: "dat", gloss: "DAT", form: "s", position: "suffix" }
{ id: "def", gloss: "DEF", form: "na", position: "prefix" }
]
}"#;
Morphology::from_hjson(body).unwrap().unwrap()
}
fn entry(w: &str, g: &str) -> DictionaryEntry {
DictionaryEntry { word: w.into(), pos: "noun".into(), translation: g.into(), ..Default::default() }
}
#[test]
fn parses_a_suffixed_form_to_its_root() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "katai");
assert!(r.parses.iter().any(|p| p.root == "kata" && p.affixes == vec!["PL".to_string()]));
}
#[test]
fn parses_a_stacked_affix_form() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "nakatas");
assert!(r.parses.iter().any(|p| {
p.root == "kata" && p.affixes.contains(&"DEF".to_string()) && p.affixes.contains(&"DAT".to_string())
}));
}
#[test]
fn a_bare_root_parses_with_no_affixes() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "kata");
assert!(r.parses.iter().any(|p| p.root == "kata" && p.affixes.is_empty()));
}
#[test]
fn an_unanalyzable_form_yields_no_parses() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "sini");
assert!(r.parses.is_empty());
}
#[test]
fn full_reduplication_is_analysed() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "katakata");
assert!(r.parses.iter().any(|p| p.root == "kata" && p.affixes == vec!["REDUP".to_string()]));
}
#[test]
fn reduplication_combines_with_affixes() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "katakatai");
assert!(r.parses.iter().any(|p| {
p.root == "kata"
&& p.affixes.contains(&"REDUP".to_string())
&& p.affixes.contains(&"PL".to_string())
}));
}
#[test]
fn partial_reduplication_is_analysed() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "kakata");
assert!(
r.parses.iter().any(|p| p.root == "kata" && p.affixes == vec!["REDUP~".to_string()]),
"parses: {:?}",
r.parses
);
}
#[test]
fn partial_reduplication_combines_with_affixes() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "kakatai");
assert!(r.parses.iter().any(|p| {
p.root == "kata"
&& p.affixes.contains(&"REDUP~".to_string())
&& p.affixes.contains(&"PL".to_string())
}));
}
#[test]
fn a_plain_suffixed_form_is_not_read_as_partial_reduplication() {
let lex = [entry("kata", "stone")];
let r = parse(&phon(), &morph(), &lex, "katai");
assert!(!r.parses.iter().any(|p| p.affixes.contains(&"REDUP~".to_string())), "parses: {:?}", r.parses);
}
fn ablaut_morph() -> Morphology {
let body = r#"{
morphemes: [
{ id: "pl", gloss: "PL", form: "i", position: "suffix" }
{ id: "pst", gloss: "PST", process: "ablaut", rules: [ { rule: "a > i" } ] }
]
}"#;
Morphology::from_hjson(body).unwrap().unwrap()
}
#[test]
fn an_ablaut_form_analyses_to_its_root() {
let lex = [entry("kat", "cut")];
let r = parse(&phon(), &ablaut_morph(), &lex, "kit");
assert!(
r.parses.iter().any(|p| p.root == "kat" && p.affixes == vec!["PST".to_string()]),
"parses: {:?}",
r.parses
);
}
#[test]
fn ablaut_combines_with_a_suffix() {
let lex = [entry("kat", "cut")];
let r = parse(&phon(), &ablaut_morph(), &lex, "kiti");
assert!(r.parses.iter().any(|p| {
p.root == "kat" && p.affixes.contains(&"PST".to_string()) && p.affixes.contains(&"PL".to_string())
}));
}
#[test]
fn a_bare_root_is_not_read_as_ablaut() {
let lex = [entry("kat", "cut")];
let r = parse(&phon(), &ablaut_morph(), &lex, "kat");
assert!(r.parses.iter().any(|p| p.root == "kat" && p.affixes.is_empty()));
assert!(!r.parses.iter().any(|p| p.affixes.contains(&"PST".to_string())), "parses: {:?}", r.parses);
}
}