use crate::conlang::scansion::{self, Beat};
use crate::poetry::syllabify::{self, StressLevel};
use crate::prose::ProseLanguage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Foot {
Iamb,
Trochee,
Dactyl,
Anapest,
Amphibrach,
Spondee,
}
impl Foot {
pub fn parse(metre: &str) -> Option<Foot> {
Some(match metre.trim().to_lowercase().as_str() {
"iambic" | "iamb" => Foot::Iamb,
"trochaic" | "trochee" => Foot::Trochee,
"dactylic" | "dactyl" => Foot::Dactyl,
"anapestic" | "anapest" => Foot::Anapest,
"amphibrachic" | "amphibrach" => Foot::Amphibrach,
"spondaic" | "spondee" => Foot::Spondee,
_ => return None,
})
}
fn pattern(self) -> &'static [bool] {
match self {
Foot::Iamb => &[false, true],
Foot::Trochee => &[true, false],
Foot::Dactyl => &[true, false, false],
Foot::Anapest => &[false, false, true],
Foot::Amphibrach => &[false, true, false],
Foot::Spondee => &[true, true],
}
}
}
fn word_beats(word: &str, lang: ProseLanguage) -> Vec<Beat> {
let marked = syllabify::marked_syllable_index(word, lang.clone());
let clean: String = word.chars().filter(|&c| c != '\u{301}').collect();
let sylls = syllabify::syllabify(&clean, lang);
let n = sylls.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![if marked.is_some() { Beat::Stressed } else { Beat::Flexible }];
}
let stressed = marked.map(|m| m.min(n - 1)).or_else(|| {
sylls.iter().position(|s| s.stress == StressLevel::Primary)
});
(0..n)
.map(|i| if Some(i) == stressed { Beat::Stressed } else { Beat::Unstressed })
.collect()
}
pub fn line_to_beats(line: &str, lang: ProseLanguage) -> Vec<Beat> {
let mut beats = Vec::new();
for raw in line.split_whitespace() {
let word: String = raw
.chars()
.filter(|&c| c.is_alphabetic() || c == '\u{301}')
.collect();
if word.chars().any(|c| c.is_alphabetic()) {
beats.extend(word_beats(&word, lang.clone()));
}
}
beats
}
pub fn detect(beats: &[Beat]) -> Option<scansion::Meter> {
scansion::detect_meter(beats)
}
#[derive(Debug, Clone, PartialEq)]
pub struct LineScan {
pub syllables: usize,
pub expected_syllables: usize,
pub conformance: f64,
pub feminine_ending: bool,
pub catalectic: bool,
}
pub fn scan_line(beats: &[Beat], foot: Foot, feet: usize) -> LineScan {
let pat = foot.pattern();
let flen = pat.len();
let expected = feet * flen;
let n = beats.len();
let mut fixed = 0usize;
let mut hits = 0usize;
for (i, b) in beats.iter().enumerate() {
let expect_strong = pat[i % flen];
match b {
Beat::Flexible => {}
Beat::Stressed => {
fixed += 1;
if expect_strong {
hits += 1;
}
}
Beat::Unstressed => {
fixed += 1;
if !expect_strong {
hits += 1;
}
}
}
}
let conformance = if fixed == 0 { 1.0 } else { hits as f64 / fixed as f64 };
LineScan {
syllables: n,
expected_syllables: expected,
conformance,
feminine_ending: n == expected + 1,
catalectic: n + 1 == expected,
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct SyllabicScan {
pub syllables: usize,
pub target: usize,
pub delta: i64,
}
#[allow(dead_code)]
pub fn scan_syllabic(line: &str, lang: ProseLanguage, target: usize) -> SyllabicScan {
let n = line_to_beats(line, lang).len();
SyllabicScan { syllables: n, target, delta: n as i64 - target as i64 }
}
#[allow(dead_code)]
pub fn scan_accentual(beats: &[Beat]) -> usize {
beats.iter().filter(|b| matches!(b, Beat::Stressed)).count()
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub struct FreeVerseProfile {
pub lines: usize,
pub mean_syllables: f64,
pub rhythm_cv: f64,
pub longest: usize,
pub shortest: usize,
}
#[allow(dead_code)]
pub fn observe_free_verse(lines: &[Vec<Beat>]) -> FreeVerseProfile {
let counts: Vec<usize> = lines.iter().map(|l| l.len()).filter(|&n| n > 0).collect();
if counts.is_empty() {
return FreeVerseProfile {
lines: 0,
mean_syllables: 0.0,
rhythm_cv: 0.0,
longest: 0,
shortest: 0,
};
}
let n = counts.len();
let sum: usize = counts.iter().sum();
let mean = sum as f64 / n as f64;
let var = counts.iter().map(|&c| (c as f64 - mean).powi(2)).sum::<f64>() / n as f64;
let cv = if mean > 0.0 { var.sqrt() / mean } else { 0.0 };
FreeVerseProfile {
lines: n,
mean_syllables: mean,
rhythm_cv: cv,
longest: *counts.iter().max().unwrap(),
shortest: *counts.iter().min().unwrap(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prose::ProseLanguage;
#[test]
fn foot_parses_from_poem_metre() {
assert_eq!(Foot::parse("iambic"), Some(Foot::Iamb));
assert_eq!(Foot::parse("Trochee"), Some(Foot::Trochee));
assert_eq!(Foot::parse("free"), None);
}
#[test]
fn marked_russian_stress_drives_the_beats() {
let beats = line_to_beats("окно\u{301}", ProseLanguage::Ru);
assert_eq!(beats, vec![Beat::Unstressed, Beat::Stressed]);
}
#[test]
fn monosyllable_is_flexible_unless_marked() {
let b = line_to_beats("дуб", ProseLanguage::Ru);
assert_eq!(b, vec![Beat::Flexible]);
let m = line_to_beats("ду\u{301}б", ProseLanguage::Ru);
assert_eq!(m, vec![Beat::Stressed]);
}
#[test]
fn scan_line_detects_feminine_ending() {
let beats = vec![
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
Beat::Unstressed,
];
let scan = scan_line(&beats, Foot::Iamb, 4);
assert_eq!(scan.expected_syllables, 8);
assert!(scan.feminine_ending);
assert!(!scan.catalectic);
assert!((scan.conformance - 1.0).abs() < 1e-9);
}
#[test]
fn detect_reuses_scansion() {
let beats = vec![
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
Beat::Unstressed, Beat::Stressed, Beat::Unstressed, Beat::Stressed,
];
let m = detect(&beats).expect("a metre");
assert_eq!(m.foot, "iamb");
assert_eq!(m.feet, 4);
}
#[test]
fn syllabic_scan_reports_delta() {
let s = scan_syllabic("розы у окна", ProseLanguage::Ru, 5);
assert_eq!(s.syllables, 5);
assert_eq!(s.delta, 0);
}
#[test]
fn free_verse_flags_monotone_rhythm() {
let line = || vec![Beat::Unstressed; 6];
let p = observe_free_verse(&[line(), line(), line(), line()]);
assert_eq!(p.lines, 4);
assert!((p.mean_syllables - 6.0).abs() < 1e-9);
assert!(p.rhythm_cv < 0.2);
}
}