readsight 1.0.2

Multilingual readability library — 86 languages, 17 formulas, TeX-based syllable counting via the Frank M. Liang algorithm.
Documentation
//! Parser for hyph-utf8 `.tex` pattern files.

use super::exceptions::{HyphenationExceptionsCollection, HyphenationOverride};
use super::pattern::{Pattern, PatternsCollection};

/// The result of parsing a `.tex` file.
pub struct LoadedPatterns {
    /// The parsed patterns.
    pub patterns: PatternsCollection,
    /// The parsed hyphenation exceptions.
    pub exceptions: HyphenationExceptionsCollection,
    /// Length of the longest pattern (in characters).
    pub max_pattern_length: usize,
}

/// Match `^\\([a-zA-Z]+)` on `rest`. Returns `(total_match_byte_len, command)`.
fn match_command(rest: &str) -> Option<(usize, String)> {
    let bytes = rest.as_bytes();
    debug_assert_eq!(bytes.first(), Some(&b'\\'));
    let mut i = 1;
    while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
        i += 1;
    }
    if i == 1 {
        None
    } else {
        Some((i, rest[1..i].to_string()))
    }
}

/// Match `^\S+` on `rest`. Returns the byte length of the token, or 0.
///
/// PHP's `preg_match('/^(\S+)/u', ...)` enables UTF-8 (`PCRE2_UTF`) but not
/// `PCRE2_UCP`, so `\s`/`\S` remain ASCII-only. We mirror that: a token ends at
/// the first ASCII whitespace character.
fn match_token_len(rest: &str) -> usize {
    let mut end = 0;
    for ch in rest.chars() {
        if matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}') {
            break;
        }
        end += ch.len_utf8();
    }
    end
}

/// Parse the contents of a `.tex` hyphenation file.
pub fn parse_tex(contents: &str) -> LoadedPatterns {
    let mut patterns = PatternsCollection::new();
    let mut exceptions = HyphenationExceptionsCollection::new();

    let mut command: Option<String> = None;
    let mut in_braces = false;

    for line in contents.lines() {
        let bytes = line.as_bytes();
        let mut offset = 0usize;

        while offset < bytes.len() {
            let b = bytes[offset];

            if b == b'%' && !in_braces {
                break;
            }

            if b == b'\\' && !in_braces {
                if let Some((end, cmd)) = match_command(&line[offset..]) {
                    command = Some(cmd);
                    offset += end;
                    continue;
                }
                offset += 1;
                continue;
            }

            if b == b'{' {
                if command.is_some() {
                    in_braces = true;
                }
                offset += 1;
                continue;
            }

            if b == b'}' && in_braces {
                in_braces = false;
                command = None;
                offset += 1;
                continue;
            }

            if in_braces {
                match command.as_deref() {
                    Some("patterns") => {
                        let rest = &line[offset..];
                        let end = match_token_len(rest);
                        if end > 0 {
                            let token = &rest[..end];
                            if let Some(pattern) = parse_pattern_token(token) {
                                patterns.add(pattern);
                            }
                            offset += end;
                            continue;
                        }
                    }
                    Some("hyphenation") => {
                        let rest = &line[offset..];
                        let end = match_token_len(rest);
                        if end > 0 {
                            let token = &rest[..end];
                            let word = token.replace('-', "").to_lowercase();
                            let hyphenated = token.to_lowercase();
                            exceptions.add(HyphenationOverride::new(word, hyphenated));
                            offset += end;
                            continue;
                        }
                    }
                    _ => {}
                }
            }

            offset += 1;
        }
    }

    let max_pattern_length = patterns.max_length();
    LoadedPatterns {
        patterns,
        exceptions,
        max_pattern_length,
    }
}

/// Parse a single pattern token (e.g. `a2ch`) into a [`Pattern`].
///
/// Returns `None` if the token contains no characters or no digit.
pub fn parse_pattern_token(token: &str) -> Option<Pattern> {
    let mut chars: Vec<String> = Vec::new();
    let mut numbers = String::new();
    let mut expect_number = true;
    let mut has_digit = false;

    for ch in token.chars() {
        if ch.is_ascii_digit() {
            numbers.push(ch);
            has_digit = true;
            expect_number = false;
        } else {
            if expect_number {
                numbers.push('0');
            }
            chars.push(ch.to_string());
            expect_number = true;
        }
    }

    if expect_number {
        numbers.push('0');
    }

    if chars.is_empty() || !has_digit {
        return None;
    }

    let weights: Vec<i32> = numbers
        .chars()
        .map(|d| d.to_digit(10).unwrap() as i32)
        .collect();

    Some(Pattern::new(chars, weights))
}