madi 0.1.1

madi(마디): pure Rust Korean morphological analyzer — library entry point (Engine, sentence splitting)
Documentation
//! 형태소 분석 결과를 쓰는 문장 분리 (평가 2차 H, 2026-10-13; 검토 `madi-review-eval2.md` §3).
//!
//! 끊는 자리:
//! 1. 종결 부호(. ? ! … 와 그 연속) 다음에 공백·줄끝 — 예전 규칙(`text::split_sentences`)과 같다.
//!    숫자 사이 마침표(3.5)와 닫는 따옴표·괄호가 바로 붙은 부호("왜?" 하고)는 끊지 않는다.
//! 2. **종결 어미(EF)로 끝난 어절** 다음 — 부호 없이 끝나는 블로그·트윗·구어 문장.
//!    어절 끝에 붙은 요/JX, 기호·이모티콘(~ ^^ ㅋㅋ)은 그 어절에 넣는다.
//! 괄호·따옴표 안(깊이 > 0)에서는 끊지 않는다(인용 안의 여러 문장은 바깥 문장 하나 — Kiwi 셋의 nested 와 같은 관행).
//! 줄바꿈은 늘 끊는다.
use madi_core::{Tag, Token};

const OPEN: &[char] = &['(', '[', '{', '“', '‘', '「', '『', '《', '〈', '【', '〔'];
const CLOSE: &[char] = &[')', ']', '}', '”', '’', '」', '』', '》', '〉', '】', '〕'];
const STRAIGHT: &[char] = &['"', '\''];

/// 글자마다 괄호·따옴표 깊이 (그 글자 *뒤* 상태).
/// 곧은따옴표(" ')는 앞에서부터 둘씩 짝지어(짝 없는 마지막 것은 버림) 여는/닫는 것으로 본다.
/// 홑따옴표가 로마자·숫자 사이(don't, '90)에 끼면 따옴표가 아니다.
fn depths(chars: &[char]) -> Vec<i32> {
    let n = chars.len();
    let mut delta = vec![0i32; n];
    for (i, &c) in chars.iter().enumerate() {
        if OPEN.contains(&c) || c == '<' {
            delta[i] = 1;
        } else if CLOSE.contains(&c) || c == '>' {
            delta[i] = -1;
        }
    }
    for (k, &q) in STRAIGHT.iter().enumerate() {
        let pos: Vec<usize> = (0..n)
            .filter(|&i| chars[i] == q)
            .filter(|&i| !(k == 1 && i > 0 && chars[i - 1].is_ascii_alphanumeric() && chars.get(i + 1).is_some_and(|x| x.is_ascii_alphanumeric())))
            .collect();
        for pair in pos.chunks(2) {
            if pair.len() == 2 {
                delta[pair[0]] = 1;
                delta[pair[1]] = -1;
            }
        }
    }
    let mut d = 0i32;
    delta
        .into_iter()
        .map(|x| {
            d = (d + x).max(0);
            d
        })
        .collect()
}

/// 꾸밈 글자: 자모(ㅋㅎㅠ), 이모티콘·기호(^ ~ ♡ 😊 …) — 글자·숫자·문장 부호가 아닌 것
fn is_decor(c: char) -> bool {
    ('\u{3131}'..='\u{3163}').contains(&c) || (!c.is_alphanumeric() && !c.is_whitespace() && !is_term(c) && !OPEN.contains(&c) && !CLOSE.contains(&c) && !STRAIGHT.contains(&c) && c != ',' && c != ':')
}

fn is_term(c: char) -> bool {
    matches!(c, '.' | '?' | '!' | '…' | '。' | '?' | '!')
}

