pub const WORDS_PER_MINUTE: f64 = 200.0;
pub const EASY_AVG_WORD_LENGTH: f64 = 4.5;
pub const HARD_AVG_WORD_LENGTH: f64 = 6.5;
pub const HARD_AVG_SENTENCE_LENGTH: f64 = 20.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Difficulty {
Easy,
Medium,
Hard,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextStats {
pub words: u64,
pub sentences: u64,
pub characters: u64,
pub characters_no_spaces: u64,
pub avg_word_length: f64,
pub avg_sentence_length: f64,
pub estimated_reading_seconds: f64,
pub estimated_reading_minutes: u64,
pub difficulty_score: f64,
}
impl TextStats {
#[must_use]
pub fn difficulty_band(&self) -> Difficulty {
if self.difficulty_score < 33.0 {
Difficulty::Easy
} else if self.difficulty_score < 66.0 {
Difficulty::Medium
} else {
Difficulty::Hard
}
}
}
fn word_tokens(text: &str) -> Vec<String> {
text.split(|c: char| !(c.is_alphanumeric() || c == '\''))
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect()
}
fn count_sentences(text: &str) -> u64 {
let n = text.chars().filter(|&c| c == '.' || c == '!' || c == '?').count() as u64;
if n == 0 && text.chars().any(char::is_alphanumeric) {
1
} else {
n
}
}
#[must_use]
pub fn stats(text: &str) -> TextStats {
let words = word_tokens(text);
let word_count = words.len() as u64;
let sentences = count_sentences(text);
let characters = text.chars().count() as u64;
let characters_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count() as u64;
let total_letters: u64 = words.iter().map(|w| w.chars().filter(|c| c.is_alphabetic()).count() as u64).sum();
let avg_word_length = if word_count == 0 {
0.0
} else {
total_letters as f64 / word_count as f64
};
let avg_sentence_length = if sentences == 0 {
0.0
} else {
word_count as f64 / sentences as f64
};
let estimated_reading_seconds = if word_count == 0 {
0.0
} else {
let secs = (word_count as f64 / WORDS_PER_MINUTE) * 60.0;
secs.max(1.0)
};
let estimated_reading_minutes = if word_count == 0 {
0
} else {
((estimated_reading_seconds / 60.0).ceil() as u64).max(1)
};
let word_signal = clamp01((avg_word_length - EASY_AVG_WORD_LENGTH) / (HARD_AVG_WORD_LENGTH - EASY_AVG_WORD_LENGTH)) * 50.0;
let sentence_signal = clamp01(avg_sentence_length / HARD_AVG_SENTENCE_LENGTH) * 50.0;
let difficulty_score = word_signal + sentence_signal;
TextStats {
words: word_count,
sentences,
characters,
characters_no_spaces,
avg_word_length,
avg_sentence_length,
estimated_reading_seconds,
estimated_reading_minutes,
difficulty_score,
}
}
#[inline]
fn clamp01(x: f64) -> f64 {
if x < 0.0 {
0.0
} else if x > 1.0 {
1.0
} else {
x
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_is_all_zero() {
let s = stats("");
assert_eq!(s.words, 0);
assert_eq!(s.sentences, 0);
assert_eq!(s.characters, 0);
assert_eq!(s.avg_word_length, 0.0);
assert_eq!(s.difficulty_score, 0.0);
assert_eq!(s.difficulty_band(), Difficulty::Easy);
}
#[test]
fn counts_simple_sentence() {
let s = stats("The cat sat on the mat.");
assert_eq!(s.words, 6);
assert_eq!(s.sentences, 1);
assert!((s.avg_word_length - (17.0 / 6.0)).abs() < 1e-9);
assert_eq!(s.avg_sentence_length, 6.0);
}
#[test]
fn counts_multiple_sentences_with_mixed_terminators() {
let s = stats("Hello world! Are you ready? Let's go.");
assert_eq!(s.sentences, 3);
assert_eq!(s.words, 7); }
#[test]
fn contraction_is_one_word() {
let s = stats("don't can't won't");
assert_eq!(s.words, 3);
}
#[test]
fn words_without_terminator_still_count_as_sentence() {
let s = stats("just a fragment with no ending");
assert_eq!(s.sentences, 1);
assert_eq!(s.words, 6);
}
#[test]
fn character_counts() {
let s = stats("ab c");
assert_eq!(s.characters, 4); assert_eq!(s.characters_no_spaces, 3);
}
#[test]
fn reading_time_at_200_wpm() {
let text: String = std::iter::repeat("word ").take(400).collect();
let s = stats(&text);
assert!((s.estimated_reading_seconds - 120.0).abs() < 1e-6);
assert_eq!(s.estimated_reading_minutes, 2);
}
#[test]
fn single_word_minimum_one_second() {
let s = stats("hi");
assert!(s.estimated_reading_seconds >= 1.0);
assert_eq!(s.estimated_reading_minutes, 1);
}
#[test]
fn easy_prose_is_easy_band() {
let s = stats("The cat sat on the mat. The dog ran. It was fun.");
assert_eq!(s.difficulty_band(), Difficulty::Easy);
assert!(s.difficulty_score < 33.0);
}
#[test]
fn long_words_and_sentences_are_hard() {
let text = "Notwithstanding the fundamental institutional accommodations, the multidisciplinary organizational representatives nevertheless demonstrated extraordinary quantitative methodological considerations throughout the extensively protracted deliberations.";
let s = stats(text);
assert_eq!(s.difficulty_band(), Difficulty::Hard);
assert!(s.difficulty_score > 66.0);
}
#[test]
fn difficulty_score_bounded_0_to_100() {
let very_long: String = std::iter::repeat("internationalization ").take(500).collect();
let s = stats(&very_long);
assert!(s.difficulty_score <= 100.0);
assert!(s.difficulty_score >= 0.0);
}
#[test]
fn digits_excluded_from_avg_word_length() {
let s = stats("12345");
assert_eq!(s.words, 1);
assert_eq!(s.avg_word_length, 0.0);
}
#[test]
fn deterministic_on_repeated_calls() {
let t = "The quick brown fox jumps over the lazy dog.";
let a = stats(t);
let b = stats(t);
assert_eq!(a, b);
}
}