readsight 1.0.0

Multilingual readability library — 86 languages, 17 formulas, TeX-based syllable counting via the Frank M. Liang algorithm.
Documentation
//! Heuristic (rule-based) syllable counter, including v1.0.7 `vowelMode`.

use std::sync::OnceLock;

use regex::Regex;
use serde_json::Value;

use crate::language::language::value_as_f64;

use super::counter::SyllableCounter;

fn non_letter_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"[^\p{L}]").unwrap())
}

/// Rule-based syllable counter driven by a language's `syllableHeuristics`.
pub struct HeuristicSyllableCounter {
    has_config: bool,
    problem_words: std::collections::HashMap<String, i64>,
    subtract_patterns: Vec<fancy_regex::Regex>,
    add_patterns: Vec<fancy_regex::Regex>,
    prefixes: Vec<(String, i64)>,
    suffixes: Vec<(String, i64)>,
    vowel_mode_individual: bool,
    individual_re: Regex,
    cluster_re: Regex,
    // Retained to compute `has_rules` faithfully.
    problem_words_present: bool,
    subtract_present: bool,
    add_present: bool,
}

fn object_string_array(v: Option<&Value>) -> Vec<String> {
    match v {
        Some(Value::Array(arr)) => arr
            .iter()
            .filter_map(|x| x.as_str().map(String::from))
            .collect(),
        _ => Vec::new(),
    }
}

fn object_int_map_ordered(v: Option<&Value>) -> Vec<(String, i64)> {
    match v {
        Some(Value::Object(map)) => map
            .iter()
            .map(|(k, val)| (k.clone(), value_as_f64(val).unwrap_or(0.0) as i64))
            .collect(),
        _ => Vec::new(),
    }
}

impl HeuristicSyllableCounter {
    /// Build from an optional `syllableHeuristics` JSON object.
    pub fn new(config: Option<&Value>) -> Self {
        let obj = config.and_then(|c| c.as_object());

        let problem_words: std::collections::HashMap<String, i64> = obj
            .and_then(|o| o.get("problemWords"))
            .and_then(|v| v.as_object())
            .map(|m| {
                m.iter()
                    .map(|(k, val)| (k.clone(), value_as_f64(val).unwrap_or(0.0) as i64))
                    .collect()
            })
            .unwrap_or_default();

        let subtract_raw = object_string_array(obj.and_then(|o| o.get("subtractPatterns")));
        let add_raw = object_string_array(obj.and_then(|o| o.get("addPatterns")));
        let prefixes = object_int_map_ordered(obj.and_then(|o| o.get("prefixes")));
        let suffixes = object_int_map_ordered(obj.and_then(|o| o.get("suffixes")));

        let subtract_present = !subtract_raw.is_empty();
        let add_present = !add_raw.is_empty();
        let problem_words_present = !problem_words.is_empty();

        let subtract_patterns = subtract_raw
            .iter()
            .filter_map(|p| fancy_regex::Regex::new(p).ok())
            .collect();
        let add_patterns = add_raw
            .iter()
            .filter_map(|p| fancy_regex::Regex::new(p).ok())
            .collect();

        let vowel_pattern = obj
            .and_then(|o| o.get("vowelPattern"))
            .and_then(|v| v.as_str())
            .unwrap_or("[aeiouy]");
        let vowel_chars: String = vowel_pattern
            .trim_matches(|c| c == '[' || c == ']')
            .to_string();

        let vowel_mode_individual = obj
            .and_then(|o| o.get("vowelMode"))
            .and_then(|v| v.as_str())
            .map(|s| s == "individual")
            .unwrap_or(false);

        let individual_re = Regex::new(&format!("[{vowel_chars}]"))
            .unwrap_or_else(|_| Regex::new("[aeiouy]").unwrap());
        let cluster_re = Regex::new(&format!("[^{vowel_chars}]+"))
            .unwrap_or_else(|_| Regex::new("[^aeiouy]+").unwrap());

        HeuristicSyllableCounter {
            has_config: config.is_some(),
            problem_words,
            subtract_patterns,
            add_patterns,
            prefixes,
            suffixes,
            vowel_mode_individual,
            individual_re,
            cluster_re,
            problem_words_present,
            subtract_present,
            add_present,
        }
    }

    fn count_vowel_groups(&self, clean: &str) -> i64 {
        if self.vowel_mode_individual {
            self.individual_re.find_iter(clean).count() as i64
        } else {
            self.cluster_re
                .split(clean)
                .filter(|p| !p.is_empty())
                .count() as i64
        }
    }

    /// Whether this counter carries actionable rules.
    pub fn has_rules(&self) -> bool {
        self.has_config
            && (self.problem_words_present
                || self.subtract_present
                || self.add_present
                || !self.prefixes.is_empty()
                || !self.suffixes.is_empty())
    }

    /// Whether `problemWords` contains the (trimmed, lowercased) word.
    pub fn has_word(&self, word: &str) -> bool {
        let word = word.trim();
        if word.is_empty() {
            return false;
        }
        self.problem_words.contains_key(&word.to_lowercase())
    }
}

impl SyllableCounter for HeuristicSyllableCounter {
    fn count_syllables(&self, word: &str) -> i64 {
        let word = word.trim();
        if word.is_empty() {
            return 0;
        }

        let lower = word.to_lowercase();

        if let Some(&n) = self.problem_words.get(&lower) {
            return n;
        }

        let clean_owned = non_letter_re().replace_all(&lower, "").into_owned();
        if clean_owned.is_empty() {
            return 1;
        }

        let mut clean = clean_owned;
        let mut affix_syllables: i64 = 0;

        for (prefix, n) in &self.prefixes {
            if let Some(stripped) = clean.strip_prefix(prefix.as_str()) {
                clean = stripped.to_string();
                affix_syllables += n;
            }
        }

        for (suffix, n) in &self.suffixes {
            if let Some(stripped) = clean.strip_suffix(suffix.as_str()) {
                clean = stripped.to_string();
                affix_syllables += n;
            }
        }

        let vowel_run_count = self.count_vowel_groups(&clean);
        let mut count = vowel_run_count + affix_syllables;

        for re in &self.subtract_patterns {
            if re.is_match(&clean).unwrap_or(false) {
                count -= 1;
            }
        }

        for re in &self.add_patterns {
            if re.is_match(&clean).unwrap_or(false) {
                count += 1;
            }
        }

        count.max(1)
    }

    fn split_syllables(&self, word: &str) -> Vec<String> {
        let count = self.count_syllables(word);

        if count <= 1 {
            return if word.is_empty() {
                Vec::new()
            } else {
                vec![word.to_string()]
            };
        }

        let chars: Vec<char> = word.chars().collect();
        let length = chars.len() as i64;

        if count >= length {
            return chars.iter().map(|c| c.to_string()).collect();
        }

        let count = count as usize;
        let length = length as usize;
        let part_len = length / count;
        let extra = length % count;
        let mut parts: Vec<String> = Vec::with_capacity(count);
        let mut pos = 0usize;

        for i in 0..count {
            let cur_len = part_len + if i < extra { 1 } else { 0 };
            let part: String = chars[pos..pos + cur_len].iter().collect();
            parts.push(part);
            pos += cur_len;
        }

        parts
    }
}