use low_expectations::ExpectationSuite;
use prosesmasher_domain_types::{Block, CheckConfig, Document, Locale};
use serde_json::json;
use crate::check::Check;
#[derive(Debug)]
pub struct GunningFogCheck;
impl Check for GunningFogCheck {
fn id(&self) -> &'static str {
"gunning-fog"
}
fn label(&self) -> &'static str {
"Gunning Fog Index"
}
fn supported_locales(&self) -> Option<&'static [Locale]> {
None
}
fn run(&self, doc: &Document, config: &CheckConfig, suite: &mut ExpectationSuite) {
if !config.quality.readability.enabled {
return;
}
let Some(max) = config.quality.readability.gunning_fog_max else {
return;
};
let total_words = doc.metadata.total_words;
let total_sentences = doc.metadata.total_sentences;
if total_sentences == 0 || total_words == 0 {
return;
}
let mut complex_count: usize = 0;
for section in &doc.sections {
complex_count = complex_count.saturating_add(count_complex_in_blocks(§ion.blocks));
}
let words_f = f64::from(u32::try_from(total_words).unwrap_or(u32::MAX));
let sentences_f = f64::from(u32::try_from(total_sentences).unwrap_or(u32::MAX));
let complex_f = f64::from(u32::try_from(complex_count).unwrap_or(u32::MAX));
let score = 0.4 * 100.0f64.mul_add(complex_f / words_f, words_f / sentences_f);
let score_100 = f64_to_i64_x100(score);
let max_100 = f64_to_i64_x100(max);
let _result = suite
.record_custom_values(
"gunning-fog",
score_100 <= max_100,
json!({
"maximum_score_x100": max_100,
"formula": "0.4 × ((words/sentences) + 100 × (complex_words/words))",
}),
json!({
"score_x100": score_100,
"score": score,
"total_words": total_words,
"total_sentences": total_sentences,
"complex_word_count": complex_count,
}),
&[json!({
"score_x100": score_100,
"score": score,
"total_words": total_words,
"total_sentences": total_sentences,
"complex_word_count": complex_count,
"maximum_score_x100": max_100,
})],
)
.label("Gunning Fog Index")
.checking("fog index (×100)");
}
}
fn count_complex_in_blocks(blocks: &[Block]) -> usize {
let mut count: usize = 0;
for block in blocks {
match block {
Block::Paragraph(p) => {
for sentence in &p.sentences {
for word in &sentence.words {
if word.syllable_count >= 3 {
count = count.saturating_add(1);
}
}
}
}
Block::BlockQuote(inner) => {
count = count.saturating_add(count_complex_in_blocks(inner));
}
Block::List(_) | Block::CodeBlock(_) => {}
}
}
count
}
fn f64_to_i64_x100(value: f64) -> i64 {
let rounded = (value * 100.0).round();
let clamped = rounded.clamp(f64::from(i32::MIN), f64::from(i32::MAX));
#[allow(clippy::cast_possible_truncation, clippy::as_conversions)]
let narrow = clamped as i32;
i64::from(narrow)
}
#[cfg(test)]
#[path = "gunning_fog_tests.rs"]
mod tests;