#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReadabilityCounts {
pub words: usize,
pub sentences: usize,
pub syllables: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ReadabilityScores {
pub flesch_reading_ease: Option<f32>,
pub flesch_kincaid_grade: Option<f32>,
}
pub fn count_syllables(word: &str) -> usize {
let normalized: Vec<u8> = word
.bytes()
.filter(|b| b.is_ascii_alphabetic())
.map(|b| b.to_ascii_lowercase())
.collect();
if normalized.is_empty() {
return 0;
}
let is_vowel = |b: u8| matches!(b, b'a' | b'e' | b'i' | b'o' | b'u' | b'y');
let mut count: usize = 0;
let mut prev_was_vowel = false;
for &b in &normalized {
let v = is_vowel(b);
if v && !prev_was_vowel {
count += 1;
}
prev_was_vowel = v;
}
let len = normalized.len();
if len >= 2 && normalized[len - 1] == b'e' {
let ends_in_le_after_consonant =
len >= 3 && normalized[len - 2] == b'l' && !is_vowel(normalized[len - 3]);
if !ends_in_le_after_consonant {
count = count.saturating_sub(1);
}
}
count.max(1)
}
#[must_use]
pub fn scores(counts: &ReadabilityCounts) -> ReadabilityScores {
if counts.words == 0 || counts.sentences == 0 {
return ReadabilityScores::default();
}
let words = counts.words as f32;
let sentences = counts.sentences as f32;
let syllables = counts.syllables as f32;
let words_per_sentence = words / sentences;
let syllables_per_word = syllables / words;
let fre = 206.835 - 1.015 * words_per_sentence - 84.6 * syllables_per_word;
let fkgl = 0.39 * words_per_sentence + 11.8 * syllables_per_word - 15.59;
ReadabilityScores {
flesch_reading_ease: Some(fre),
flesch_kincaid_grade: Some(fkgl),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(actual: f32, expected: f32, tolerance: f32, label: &str) {
assert!(
(actual - expected).abs() < tolerance,
"{label}: expected {expected} ± {tolerance}, got {actual}"
);
}
#[test]
fn syllables_empty_word_is_zero() {
assert_eq!(count_syllables(""), 0);
assert_eq!(count_syllables(" "), 0);
assert_eq!(count_syllables("!!!"), 0);
}
#[test]
fn syllables_single_syllable_words() {
assert_eq!(count_syllables("the"), 1);
assert_eq!(count_syllables("cat"), 1);
assert_eq!(count_syllables("dog"), 1);
assert_eq!(count_syllables("run"), 1);
assert_eq!(count_syllables("eight"), 1);
}
#[test]
fn syllables_two_syllable_words() {
assert_eq!(count_syllables("apple"), 2);
assert_eq!(count_syllables("table"), 2);
assert_eq!(count_syllables("simple"), 2);
}
#[test]
fn syllables_longer_words() {
assert_eq!(count_syllables("readability"), 5);
}
#[test]
fn syllables_edge_cases_documented() {
assert_eq!(count_syllables("every"), 3);
assert_eq!(count_syllables("shoreline"), 3);
assert_eq!(count_syllables("simile"), 2);
assert_eq!(count_syllables("queue"), 1);
}
#[test]
fn syllables_ignores_non_alpha_characters() {
assert_eq!(count_syllables("don't"), count_syllables("dont"));
assert_eq!(count_syllables("cat!"), 1);
assert_eq!(count_syllables(" hello "), count_syllables("hello"));
}
#[test]
fn syllables_handles_all_vowels_word() {
assert_eq!(count_syllables("aeiou"), 1); }
#[test]
fn scores_returns_none_for_empty_input() {
let empty = ReadabilityCounts::default();
let s = scores(&empty);
assert!(s.flesch_reading_ease.is_none());
assert!(s.flesch_kincaid_grade.is_none());
}
#[test]
fn scores_returns_none_when_sentences_is_zero() {
let counts = ReadabilityCounts {
words: 10,
sentences: 0,
syllables: 15,
};
let s = scores(&counts);
assert!(s.flesch_reading_ease.is_none());
assert!(s.flesch_kincaid_grade.is_none());
}
#[test]
fn scores_returns_none_when_words_is_zero() {
let counts = ReadabilityCounts {
words: 0,
sentences: 1,
syllables: 0,
};
let s = scores(&counts);
assert!(s.flesch_reading_ease.is_none());
assert!(s.flesch_kincaid_grade.is_none());
}
#[test]
fn scores_known_simple_sentence() {
let counts = ReadabilityCounts {
words: 6,
sentences: 1,
syllables: 6,
};
let s = scores(&counts);
assert_close(s.flesch_reading_ease.unwrap(), 116.145, 0.01, "FRE");
assert_close(s.flesch_kincaid_grade.unwrap(), -1.45, 0.01, "FKGL");
}
#[test]
fn scores_typical_prose() {
let counts = ReadabilityCounts {
words: 150,
sentences: 10,
syllables: 225,
};
let s = scores(&counts);
let fre = s.flesch_reading_ease.unwrap();
let fkgl = s.flesch_kincaid_grade.unwrap();
assert!(
(55.0..=75.0).contains(&fre),
"FRE for typical prose should be ~65, got {fre}"
);
assert!(
(6.0..=10.0).contains(&fkgl),
"FKGL for typical prose should be ~8, got {fkgl}"
);
}
}