/// 한 줄(문단)을 문장 구간(글자 위치 [s, e))으로. `toks` 는 같은 줄을 분석한 결과(글자 위치)
pub fn split_line(line: &str, toks: &[Token]) -> Vec<(usize, usize)> {
    let chars: Vec<char> = line.chars().collect();
    let n = chars.len();
    let dep = depths(&chars);
    let depth_at = |i: usize| if i == 0 { 0 } else { dep[i - 1] };
    let mut cuts: Vec<usize> = Vec::new(); // 다음 문장이 시작하는 글자 위치
    // s2 에서 시작하는 어절이 앞말을 잇는 말인가 (인용 조사·서술격·'하고/하며'·연결 어미·라며)
    let cont_at = |s2: usize| {
        toks.iter().find(|t| t.start >= s2).is_some_and(|t| {
            matches!(t.tag, Tag::JKQ | Tag::VCP | Tag::EC | Tag::ETM | Tag::VX)
                || (matches!(t.tag, Tag::VV | Tag::VX) && t.form == "하")
        })
    };
    // 글자 i 앞에서 끝나는 마지막 '말' 토큰(기호 아닌 것)의 태그
    let last_word_tag = |i: usize| toks.iter().rev().find(|t| t.end <= i && !matches!(t.tag, Tag::SF | Tag::SE | Tag::SP | Tag::SS | Tag::SO | Tag::SW)).map(|t| t.tag);

    // 1) 종결 부호
    let mut i = 0;
    while i < n {
        if is_term(chars[i]) {
            let mut j = i + 1;
            while j < n && is_term(chars[j]) {
                j += 1;
            }
            let quoted = j < n && (CLOSE.contains(&chars[j]) || STRAIGHT.contains(&chars[j]) || chars[j] == '>');
            let digit_dot = chars[i] == '.' && j == i + 1 && i > 0 && chars[i - 1].is_ascii_digit() && j < n && chars[j].is_ascii_digit();
            // 말줄임(… 또는 점 둘 이상)은 종결 어미 뒤에서만 문장 끝으로 본다 ('때문에... 대충' 은 이어짐)
            let ellipsis = chars[i..j].iter().any(|&c| c == '…') || chars[i..j].iter().filter(|&&c| c == '.').count() >= 2;
            // 말줄임은 앞말이 조사로 끝나면('때문에...') 잇는 것으로, 그 밖(어미·명사·요)은 끝으로
            let ell_ok = !ellipsis
                || !matches!(last_word_tag(i), Some(Tag::JKS | Tag::JKC | Tag::JKG | Tag::JKO | Tag::JKB | Tag::JKV | Tag::JKQ | Tag::JC) | None);
            // 목록 번호 '15. ' '3) ' 는 끊지 않는다 (문장 시작부터 i 까지가 숫자뿐)
            let mut w0 = i;
            while w0 > 0 && !chars[w0 - 1].is_whitespace() {
                w0 -= 1;
            }
            let list_no = chars[i] == '.' && j == i + 1 && i > w0 && i - w0 <= 2 && chars[w0..i].iter().all(|c| c.is_ascii_digit());
            let mut s1 = j;
            while s1 < n && chars[s1].is_whitespace() {
                s1 += 1;
            }
            if (j >= n || chars[j].is_whitespace()) && !quoted && !digit_dot && depth_at(i) == 0 && ell_ok && !list_no && (s1 >= n || !cont_at(s1)) {
                // 뒤 어절이 꾸밈(^^ ㅎㅎ 😊)뿐이면 그것까지
                let mut e1 = s1;
                while e1 < n && !chars[e1].is_whitespace() && is_decor(chars[e1]) {
                    e1 += 1;
                }
                if e1 > s1 && (e1 >= n || chars[e1].is_whitespace()) {
                    cuts.push(e1);
                } else {
                    cuts.push(j);
                }
            } else if j < n && !quoted && !digit_dot && depth_at(i) == 0 && chars[j].is_alphabetic() && !chars[j].is_ascii()
                && matches!(last_word_tag(i), Some(Tag::EF)) && !cont_at(j) {
                // 띄어 쓰지 않은 문장 경계 '…하였다.그리하여' — 종결 어미 + 부호 바로 뒤 한글
                cuts.push(j);
            } else if quoted {
                // 부호 + 닫는 따옴표·괄호들 뒤에 공백이 오고 바깥(깊이 0)이 되면, 다음 어절이 인용을 잇는 말(하며·하고·라고 …)이 아닐 때 끊는다
                let mut m = j;
                while m < n && (CLOSE.contains(&chars[m]) || STRAIGHT.contains(&chars[m]) || chars[m] == '>') {
                    m += 1;
                }
                if (m >= n || chars[m].is_whitespace()) && dep[m - 1] == 0 {
                    let mut s2 = m;
                    while s2 < n && chars[s2].is_whitespace() {
                        s2 += 1;
                    }
                    if !cont_at(s2) && s2 > m {
                        cuts.push(m);
                    }
                }
            }
            i = j;
            continue;
        }
        i += 1;
    }

    // 2) 종결 어미로 끝난 어절
    // 어절 = space_before 로 나뉜 토큰 묶음
    let mut groups: Vec<(usize, usize)> = Vec::new(); // 토큰 [a, b)
    let mut a = 0;
    for k in 1..=toks.len() {
        if k == toks.len() || toks[k].space_before {
            if a < k {
                groups.push((a, k));
            }
            a = k;
        }
    }
    for (gi, &(a, b)) in groups.iter().enumerate() {
        if gi + 1 == groups.len() {
            break; // 줄 끝은 어차피 끝
        }
        // 끝의 기호·이모티콘·요/JX 를 건너 마지막 '말' 토큰
        let mut k = b;
        let mut saw_yo = false;
        while k > a {
            let t = &toks[k - 1];
            let jamo = !t.form.is_empty() && t.form.chars().all(|c| ('\u{3131}'..='\u{3163}').contains(&c));   // ㅋㅋ ㅎㅎ ㅠㅠ
            let trailing = matches!(t.tag, Tag::SW | Tag::SO | Tag::SE | Tag::SS | Tag::SP | Tag::NA | Tag::NF | Tag::NV)
                || (t.tag == Tag::JX && t.form == "요")
                || (k - 1 > a && matches!(t.form.as_str(), "용" | "염" | "욤" | "여") && !matches!(t.tag, Tag::EF | Tag::EC))   // 잘됐네용 → 네/EF 용/XSN
                || (jamo && k - 1 > a);
            if !trailing {
                break;
            }
            // 따옴표·괄호 닫힘이 끝에 있으면 인용 끝, 쉼표면 나열 — 끊지 않는다 ("…했다" 하며 / 물려받다, 또는)
            if t.tag == Tag::SS || (t.tag == Tag::SP && t.form != "~") {
                k = a;
                break;
            }
            if t.tag == Tag::JX && t.form == "요" {
                saw_yo = true;
            }
            k -= 1;
        }
        if k <= a {
            continue;
        }
        let lt = &toks[k - 1];
        // 문장 안에 박히기 쉬운 종결형(뭔가·했는지·…는데 그건)은 부호 없이는 끊지 않는다
        // 구어·방언 종결형인데 EC 로 읽히는 것(는구먼·니께·드라구·걸랑 …)
        let ec_final = lt.tag == Tag::EC
            && ["구먼", "구만", "니께", "니까요", "드라구", "더라구", "라구", "걸랑", "거든", "잖아", "쥬", "죠"].iter().any(|x| lt.form.ends_with(x));
        // 연결 어미 + 요 (아니구요·많구요·했고요) 는 높임 종결
        let ec_yo = saw_yo && lt.tag == Tag::EC;
        let ef = (lt.tag == Tag::EF || ec_final || ec_yo)
            && !matches!(lt.form.as_str(), "ㄴ가" | "는가" | "은가" | "ㄴ지" | "는지" | "은지" | "ㄹ지" | "을지")
            && !lt.form.ends_with('데');
        // 명사형 종결(블로그 '맛있음', '대비용임'): 용언·서술격 뒤 ㅁ/음/ETN 이 어절 끝
        let etn = lt.tag == Tag::ETN && matches!(lt.form.as_str(), "ㅁ" | "음") && k - 1 > a && matches!(toks[k - 2].tag, Tag::VV | Tag::VA | Tag::VX | Tag::VCP | Tag::EP | Tag::XSV | Tag::XSA);
        if !(ef || etn) {
            continue;
        }
        let end = toks[b - 1].end.max(toks[k - 1].end).min(n);
        // 이미 부호 규칙으로 끊긴 자리면 그대로
        if end < n && is_term(chars[end.saturating_sub(1)]) {
            continue;
        }
        // 다음 어절이 인용·연결로 이어지면(고/JKQ·며·라고 … — 띄어 쓴 경우) 끊지 않는다
        let (na, _) = groups[gi + 1];
        if cont_at(toks[na].start) {
            continue;
        }
        // 다음 어절이 기호·자모·이모티콘뿐이면(ㅎㅎ, ?, 😊) 그것까지 이 문장에 — 부호면 부호 규칙에 맡긴다
        let decor = |g: (usize, usize)| {
            toks[g.0..g.1].iter().all(|t| {
                matches!(t.tag, Tag::SF | Tag::SE | Tag::SW | Tag::SO | Tag::SS | Tag::SP | Tag::NA | Tag::NF | Tag::NV)
                    || (!t.form.is_empty() && t.form.chars().all(|c| ('\u{3131}'..='\u{3163}').contains(&c)))
            })
        };
        let mut b = b;
        let mut gj = gi + 1;
        let mut has_sp = false;
        while gj < groups.len() && decor(groups[gj]) {
            has_sp |= toks[groups[gj].0..groups[gj].1].iter().any(|t| t.tag == Tag::SP && t.form != "~");
            b = groups[gj].1;
            gj += 1;
        }
        if has_sp {
            continue; // '헌터보는나 :' 처럼 쌍점·쉼표가 따로 오면 끝이 아니다
        }
        if gj > gi + 1 && toks[b - 1].end <= n && is_term(chars[toks[b - 1].end.saturating_sub(1)]) {
            continue; // 뒤에 띄어 쓴 부호(아인교 ?)가 문장을 닫는다
        }
        let end = end.max(toks[b - 1].end.min(n));
        if depth_at(end.min(n)) != 0 {
            continue;
        }
        // 공백 뒤 다음 글자 위치
        let mut s = end;
        while s < n && chars[s].is_whitespace() {
            s += 1;
        }
        if s > end {
            cuts.push(end);
        }
    }
    cuts.sort_unstable();
    cuts.dedup();

    let mut out = Vec::new();
    let mut start = 0;
    for c in cuts.into_iter().chain(std::iter::once(n)) {
        let (mut s, mut e) = (start, c.min(n));
        while s < e && chars[s].is_whitespace() {
            s += 1;
        }
        while e > s && chars[e - 1].is_whitespace() {
            e -= 1;
        }
        if s < e {
            out.push((s, e));
        }
        start = c;
    }
    out
}