madi 0.1.1

madi(마디): pure Rust Korean morphological analyzer — library entry point (Engine, sentence splitting)
Documentation
//! 문서 전처리: 보일러플레이트 규칙(정제·문장 버리기), 문장 분리, 토큰 줄 형식.
//! `madi corpus` 와 `madi::Engine` 이 같은 코드를 쓴다 (10.6, CLI 에서 옮김).

use anyhow::{Context, Result};
use madi_core::Token;
use std::path::Path;

/// 기본 보일러플레이트 규칙 원문 (`tools/boilerplate.txt`)
/// 크레이트 안의 사본 — `tools/boilerplate.txt` 와 같아야 한다(버전 문자열 `.s` 해시)
pub const BUILTIN_SKIP: &str = include_str!("../boilerplate.txt");

/// 보일러플레이트 줄 걸러내기 규칙 (10.3). 파일의 한 줄 = 규칙 하나, `#` 은 주석.
/// - `-문자열` : 본문에서 그 문자열을 모두 지운다 (마크다운 `**` 등)
/// - `|문자열` : 그 문자열을 줄바꿈으로 바꾼다 (`*` 로 본문에 붙은 바이라인·사진 설명을 제 줄로)
/// - 그 외 : 조건들을 ` && ` 로 이어 쓴다. 모두 맞는 문장은 버린다
///   `^머리` 로 시작, `꼬리$` 로 끝, `~부분` 포함, `=전체` 일치, `len<N` 글자 수 N 미만
#[derive(Default, Clone)]
pub struct SkipRules {
    rules: Vec<Vec<Cond>>,
    remove: Vec<String>,
    split: Vec<String>,
}

#[derive(Clone)]
enum Cond {
    Prefix(String),
    Suffix(String),
    Contains(String),
    Exact(String),
    LenLt(usize),
}

impl SkipRules {
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?;
        Self::parse(&text, &path.display().to_string())
    }

    /// 기본 규칙 (`tools/boilerplate.txt` 를 빌드 때 넣은 것)
    pub fn builtin() -> Self {
        Self::parse(BUILTIN_SKIP, "builtin").expect("builtin boilerplate rules")
    }

    /// 규칙 텍스트 → 규칙. `origin` 은 오류 메시지용 이름
    pub fn parse(text: &str, origin: &str) -> Result<Self> {
        let mut r = SkipRules::default();
        for (ln, raw) in text.lines().enumerate() {
            let line = raw.trim_start_matches('\u{FEFF}').trim_end();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some(rest) = line.strip_prefix('-') {
                if !rest.is_empty() {
                    r.remove.push(rest.to_string());
                }
                continue;
            }
            if let Some(rest) = line.strip_prefix('|') {
                if !rest.is_empty() {
                    r.split.push(rest.to_string());
                }
                continue;
            }
            let mut conds = Vec::new();
            for part in line.split(" && ") {
                let part = part.trim();
                if part.is_empty() {
                    continue;
                }
                let c = if let Some(n) = part.strip_prefix("len<") {
                    Cond::LenLt(n.parse().with_context(|| format!("{}:{}: len<N", origin, ln + 1))?)
                } else if let Some(x) = part.strip_prefix('^') {
                    Cond::Prefix(x.to_string())
                } else if let Some(x) = part.strip_prefix('~') {
                    Cond::Contains(x.to_string())
                } else if let Some(x) = part.strip_prefix('=') {
                    Cond::Exact(x.to_string())
                } else if let Some(x) = part.strip_suffix('$') {
                    Cond::Suffix(x.to_string())
                } else {
                    anyhow::bail!("{}:{}: 규칙 형식 오류: {}", origin, ln + 1, part);
                };
                conds.push(c);
            }
            if !conds.is_empty() {
                r.rules.push(conds);
            }
        }
        Ok(r)
    }

    pub fn len(&self) -> usize {
        self.rules.len() + self.remove.len() + self.split.len()
    }

    /// 본문 전처리: BOM·zero-width 제거, `-` 규칙 문자열 삭제
    pub fn clean(&self, text: &str) -> String {
        let mut t: String = text.chars().filter(|c| !matches!(c, '\u{FEFF}' | '\u{200B}' | '\u{200C}' | '\u{200D}')).collect();
        for r in &self.remove {
            if t.contains(r.as_str()) {
                t = t.replace(r.as_str(), "");
            }
        }
        for r in &self.split {
            if t.contains(r.as_str()) {
                t = t.replace(r.as_str(), "\n");
            }
        }
        t
    }

    /// 이 문장을 버릴까
    pub fn skip(&self, sent: &str) -> bool {
        let s = sent.trim();
        let mut n_chars: Option<usize> = None;
        'rule: for conds in &self.rules {
            for c in conds {
                let ok = match c {
                    Cond::Prefix(x) => s.starts_with(x.as_str()),
                    Cond::Suffix(x) => s.ends_with(x.as_str()),
                    Cond::Contains(x) => s.contains(x.as_str()),
                    Cond::Exact(x) => s == x,
                    Cond::LenLt(n) => *n_chars.get_or_insert_with(|| s.chars().count()) < *n,
                };
                if !ok {
                    continue 'rule;
                }
            }
            return true;
        }
        false
    }
}

/// 간단한 문장 분리: 줄 단위로 나눈 뒤, 종결 부호(. ? ! …) 다음에 공백·닫는 따옴표·줄끝이 오면 끊는다.
/// 숫자 사이의 . (3.5) 와 줄임표 안은 끊지 않는다.
pub fn split_sentences(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let chars: Vec<char> = line.chars().collect();
        let mut start = 0;
        let n = chars.len();
        let mut i = 0;
        while i < n {
            let c = chars[i];
            if matches!(c, '.' | '?' | '!' | '…') {
                // 연속 부호를 한 덩어리로
                let mut j = i + 1;
                while j < n && matches!(chars[j], '.' | '?' | '!' | '…') {
                    j += 1;
                }
                // 닫는 따옴표·괄호가 바로 따라오면 인용 안의 종결로 보고 끊지 않는다 ("왜?" 하고 물었다)
                let quoted = j < n && matches!(chars[j], '"' | '”' | '’' | '\'' | ')' | ']' | '」' | '』' | '》' | '〉');
                let prev_digit = i > 0 && chars[i - 1].is_ascii_digit();
                let next_digit = j < n && chars[j].is_ascii_digit();
                let at_end = j >= n;
                let next_space = j < n && chars[j].is_whitespace();
                if (at_end || next_space) && !quoted && !(c == '.' && prev_digit && next_digit) {
                    let s: String = chars[start..j].iter().collect();
                    let s = s.trim();
                    if !s.is_empty() {
                        out.push(s.to_string());
                    }
                    start = j;
                }
                i = j.max(i + 1);
                continue;
            }
            i += 1;
        }
        if start < n {
            let s: String = chars[start..].iter().collect();
            let s = s.trim();
            if !s.is_empty() {
                out.push(s.to_string());
            }
        }
    }
    out
}

pub fn format_tokens(toks: &[Token]) -> String {
    let mut s = String::new();
    for (i, t) in toks.iter().enumerate() {
        if i > 0 {
            if t.space_before {
                s.push_str(" | ");
            } else {
                s.push(' ');
            }
        }
        s.push_str(&t.form.replace(' ', "_"));
        s.push('/');
        s.push_str(t.tag.code());
    }
    s
}