rishe 0.3.0

Rishe (ریشه): High-performance Persian NLP engine in Rust — tokenization, SBD, lemmatization, ezafe detection, informal→formal normalization
Documentation
use crate::normalize_ops::PERSIAN_LETTERS;
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
use std::sync::OnceLock;

const UNITS: [&str; 20] = [
    "صفر",
    "یک",
    "دو",
    "سه",
    "چهار",
    "پنج",
    "شش",
    "هفت",
    "هشت",
    "نه",
    "ده",
    "یازده",
    "دوازده",
    "سیزده",
    "چهارده",
    "پانزده",
    "شانزده",
    "هفده",
    "هجده",
    "نوزده",
];
const TENS: [&str; 10] = [
    "",
    "",
    "بیست",
    "سی",
    "چهل",
    "پنجاه",
    "شصت",
    "هفتاد",
    "هشتاد",
    "نود",
];
const HUNDREDS: [&str; 10] = [
    "",
    "صد",
    "دویست",
    "سیصد",
    "چهارصد",
    "پانصد",
    "ششصد",
    "هفتصد",
    "هشتصد",
    "نهصد",
];
const SCALES: [&str; 7] = [
    "",
    "هزار",
    "میلیون",
    "میلیارد",
    "تریلیون",
    "کوادریلیون",
    "کوینتیلیون",
];

pub fn number_to_words(number: i64) -> String {
    if number < 0 {
        return format!("منفی {}", unsigned_to_words(number.unsigned_abs()));
    }
    unsigned_to_words(number as u64)
}

pub fn number_to_words_text(text: &str) -> Result<String, String> {
    let normalized: String = text
        .trim()
        .chars()
        .map(|ch| match ch {
            '\u{06F0}'..='\u{06F9}' => char::from_u32(ch as u32 - 0x06F0 + 0x30).unwrap(),
            '\u{0660}'..='\u{0669}' => char::from_u32(ch as u32 - 0x0660 + 0x30).unwrap(),
            _ => ch,
        })
        .collect();

    let (negative, body) = match normalized.strip_prefix('-') {
        Some(rest) => (true, rest),
        None => (false, normalized.as_str()),
    };
    if body.is_empty() {
        return Err(format!("invalid numeric input: {text}"));
    }

    let mut parts = body.splitn(2, '.');
    let integer = parts.next().unwrap();
    let decimal = parts.next();

    if integer.is_empty() || !integer.chars().all(|ch| ch.is_ascii_digit()) {
        return Err(format!("invalid numeric input: {text}"));
    }
    let integer_value: u64 = integer
        .parse()
        .map_err(|_| format!("number out of range: {text}"))?;

    let mut words = unsigned_to_words(integer_value);
    if let Some(decimal) = decimal {
        if decimal.is_empty() || !decimal.chars().all(|ch| ch.is_ascii_digit()) {
            return Err(format!("invalid numeric input: {text}"));
        }
        let decimal_value: u64 = decimal
            .parse()
            .map_err(|_| format!("number out of range: {text}"))?;
        words = format!("{words} ممیز {}", unsigned_to_words(decimal_value));
    }
    if negative {
        words = format!("منفی {words}");
    }
    Ok(words)
}

fn unsigned_to_words(mut number: u64) -> String {
    if number == 0 {
        return UNITS[0].to_string();
    }

    let mut chunks = Vec::new();
    let mut scale = 0;
    while number > 0 {
        let chunk = (number % 1000) as u32;
        if chunk > 0 {
            let words = chunk_to_words(chunk);
            if SCALES[scale].is_empty() {
                chunks.push(words);
            } else {
                chunks.push(format!("{words} {}", SCALES[scale]));
            }
        }
        number /= 1000;
        scale += 1;
    }

    chunks.reverse();
    chunks.join(" و ")
}

fn chunk_to_words(number: u32) -> String {
    let mut parts = Vec::new();
    let hundreds = (number / 100) as usize;
    let remainder = (number % 100) as usize;

    if hundreds > 0 {
        parts.push(HUNDREDS[hundreds].to_string());
    }
    if remainder > 0 {
        if remainder < 20 {
            parts.push(UNITS[remainder].to_string());
        } else {
            parts.push(TENS[remainder / 10].to_string());
            if remainder % 10 > 0 {
                parts.push(UNITS[remainder % 10].to_string());
            }
        }
    }

    parts.join(" و ")
}

