Skip to main content

fandhe_ai/text/
split.rs

1//! 文字列の分割(空白・文字・なし)の内部実装。
2//!
3//! 役割: 標準化済みの文字列をトークン列へ分割する。呼び出し元は n-gram(#2899)・
4//! adapt(#2901)・transform(#2902)。定義の正は
5//! `docs/facade-text-vectorization-design.md` §3。
6//!
7//! - `Whitespace`: ASCII 空白(SP・TAB・LF・FF・CR)で区切る。VT・NBSP・全角空白は
8//!   区切りにならない。連続空白で空トークンは出ない
9//! - `UnicodeWhitespace`: `str::split_whitespace`(Unicode White_Space)で区切る。
10//!   全角空白・NBSP・VT・NEL・U+2028 で区切り、ZWSP(U+200B)では区切らない
11//! - `Character`: Unicode スカラー値(`char`)単位。書記素クラスタ単位ではなく、
12//!   結合文字・ZWJ 絵文字は複数トークンに分かれる
13//! - `None`: 入力全体を常に 1 トークンにする。空入力でも空文字列の 1 トークンを
14//!   返す(設計記録に空入力の特記がないための素直な解釈)。空文字列はパディング
15//!   予約語と一致するため、lookup 段(#2900・#2902)での扱いは後続で決める
16//!
17//! 戻り値は入力を借用するイテレータでトークンごとの確保をしない。上限検査
18//! (`TextLimits`)は呼び出し側が入口で先に行う前提(設計記録 §8)。論点 3 の
19//! ASCII 部分は承認済みで、Unicode White_Space 分割は #3089 で opt-in の
20//! `UnicodeWhitespace` として追加した(設計記録 §18。既定は不変)。
21//! 失敗しない純関数。公開型 `Split` は `fandhe_ai::text` へ再エクスポートされる
22//! (§16.2)。自由関数 `split` と `SplitIter` は非公開のまま。
23
24use std::str::{CharIndices, SplitAsciiWhitespace, SplitWhitespace};
25
26/// 分割の方式。既定の `Whitespace` の空白判定は ASCII に限る。
27///
28/// Unicode White_Space(全角空白・NBSP 等)での分割は `UnicodeWhitespace` を
29/// opt-in で選ぶ。`#[non_exhaustive]` のため variant の追加は非破壊。
30#[non_exhaustive]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Split {
33    /// 分割しない(入力全体を 1 トークン)。空入力 `""` も 1 トークン(id 0)になる。
34    None,
35    /// ASCII 空白(SP・TAB・LF・FF・CR)だけで分割する(既定)。連続空白で空
36    /// トークンは出ない。Unicode White_Space(全角空白・NBSP 等)では分割しない。
37    #[default]
38    Whitespace,
39    /// `char`(Unicode スカラー値)単位で分割する。書記素クラスタ単位ではなく、
40    /// 結合文字や ZWJ 絵文字は複数トークンに分かれる。
41    Character,
42    /// Unicode White_Space(`char::is_whitespace`)で分割する。全角空白・NBSP・VT・
43    /// NEL・U+2028 で区切る。ZWSP(U+200B)は White_Space ではないので区切らない。
44    /// 連続空白で空トークンは出ない。
45    UnicodeWhitespace,
46}
47
48/// `split` が返すトークンのイテレータ(入力の部分スライスを返す)。
49pub(crate) enum SplitIter<'a> {
50    /// `Split::None`: 未返却なら全体を 1 回だけ返す。
51    Whole(Option<&'a str>),
52    /// `Split::Whitespace`。
53    Whitespace(SplitAsciiWhitespace<'a>),
54    /// `Split::UnicodeWhitespace`。
55    UnicodeWhitespace(SplitWhitespace<'a>),
56    /// `Split::Character`: 元文字列とバイト位置付きの文字列走査。
57    Character(&'a str, CharIndices<'a>),
58}
59
60impl<'a> Iterator for SplitIter<'a> {
61    type Item = &'a str;
62
63    fn next(&mut self) -> Option<&'a str> {
64        match self {
65            SplitIter::Whole(rest) => rest.take(),
66            SplitIter::Whitespace(it) => it.next(),
67            SplitIter::UnicodeWhitespace(it) => it.next(),
68            SplitIter::Character(src, it) => {
69                let (i, c) = it.next()?;
70                // `char_indices` の境界は常に文字境界で、`get` は失敗しない。
71                src.get(i..i + c.len_utf8())
72            }
73        }
74    }
75}
76
77/// `input` を `mode` に従ってトークンへ分割する。
78pub(crate) fn split(input: &str, mode: Split) -> SplitIter<'_> {
79    match mode {
80        Split::None => SplitIter::Whole(Some(input)),
81        Split::Whitespace => SplitIter::Whitespace(input.split_ascii_whitespace()),
82        Split::UnicodeWhitespace => SplitIter::UnicodeWhitespace(input.split_whitespace()),
83        Split::Character => SplitIter::Character(input, input.char_indices()),
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn toks(s: &str, m: Split) -> Vec<&str> {
92        split(s, m).collect()
93    }
94
95    fn is_ws(c: char) -> bool {
96        c.is_ascii_whitespace()
97    }
98
99    fn all_strings() -> Vec<String> {
100        let alphabet = ['a', 'é', ' ', '\t', '\n', '\u{3000}', '\u{A0}', '日'];
101        let mut all = vec![String::new()];
102        let mut frontier = vec![String::new()];
103        for _ in 0..4 {
104            let mut next = Vec::new();
105            for s in &frontier {
106                for c in alphabet {
107                    let mut t = s.clone();
108                    t.push(c);
109                    next.push(t);
110                }
111            }
112            all.extend(next.iter().cloned());
113            frontier = next;
114        }
115        all
116    }
117
118    #[test]
119    fn 空白分割() {
120        assert_eq!(toks("  a  b\tc\n", Split::Whitespace), ["a", "b", "c"]);
121        assert!(toks("", Split::Whitespace).is_empty());
122        assert!(toks(" \t\n", Split::Whitespace).is_empty());
123        assert_eq!(toks("a\u{3000}b", Split::Whitespace), ["a\u{3000}b"]);
124        assert_eq!(toks("a\u{00A0}b", Split::Whitespace), ["a\u{00A0}b"]);
125        assert_eq!(toks("a\u{0B}b", Split::Whitespace), ["a\u{0B}b"]);
126        assert_eq!(toks("日本 語", Split::Whitespace), ["日本", "語"]);
127    }
128
129    #[test]
130    fn 文字分割() {
131        assert_eq!(toks("aé日", Split::Character), ["a", "é", "日"]);
132        assert!(toks("", Split::Character).is_empty());
133        assert_eq!(toks("e\u{301}", Split::Character).len(), 2);
134        assert_eq!(toks("👨\u{200D}👩", Split::Character).len(), 3);
135        assert_eq!(toks("a b", Split::Character), ["a", " ", "b"]);
136    }
137
138    #[test]
139    fn 分割なし() {
140        assert_eq!(toks("a b", Split::None), ["a b"]);
141        assert_eq!(toks("", Split::None), [""]);
142        assert_eq!(toks(" \t", Split::None), [" \t"]);
143    }
144
145    #[test]
146    fn 長さ不変条件() {
147        for s in all_strings() {
148            let ch = toks(&s, Split::Character);
149            assert_eq!(ch.len(), s.chars().count());
150            assert!(ch.iter().all(|t| t.chars().count() == 1));
151            assert_eq!(ch.concat(), s);
152            assert_eq!(ch.iter().map(|t| t.len()).sum::<usize>(), s.len());
153
154            let ws = toks(&s, Split::Whitespace);
155            assert!(ws.iter().all(|t| !t.is_empty() && !t.contains(is_ws)));
156            let stripped: String = s.chars().filter(|c| !is_ws(*c)).collect();
157            assert_eq!(ws.concat(), stripped);
158            assert!(ws.iter().map(|t| t.len()).sum::<usize>() <= s.len());
159
160            assert_eq!(toks(&s, Split::None), [s.as_str()]);
161        }
162    }
163
164    #[test]
165    fn unicode_空白分割() {
166        let u = |s| toks(s, Split::UnicodeWhitespace);
167        assert_eq!(u("a\u{3000}b"), ["a", "b"]);
168        assert_eq!(u("a\u{00A0}b"), ["a", "b"]);
169        assert_eq!(u("a\u{0B}b"), ["a", "b"]);
170        assert!(u("").is_empty());
171        assert!(u("\u{3000} \u{A0}").is_empty());
172        assert_eq!(u("a\u{200B}b"), ["a\u{200B}b"]);
173    }
174
175    #[test]
176    fn unicode_空白分割の不変条件() {
177        for s in all_strings() {
178            let ws = toks(&s, Split::UnicodeWhitespace);
179            assert!(
180                ws.iter()
181                    .all(|t| !t.is_empty() && !t.contains(char::is_whitespace))
182            );
183            let stripped: String = s.chars().filter(|c| !c.is_whitespace()).collect();
184            assert_eq!(ws.concat(), stripped);
185        }
186    }
187
188    #[test]
189    fn 既定値は空白() {
190        assert_eq!(Split::default(), Split::Whitespace);
191    }
192}