use serde::{Deserialize, Serialize};
use crate::conlang::morphology::gloss;
use crate::conlang::morphology::paradigm::MorphSeg;
use crate::conlang::types::Phonology;
use crate::conlang::types::morphology::Morphology;
use crate::language_entry::DictionaryEntry;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IgtWord {
pub surface: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gloss: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub segments: Vec<MorphSeg>,
}
impl IgtWord {
fn segmented(&self) -> String {
if self.segments.is_empty() {
self.surface.clone()
} else {
self.segments.iter().map(|s| s.surface.as_str()).collect::<Vec<_>>().join("-")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Igt {
pub text: String,
pub words: Vec<IgtWord>,
pub translation: String,
pub recognised: usize,
}
fn lexical_meaning(gloss: &str) -> &str {
gloss.split_once('-').map(|(base, _)| base).unwrap_or(gloss)
}
pub fn build(phon: &Phonology, morph: &Morphology, entries: &[DictionaryEntry], text: &str) -> Igt {
let index = gloss::build_index(phon, morph, entries);
let mut words = Vec::new();
let mut literal = Vec::new();
let mut recognised = 0;
for item in index.gloss_text(text) {
match &item.gloss {
Some(g) => {
recognised += 1;
literal.push(lexical_meaning(g).to_string());
}
None => literal.push(item.surface.clone()),
}
words.push(IgtWord {
surface: item.surface,
root: item.root,
gloss: item.gloss,
segments: item.segments,
});
}
Igt { text: text.to_string(), words, translation: literal.join(" "), recognised }
}
impl Igt {
pub fn columns(&self) -> Vec<(String, String)> {
self.words
.iter()
.map(|w| (w.segmented(), w.gloss.clone().unwrap_or_else(|| w.surface.clone())))
.collect()
}
pub fn render(&self) -> String {
let cells = self.columns();
let mut surface_line = String::new();
let mut gloss_line = String::new();
for (surface, gloss) in &cells {
let width = surface.chars().count().max(gloss.chars().count()) + 2;
surface_line.push_str(&pad(surface, width));
gloss_line.push_str(&pad(gloss, width));
}
format!(
"{}\n{}\n'{}'",
surface_line.trim_end(),
gloss_line.trim_end(),
self.translation
)
}
}
fn pad(s: &str, width: usize) -> String {
let n = s.chars().count();
let mut out = s.to_string();
out.extend(std::iter::repeat_n(' ', width.saturating_sub(n)));
out
}
#[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: "l", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "i", kind: "vowel" }, { ipa: "o", kind: "vowel" }
]
}"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn morph() -> Morphology {
let body = r#"{
morphemes: [ { id: "pl", gloss: "PL", form: "i", position: "suffix" } ]
paradigms: [ { name: "noun", cells: [
{ features: {}, morphemes: [] }
{ features: {}, morphemes: ["pl"] }
] } ]
}"#;
Morphology::from_hjson(body).unwrap().unwrap()
}
fn entry(word: &str, gloss: &str, paradigm: Option<&str>) -> DictionaryEntry {
DictionaryEntry {
word: word.into(),
translation: gloss.into(),
paradigm: paradigm.map(String::from),
..Default::default()
}
}
fn lex() -> Vec<DictionaryEntry> {
vec![entry("kata", "stone", Some("noun")), entry("nilo", "friend", None)]
}
#[test]
fn glosses_a_recognised_sentence() {
let igt = build(&phon(), &morph(), &lex(), "katai nilo");
assert_eq!(igt.words.len(), 2);
assert_eq!(igt.words[0].gloss.as_deref(), Some("stone-PL"));
assert_eq!(igt.words[1].gloss.as_deref(), Some("friend"));
assert_eq!(igt.recognised, 2);
}
#[test]
fn the_literal_translation_drops_grammatical_tags() {
let igt = build(&phon(), &morph(), &lex(), "katai nilo");
assert_eq!(igt.translation, "stone friend");
}
#[test]
fn an_unrecognised_word_passes_through() {
let igt = build(&phon(), &morph(), &lex(), "xyz nilo");
assert!(igt.words[0].gloss.is_none());
assert_eq!(igt.recognised, 1);
assert!(igt.translation.starts_with("xyz"));
}
#[test]
fn segments_an_inflected_word() {
let igt = build(&phon(), &morph(), &lex(), "katai");
let w = &igt.words[0];
assert_eq!(w.segments.len(), 2);
assert_eq!(w.segments[0].surface, "kata");
assert_eq!(w.segments[0].gloss, "stone");
assert_eq!(w.segments[1].surface, "i");
assert_eq!(w.segments[1].gloss, "PL");
}
#[test]
fn the_segmented_line_shows_morpheme_boundaries() {
let igt = build(&phon(), &morph(), &lex(), "katai nilo");
let line1 = igt.render().lines().next().unwrap().to_string();
assert!(line1.contains("kata-i"), "line1: {line1}");
}
#[test]
fn igt_round_trips_through_json() {
let igt = build(&phon(), &morph(), &lex(), "katai zzz");
assert!(igt.words[1].gloss.is_none() && igt.words[1].segments.is_empty(), "zzz should be unrecognised");
let json = serde_json::to_string(&igt).unwrap();
let back: Igt = serde_json::from_str(&json).unwrap();
assert_eq!(igt, back);
}
#[test]
fn render_aligns_the_surface_and_gloss_columns() {
let igt = build(&phon(), &morph(), &lex(), "katai nilo");
let block = igt.render();
let lines: Vec<&str> = block.lines().collect();
assert_eq!(lines.len(), 3);
let s_col = lines[0].find("nilo").unwrap();
let g_col = lines[1].find("friend").unwrap();
assert_eq!(s_col, g_col, "columns misaligned:\n{block}");
assert_eq!(lines[2], "'stone friend'");
}
}