const LOANWORDS: &[(&str, &[&str])] = &[
    ("کامپیوتر", &["رایانه"]),
    ("لپ‌تاپ", &["رایانهٔ همراه"]),
    ("موبایل", &["تلفن همراه"]),
    ("ایمیل", &["رایانامه"]),
    ("سایت", &["تارنما"]),
    ("وب‌سایت", &["تارنما"]),
    ("وب سایت", &["تارنما"]),
    ("دانلود", &["بارگیری"]),
    ("آپلود", &["بارگذاری"]),
    ("فایل", &["پرونده"]),
    ("دیتا", &["داده"]),
    ("دیتابیس", &["پایگاه داده"]),
    ("سرور", &["کارساز"]),
    ("آنلاین", &["برخط"]),
    ("آفلاین", &["برون‌خط"]),
    ("سیستم", &["سامانه"]),
    ("پروژه", &["طرح"]),
    ("پروسه", &["فرایند"]),
    ("استراتژی", &["راهبرد"]),
    ("تکنولوژی", &["فناوری"]),
    ("تکنیک", &["فن"]),
    ("پروتکل", &["قرارداد"]),
    ("مانیتور", &["نمایشگر"]),
    ("کیبورد", &["صفحه‌کلید"]),
    ("پرینتر", &["چاپگر"]),
    ("اسکنر", &["پویشگر"]),
    ("پرینت", &["چاپ"]),
    ("اسکن", &["پویش"]),
    ("کپی", &["رونوشت"]),
    ("فکس", &["دورنگار"]),
    ("پروفایل", &["نمایه"]),
    ("اکانت", &["حساب"]),
    ("لاگین", &["ورود"]),
    ("پسورد", &["گذرواژه"]),
    ("یوزرنیم", &["نام کاربری"]),
    ("آپدیت", &["به‌روزرسانی"]),
    ("کنفرانس", &["همایش"]),
    ("سمینار", &["همایش"]),
    ("میتینگ", &["جلسه"]),
    ("تیم", &["گروه"]),
    ("کمپین", &["کارزار"]),
    ("برند", &["نشان تجاری"]),
    ("مارکتینگ", &["بازاریابی"]),
    ("بیزینس", &["کسب‌وکار"]),
    ("استارتاپ", &["کسب‌وکار نوپا"]),
    ("فریلنسر", &["آزادکار"]),
    ("ایده", &["اندیشه"]),
    ("ایدئولوژی", &["اندیشه‌شناسی"]),
    ("استادیوم", &["ورزشگاه"]),
    ("اورژانس", &["فوریت"]),
    ("هتل", &["مهمان‌سرا"]),
    ("پارکینگ", &["جایگاه خودرو"]),
    ("دربی", &["شهرآورد"]),
    ("فینال", &["پایانی"]),
    ("کاپ", &["جام"]),
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoanwordMatch {
    pub word: String,
    pub alternatives: Vec<String>,
    pub start: usize,
    pub end: usize,
}

static LOANWORD_MATCHER: OnceLock<AhoCorasick> = OnceLock::new();

fn loanword_matcher() -> &'static AhoCorasick {
    LOANWORD_MATCHER.get_or_init(|| {
        AhoCorasickBuilder::new()
            .match_kind(MatchKind::LeftmostLongest)
            .build(LOANWORDS.iter().map(|(word, _)| *word))
            .expect("valid Aho-Corasick patterns")
    })
}

pub fn persianize(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut last = 0;

    for found in loanword_matcher().find_iter(text) {
        if !word_boundary(text, found.start(), found.end()) {
            continue;
        }
        out.push_str(&text[last..found.start()]);
        out.push_str(LOANWORDS[found.pattern().as_usize()].1[0]);
        last = found.end();
    }
    out.push_str(&text[last..]);
    out
}

pub fn persianize_suggest(text: &str) -> Vec<LoanwordMatch> {
    persianize_matches(text)
        .into_iter()
        .map(|(word, alternatives, start, end)| LoanwordMatch {
            word: word.to_string(),
            alternatives: alternatives.iter().map(|alt| (*alt).to_string()).collect(),
            start,
            end,
        })
        .collect()
}

fn persianize_matches(text: &str) -> Vec<(&'static str, &'static [&'static str], usize, usize)> {
    let mut matches = Vec::new();
    for found in loanword_matcher().find_iter(text) {
        if !word_boundary(text, found.start(), found.end()) {
            continue;
        }
        let (word, alternatives) = LOANWORDS[found.pattern().as_usize()];
        let start = text[..found.start()].chars().count();
        let end = text[..found.end()].chars().count();
        matches.push((word, alternatives, start, end));
    }
    matches
}

fn word_boundary(text: &str, start: usize, end: usize) -> bool {
    let before = text[..start]
        .chars()
        .next_back()
        .map_or(true, |ch| !is_word_char(ch));
    let after = text[end..]
        .chars()
        .next()
        .map_or(true, |ch| !is_word_char(ch));
    before && after
}

fn is_word_char(ch: char) -> bool {
    PERSIAN_LETTERS.contains(ch) || ch == '\u{200C}' || ch.is_ascii_alphanumeric()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn converts_integers() {
        assert_eq!(number_to_words(0), "صفر");
        assert_eq!(number_to_words(1), "یک");
        assert_eq!(number_to_words(19), "نوزده");
        assert_eq!(number_to_words(21), "بیست و یک");
        assert_eq!(number_to_words(100), "صد");
        assert_eq!(number_to_words(101), "صد و یک");
        assert_eq!(number_to_words(999), "نهصد و نود و نه");
        assert_eq!(number_to_words(1000), "یک هزار");
        assert_eq!(number_to_words(1234), "یک هزار و دویست و سی و چهار");
        assert_eq!(number_to_words(1_000_000), "یک میلیون");
        assert_eq!(number_to_words(-5), "منفی پنج");
    }

    #[test]
    fn converts_numeric_text() {
        assert_eq!(
            number_to_words_text("۱۴۰۴").unwrap(),
            "یک هزار و چهارصد و چهار"
        );
        assert_eq!(number_to_words_text("3.14").unwrap(), "سه ممیز چهارده");
        assert_eq!(number_to_words_text("-0.5").unwrap(), "منفی صفر ممیز پنج");
        assert!(number_to_words_text("12a").is_err());
        assert!(number_to_words_text("1.").is_err());
    }

    #[test]
    fn persianizes_known_loanwords() {
        assert_eq!(persianize("فایل را کامپیوتر کن"), "پرونده را رایانه کن");
        assert_eq!(persianize("کامپیوتری"), "کامپیوتری");
        let matches = persianize_suggest("فایل و کامپیوتر");
        assert_eq!(matches.len(), 2);
        assert_eq!(matches[0].word, "فایل");
        assert_eq!(matches[0].alternatives, ["پرونده"]);
        assert_eq!((matches[0].start, matches[0].end), (0, 4));
    }
}