use crate::conlang::phonology::{stress_eval, syllable};
use crate::conlang::types::{Phonology, PhonemeKind};
use crate::language_entry::DictionaryEntry;
const ACUTE: char = '\u{0301}';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Beat {
Stressed,
Unstressed,
Flexible,
}
impl Beat {
pub fn glyph(self) -> char {
match self {
Beat::Stressed => '/',
Beat::Unstressed => '×',
Beat::Flexible => '·',
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScannedSyllable {
pub text: String,
pub beat: Beat,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScannedWord {
pub surface: String,
pub syllables: Vec<ScannedSyllable>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Meter {
pub foot: &'static str,
pub feet: usize,
pub name: String,
pub conformance: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ScannedLine {
pub words: Vec<ScannedWord>,
pub meter: Option<Meter>,
}
impl ScannedLine {
pub fn beats(&self) -> Vec<Beat> {
self.words.iter().flat_map(|w| w.syllables.iter().map(|s| s.beat)).collect()
}
}
const FEET: &[(&str, &[bool])] = &[
("iamb", &[false, true]),
("trochee", &[true, false]),
("anapest", &[false, false, true]),
("dactyl", &[true, false, false]),
("amphibrach", &[false, true, false]),
];
pub fn scan_line(phon: &Phonology, entries: &[DictionaryEntry], line: &str) -> ScannedLine {
let vowels = vowel_graphemes(phon);
let mut words = Vec::new();
for raw in line.split_whitespace() {
let word = trim_punct(raw);
if word.chars().all(|c| !c.is_alphabetic()) {
continue;
}
words.push(scan_word(phon, entries, &vowels, word));
}
let meter = detect_meter(&flatten(&words));
ScannedLine { words, meter }
}
pub fn scan_text(phon: &Phonology, entries: &[DictionaryEntry], text: &str) -> Vec<ScannedLine> {
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| scan_line(phon, entries, l))
.collect()
}
pub fn dominant_meter(lines: &[ScannedLine]) -> Option<Meter> {
use std::collections::HashMap;
let mut tally: HashMap<(&str, usize), (usize, f64)> = HashMap::new();
for l in lines {
if let Some(m) = &l.meter {
let e = tally.entry((m.foot, m.feet)).or_insert((0, 0.0));
e.0 += 1;
e.1 += m.conformance;
}
}
tally.into_iter().max_by_key(|(_, (count, _))| *count).map(|((foot, feet), (count, sumc))| Meter {
foot,
feet,
name: format!("{} {}", adjective(foot), length_name(feet)),
conformance: sumc / count as f64,
})
}
fn flatten(words: &[ScannedWord]) -> Vec<Beat> {
words.iter().flat_map(|w| w.syllables.iter().map(|s| s.beat)).collect()
}
fn scan_word(
phon: &Phonology,
entries: &[DictionaryEntry],
vowels: &[String],
word: &str,
) -> ScannedWord {
let (clean, marked_syllable) = strip_stress_mark(word, vowels);
let seq = phon.segment(&clean);
let sylls = syllable::syllabify(phon, &seq);
let n = sylls.len().max(1);
let (stressed, lexical) = if let Some(i) = marked_syllable {
(Some(i.min(n - 1)), true)
} else if let Some(i) = lexicon_stress(entries, &clean) {
(Some((i.saturating_sub(1)).min(n - 1)), true)
} else if let Some(rule) = &phon.stress {
(stress_eval::primary_stress(rule, &sylls), false)
} else {
(None, false)
};
let render = |s: &syllable::Syllable| -> String {
s.onset
.iter()
.chain(&s.nucleus)
.chain(&s.coda)
.map(|ipa| phon.phoneme(ipa).map(|p| p.grapheme().to_string()).unwrap_or_else(|| ipa.clone()))
.collect()
};
let mono = sylls.len() <= 1;
let syllables = sylls
.iter()
.enumerate()
.map(|(i, s)| {
let beat = if mono {
if lexical && stressed == Some(i) { Beat::Stressed } else { Beat::Flexible }
} else {
match stressed {
Some(k) if k == i => Beat::Stressed,
Some(_) => Beat::Unstressed,
None => Beat::Flexible,
}
};
ScannedSyllable { text: render(s), beat }
})
.collect();
ScannedWord { surface: word.to_string(), syllables }
}
fn lexicon_stress(entries: &[DictionaryEntry], word: &str) -> Option<usize> {
entries
.iter()
.find(|e| e.word.eq_ignore_ascii_case(word) || unicode_eq(&e.word, word))
.and_then(|e| e.stress)
.filter(|&s| s > 0)
}
fn unicode_eq(a: &str, b: &str) -> bool {
a.to_lowercase() == b.to_lowercase()
}
fn strip_stress_mark(word: &str, vowels: &[String]) -> (String, Option<usize>) {
if !word.contains(ACUTE) {
return (word.to_string(), None);
}
let mut clean = String::with_capacity(word.len());
let mut vowel_ord = 0usize; let mut marked: Option<usize> = None;
for ch in word.chars() {
if ch == ACUTE {
if marked.is_none() && vowel_ord > 0 {
marked = Some(vowel_ord - 1);
}
continue;
}
clean.push(ch);
if is_vowel_char(ch, vowels) {
vowel_ord += 1;
}
}
(clean, marked)
}
fn is_vowel_char(ch: char, vowels: &[String]) -> bool {
let s = ch.to_lowercase().to_string();
vowels.iter().any(|v| v.to_lowercase() == s)
}
fn vowel_graphemes(phon: &Phonology) -> Vec<String> {
phon.phonemes
.iter()
.filter(|p| p.kind == PhonemeKind::Vowel)
.map(|p| p.grapheme().to_string())
.collect()
}
fn trim_punct(s: &str) -> &str {
s.trim_matches(|c: char| !c.is_alphabetic() && c != ACUTE && c != '\u{0300}')
}
fn detect_meter(beats: &[Beat]) -> Option<Meter> {
let n = beats.len();
if n < 2 {
return None;
}
let mut best: Option<Meter> = None;
for (foot, pattern) in FEET {
let f = pattern.len();
if n < f {
continue;
}
let feet = (n / f).max(1);
let mut fixed = 0usize;
let mut hits = 0usize;
for (i, b) in beats.iter().enumerate() {
let expect_stressed = pattern[i % f];
match b {
Beat::Flexible => {}
Beat::Stressed => {
fixed += 1;
if expect_stressed {
hits += 1;
}
}
Beat::Unstressed => {
fixed += 1;
if !expect_stressed {
hits += 1;
}
}
}
}
if fixed == 0 {
continue;
}
let conformance = hits as f64 / fixed as f64;
let cand = Meter {
foot,
feet,
name: format!("{} {}", adjective(foot), length_name(feet)),
conformance,
};
if best.as_ref().is_none_or(|b| cand.conformance > b.conformance) {
best = Some(cand);
}
}
best.filter(|m| m.conformance >= 0.7)
}
fn adjective(foot: &str) -> &'static str {
match foot {
"iamb" => "iambic",
"trochee" => "trochaic",
"anapest" => "anapestic",
"dactyl" => "dactylic",
"amphibrach" => "amphibrachic",
_ => "metrical",
}
}
fn length_name(feet: usize) -> String {
match feet {
1 => "monometer".into(),
2 => "dimeter".into(),
3 => "trimeter".into(),
4 => "tetrameter".into(),
5 => "pentameter".into(),
6 => "hexameter".into(),
7 => "heptameter".into(),
8 => "octameter".into(),
n => format!("{n}-meter"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conlang::types::{Phoneme, PhonemeKind, StressRule};
use crate::conlang::types::stress::StressPlacement;
fn ph(ipa: &str, roman: &str, kind: PhonemeKind) -> Phoneme {
Phoneme { ipa: ipa.into(), romanize: Some(roman.into()), kind, sonority: None }
}
fn ru() -> Phonology {
Phonology {
phonemes: vec![
ph("a", "а", PhonemeKind::Vowel),
ph("o", "о", PhonemeKind::Vowel),
ph("u", "у", PhonemeKind::Vowel),
ph("i", "и", PhonemeKind::Vowel),
ph("e", "е", PhonemeKind::Vowel),
ph("ɨ", "ы", PhonemeKind::Vowel),
ph("d", "д", PhonemeKind::Consonant),
ph("m", "м", PhonemeKind::Consonant),
ph("k", "к", PhonemeKind::Consonant),
ph("n", "н", PhonemeKind::Consonant),
ph("r", "р", PhonemeKind::Consonant),
ph("l", "л", PhonemeKind::Consonant),
ph("t", "т", PhonemeKind::Consonant),
ph("s", "с", PhonemeKind::Consonant),
ph("v", "в", PhonemeKind::Consonant),
ph("b", "б", PhonemeKind::Consonant),
ph("g", "г", PhonemeKind::Consonant),
ph("j", "й", PhonemeKind::Consonant),
ph("z", "з", PhonemeKind::Consonant),
ph("p", "п", PhonemeKind::Consonant),
ph("x", "х", PhonemeKind::Consonant),
ph("f", "ф", PhonemeKind::Consonant),
ph("ʃ", "ш", PhonemeKind::Consonant),
ph("ʒ", "ж", PhonemeKind::Consonant),
],
..Default::default()
}
}
#[test]
fn explicit_mark_sets_the_stressed_syllable() {
let p = ru();
let w = scan_word(&p, &[], &vowel_graphemes(&p), "окно\u{0301}");
assert_eq!(w.syllables.len(), 2);
assert_eq!(w.syllables[0].beat, Beat::Unstressed);
assert_eq!(w.syllables[1].beat, Beat::Stressed);
}
#[test]
fn lexicon_stress_is_used_when_text_is_unmarked() {
let p = ru();
let entries = vec![DictionaryEntry {
word: "окно".into(),
stress: Some(2),
..Default::default()
}];
let w = scan_word(&p, &entries, &vowel_graphemes(&p), "окно");
assert_eq!(w.syllables[1].beat, Beat::Stressed);
assert_eq!(w.syllables[0].beat, Beat::Unstressed);
}
#[test]
fn stress_rule_is_the_last_resort() {
let mut p = ru();
p.stress = Some(StressRule { primary: StressPlacement::Initial });
let w = scan_word(&p, &[], &vowel_graphemes(&p), "молоко");
assert_eq!(w.syllables.len(), 3);
assert_eq!(w.syllables[0].beat, Beat::Stressed);
assert_eq!(w.syllables[1].beat, Beat::Unstressed);
assert_eq!(w.syllables[2].beat, Beat::Unstressed);
}
#[test]
fn unmarked_monosyllable_is_flexible() {
let p = ru();
let w = scan_word(&p, &[], &vowel_graphemes(&p), "дуб");
assert_eq!(w.syllables.len(), 1);
assert_eq!(w.syllables[0].beat, Beat::Flexible);
}
#[test]
fn beats_render_the_glyph_row() {
let p = ru();
let line = scan_line(&p, &[], "окно\u{0301} дуб");
let glyphs: String = line.beats().iter().map(|b| b.glyph()).collect();
assert_eq!(glyphs, "×/·");
assert_eq!(line.beats().len(), 3);
}
#[test]
fn detects_iambic_tetrameter() {
let beats = vec![
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
];
let m = detect_meter(&beats).expect("a metre");
assert_eq!(m.foot, "iamb");
assert_eq!(m.feet, 4);
assert_eq!(m.name, "iambic tetrameter");
assert!((m.conformance - 1.0).abs() < 1e-9);
}
#[test]
fn flexible_monosyllables_promote_to_fit() {
let beats = vec![
Beat::Flexible, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
];
let m = detect_meter(&beats).expect("a metre");
assert_eq!(m.foot, "iamb");
assert!((m.conformance - 1.0).abs() < 1e-9); }
#[test]
fn free_verse_names_no_metre() {
let beats = vec![
Beat::Stressed, Beat::Stressed, Beat::Stressed, Beat::Stressed, Beat::Stressed,
];
assert!(detect_meter(&beats).map(|m| m.conformance < 0.7).unwrap_or(true));
}
}