use crate::prelude::HashMap;
use crate::util::constants::app::{
MAX_ALLOWED_ARI, MAX_ALLOWED_CLI, MAX_ALLOWED_FKGL, MAX_ALLOWED_FRES, MAX_ALLOWED_GFI, MAX_ALLOWED_LIX, MAX_ALLOWED_SMOG,
};
use crate::util::find_first;
use crate::util::Label;
use aho_corasick::{AhoCorasickBuilder, MatchKind};
use derive_more::Display;
use dotenvy::dotenv;
use fancy_regex::Regex;
use itertools::Itertools;
use tracing::warn;
use tracing::{debug, trace};
pub mod constants;
use constants::{
ACRONYM_TOKEN, DOUBLE, DOUBLE_SYLLABIC_FOUR, DOUBLE_SYLLABIC_ONE, DOUBLE_SYLLABIC_THREE, DOUBLE_SYLLABIC_TWO, IRREGULAR_NOUNS,
IRREGULAR_NOUNS_INVERTED, NEED_TO_BE_FIXED, NON_ALPHABETIC, PLURAL_TO_SINGULAR, PROBLEMATIC_WORDS, SAME_SINGULAR_PLURAL, SINGLE,
SINGLE_SYLLABIC_ONE, SINGLE_SYLLABIC_TWO, TRIPLE, VOWEL, WORD_SYLLABLES,
};
#[derive(Clone, Copy, Debug, Default, Display, PartialEq)]
pub enum ReadabilityType {
#[display("ari")]
ARI,
#[display("cli")]
CLI,
#[default]
#[display("fkgl")]
FKGL,
#[display("fres")]
FRES,
#[display("gfi")]
GFI,
#[display("lix")]
Lix,
#[display("smog")]
SMOG,
}
impl From<ReadabilityType> for String {
fn from(value: ReadabilityType) -> Self {
value.to_string()
}
}
impl From<String> for ReadabilityType {
fn from(value: String) -> Self {
ReadabilityType::from_string(&value)
}
}
impl From<&str> for ReadabilityType {
fn from(value: &str) -> Self {
ReadabilityType::from_string(value)
}
}
impl ReadabilityType {
pub fn calculate(self, text: &str) -> f64 {
match self {
| ReadabilityType::ARI => automated_readability_index(text),
| ReadabilityType::CLI => coleman_liau_index(text),
| ReadabilityType::FKGL => flesch_kincaid_grade_level(text),
| ReadabilityType::FRES => flesch_reading_ease_score(text),
| ReadabilityType::GFI => gunning_fog_index(text),
| ReadabilityType::Lix => lix(text),
| ReadabilityType::SMOG => smog(text),
}
}
pub fn from_string(value: &str) -> ReadabilityType {
match value.to_lowercase().replace("-", " ").as_str() {
| "ari" | "automated readability index" => ReadabilityType::ARI,
| "cli" | "coleman liau index" => ReadabilityType::CLI,
| "fkgl" | "flesch kincaid grade level" => ReadabilityType::FKGL,
| "fres" | "flesch reading ease score" => ReadabilityType::FRES,
| "gfi" | "gunning fog index" => ReadabilityType::GFI,
| "lix" => ReadabilityType::Lix,
| "smog" | "simple measure of gobbledygook" => ReadabilityType::SMOG,
| _ => {
warn!(value, "=> {} Unknown Readability Type", Label::using());
ReadabilityType::default()
}
}
}
pub fn maximum_allowed(self) -> f64 {
match self {
| ReadabilityType::ARI => MAX_ALLOWED_ARI,
| ReadabilityType::CLI => MAX_ALLOWED_CLI,
| ReadabilityType::FKGL => MAX_ALLOWED_FKGL,
| ReadabilityType::FRES => MAX_ALLOWED_FRES,
| ReadabilityType::GFI => MAX_ALLOWED_GFI,
| ReadabilityType::Lix => MAX_ALLOWED_LIX,
| ReadabilityType::SMOG => MAX_ALLOWED_SMOG,
}
}
pub fn maximum_allowed_from_env(self) -> Option<f64> {
match dotenv() {
| Ok(_) => {
let variables = dotenvy::vars().collect::<Vec<(String, String)>>();
let pair = match self {
| ReadabilityType::ARI => find_first(variables, "MAX_ALLOWED_ARI"),
| ReadabilityType::CLI => find_first(variables, "MAX_ALLOWED_CLI"),
| ReadabilityType::FKGL => find_first(variables, "MAX_ALLOWED_FKGL"),
| ReadabilityType::FRES => find_first(variables, "MAX_ALLOWED_FRES"),
| ReadabilityType::GFI => find_first(variables, "MAX_ALLOWED_GFI"),
| ReadabilityType::Lix => find_first(variables, "MAX_ALLOWED_LIX"),
| ReadabilityType::SMOG => find_first(variables, "MAX_ALLOWED_SMOG"),
};
match pair {
| Some((_, value)) => value.parse::<f64>().ok(),
| None => None,
}
}
| Err(_) => None,
}
}
}
pub fn expand_acronyms(text: &str, acronyms: &HashMap<String, String>) -> String {
const AHO_MAP_THRESHOLD: usize = 32;
const AHO_TEXT_THRESHOLD: usize = 256;
fn expand_with_regex(text: &str, normalized: &HashMap<String, String>) -> String {
ACRONYM_TOKEN
.replace_all(text, |captures: &fancy_regex::Captures<'_>| {
captures.get(0).map_or(String::new(), |value| {
let token = value.as_str();
normalized.get(&token.to_lowercase()).cloned().unwrap_or_else(|| token.to_string())
})
})
.to_string()
}
fn is_word_character(character: char) -> bool {
character == '_' || character.is_alphanumeric()
}
fn is_token_boundary(text: &str, start: usize, end: usize) -> bool {
let previous = text.get(..start).and_then(|segment| segment.chars().next_back());
let next = text.get(end..).and_then(|segment| segment.chars().next());
!matches!(previous, Some(value) if is_word_character(value)) && !matches!(next, Some(value) if is_word_character(value))
}
let normalized = acronyms
.iter()
.map(|(key, value)| (key.to_lowercase(), value.clone()))
.collect::<HashMap<String, String>>();
match normalized.is_empty() {
| true => text.to_string(),
| false if normalized.len() <= AHO_MAP_THRESHOLD || text.len() < AHO_TEXT_THRESHOLD => expand_with_regex(text, &normalized),
| false => {
let patterns = normalized
.keys()
.map(String::as_str)
.sorted_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)))
.collect::<Vec<_>>();
let matcher = AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostLongest)
.build(patterns);
match matcher {
| Ok(value) => {
let (result, last) = value
.find_iter(text)
.fold((String::with_capacity(text.len()), 0usize), |(result, last), found| {
let start = found.start();
let end = found.end();
let replacement = is_token_boundary(text, start, end)
.then(|| normalized.get(&text[start..end].to_lowercase()).cloned())
.flatten();
match replacement {
| Some(value) => {
let chunk = [result, text[last..start].to_string(), value];
(chunk.concat(), end)
}
| None => (result, last),
}
});
[result, text[last..].to_string()].concat()
}
| Err(_) => expand_with_regex(text, &normalized),
}
}
}
}
pub fn complex_word_count(text: &str) -> u32 {
words(text).iter().filter(|word| syllable_count(word) > 2).count() as u32
}
pub fn letter_count(text: &str) -> u32 {
text.chars()
.filter(|c| !(c.is_whitespace() || NON_ALPHABETIC.is_match(&c.to_string()).unwrap_or_default()))
.count() as u32
}
pub fn long_word_count(text: &str) -> u32 {
words(text).iter().filter(|word| word.len() > 6).count() as u32
}
pub fn sentence_count(text: &str) -> u32 {
fn is_wrapper(character: char) -> bool {
matches!(character, ')' | ']' | '}' | '"' | '\'')
}
fn next_meaningful(chars: &[char], index: usize) -> Option<char> {
chars
.iter()
.skip(index.saturating_add(1))
.copied()
.find(|character| !(character.is_whitespace() || is_wrapper(*character)))
}
fn previous_meaningful(chars: &[char], index: usize) -> Option<char> {
chars
.get(..index)
.into_iter()
.flatten()
.rev()
.copied()
.find(|character| !(character.is_whitespace() || is_wrapper(*character)))
}
fn token_before(chars: &[char], index: usize) -> String {
chars.get(..index).map_or(String::new(), |slice| {
slice
.iter()
.rev()
.take_while(|character| !character.is_whitespace())
.copied()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<String>()
.trim_matches(|character: char| !character.is_alphanumeric() && character != '.')
.to_lowercase()
})
}
fn suppresses_boundary(token: &str, next: Option<char>) -> bool {
matches!(token, "e.g" | "i.e")
|| matches!(token, "mr" | "mrs" | "ms" | "dr" | "prof" | "sr" | "jr" | "vs" | "st")
&& matches!(next, Some(value) if value.is_alphabetic())
}
let chars = text.chars().collect::<Vec<_>>();
chars
.iter()
.enumerate()
.filter(|(index, character)| match character {
| '!' | '?' => previous_meaningful(&chars, *index).is_some(),
| '.' => {
let previous = previous_meaningful(&chars, *index);
let next = next_meaningful(&chars, *index);
let token = token_before(&chars, *index);
!matches!(chars.get(index.saturating_add(1)), Some('.'))
&& !matches!(chars.get(index.wrapping_sub(1)), Some('.'))
&& !matches!((previous, next), (Some(left), Some(right)) if left.is_ascii_digit() && right.is_ascii_digit())
&& !matches!(next, Some(value) if value.is_ascii_lowercase())
&& !suppresses_boundary(&token, next)
&& previous.is_some()
}
| _ => false,
})
.count() as u32
}
pub fn words(text: &str) -> Vec<String> {
text.split_whitespace().map(String::from).collect()
}
pub fn word_count(text: &str) -> u32 {
words(text).len() as u32
}
pub fn automated_readability_index(text: &str) -> f64 {
let letters = letter_count(text);
let words = word_count(text);
let sentences = sentence_count(text);
debug!(letters, words, sentences, "=> {}", Label::using());
let score = 4.71 * (letters as f64 / words as f64) + 0.5 * (words as f64 / sentences as f64) - 21.43;
(score * 100.0).round() / 100.0
}
pub fn coleman_liau_index(text: &str) -> f64 {
let letters = letter_count(text);
let words = word_count(text);
let sentences = sentence_count(text);
debug!(letters, words, sentences, "=> {}", Label::using());
let score = (0.0588 * 100.0 * (letters as f64 / words as f64)) - (0.296 * 100.0 * (sentences as f64 / words as f64)) - 15.8;
(score * 100.0).round() / 100.0
}
pub fn flesch_kincaid_grade_level(text: &str) -> f64 {
let words = word_count(text);
let sentences = sentence_count(text);
let syllables = syllable_count(text);
debug!(words, sentences, syllables, "=> {}", Label::using());
let score = 0.39 * (words as f64 / sentences as f64) + 11.8 * (syllables as f64 / words as f64) - 15.59;
(score * 100.0).round() / 100.0
}
pub fn flesch_reading_ease_score(text: &str) -> f64 {
let words = word_count(text);
let sentences = sentence_count(text);
let syllables = syllable_count(text);
debug!(words, sentences, syllables, "=> {}", Label::using());
let score = 206.835 - (1.015 * words as f64 / sentences as f64) - (84.6 * syllables as f64 / words as f64);
(score * 100.0).round() / 100.0
}
pub fn gunning_fog_index(text: &str) -> f64 {
let words = word_count(text);
let complex_words = complex_word_count(text);
let sentences = sentence_count(text);
let score = 0.4 * ((words as f64 / sentences as f64) + (100.0 * (complex_words as f64 / words as f64)));
(score * 100.0).round() / 100.0
}
pub fn lix(text: &str) -> f64 {
let words = word_count(text);
let sentences = sentence_count(text);
let long_words = long_word_count(text);
let score = (words as f64 / sentences as f64) + 100.0 * (long_words as f64 / words as f64);
(score * 100.0).round() / 100.0
}
pub fn smog(text: &str) -> f64 {
let sentences = sentence_count(text);
let complex_words = complex_word_count(text);
let score = 1.0430 * (30.0 * (complex_words as f64 / sentences as f64)).sqrt() + 3.1291;
(score * 100.0).round() / 100.0
}
pub fn singular_form(word: &str) -> String {
match word.to_lowercase().as_str() {
| value if SAME_SINGULAR_PLURAL.contains(&value) => value.to_string(),
| value if IRREGULAR_NOUNS.contains_key(&value) => value.to_string(),
| value if IRREGULAR_NOUNS_INVERTED.contains_key(&value) => match IRREGULAR_NOUNS_INVERTED.get(value) {
| Some(value) => value.to_string(),
| None => value.to_string(),
},
| value => {
let pair = PLURAL_TO_SINGULAR.iter().find(|(pattern, _)| {
#[allow(clippy::unwrap_used)]
Regex::new(pattern).unwrap().is_match(value).unwrap_or(false)
});
match pair {
| Some((pattern, replacement)) => {
trace!(pattern, replacement, value, "=> {} Singular form conversion", Label::using());
#[allow(clippy::unwrap_used)]
let re = Regex::new(pattern).unwrap();
re.replace_all(value, *replacement).to_string()
}
| None => value.to_string(),
}
}
}
}
pub fn syllable_count(text: &str) -> usize {
fn syllables(word: String) -> usize {
let singular = singular_form(&word);
let normalized = word.to_lowercase();
if let Some(value) = WORD_SYLLABLES.get(&normalized).or_else(|| WORD_SYLLABLES.get(&singular)) {
return *value;
}
match word.as_str() {
| "" => 0,
| value if value.len() < 3 => 1,
| value if PROBLEMATIC_WORDS.contains_key(value) => match PROBLEMATIC_WORDS.get(value) {
| Some(x) => *x,
| None => 0,
},
| _ if PROBLEMATIC_WORDS.contains_key(&singular.as_str()) => match PROBLEMATIC_WORDS.get(singular.as_str()) {
| Some(x) => *x,
| None => 0,
},
| value if NEED_TO_BE_FIXED.contains_key(value) => match NEED_TO_BE_FIXED.get(value) {
| Some(x) => *x,
| None => 0,
},
| _ if NEED_TO_BE_FIXED.contains_key(&singular.as_str()) => match NEED_TO_BE_FIXED.get(singular.as_str()) {
| Some(x) => *x,
| None => 0,
},
| _ => {
#[allow(clippy::arithmetic_side_effects)]
{
let mut count: isize = 0;
let mut input = word.to_lowercase();
count += 3 * TRIPLE.find_iter(&input).count() as isize;
input = TRIPLE.replace_all(&input, "").to_string();
count += 2 * DOUBLE.find_iter(&input).count() as isize;
input = DOUBLE.replace_all(&input, "").to_string();
count += SINGLE.find_iter(&input).count() as isize;
input = SINGLE.replace_all(&input, "").to_string();
count -= SINGLE_SYLLABIC_ONE.find_iter(&input).count() as isize;
count -= SINGLE_SYLLABIC_TWO.find_iter(&input).count() as isize;
count += DOUBLE_SYLLABIC_ONE.find_iter(&input).count() as isize;
count += DOUBLE_SYLLABIC_TWO.find_iter(&input).count() as isize;
count += DOUBLE_SYLLABIC_THREE.find_iter(&input).count() as isize;
count += DOUBLE_SYLLABIC_FOUR.find_iter(&input).count() as isize;
count += VOWEL.split(&input).filter_map(Result::ok).filter(|x| !x.is_empty()).count() as isize;
count.max(1).cast_unsigned()
}
}
}
}
let tokens = text.split_whitespace().flat_map(tokenize).collect::<Vec<String>>();
tokens.into_iter().map(syllables).sum()
}
pub(crate) fn tokenize(value: &str) -> Vec<String> {
value
.replace("é", "-e")
.replace("ë", "-e")
.split('-')
.map(|x| NON_ALPHABETIC.replace_all(x, "").to_lowercase())
.collect::<Vec<_>>()
}
#[cfg(test)]
mod tests;