use icu_segmenter::LineSegmenter;
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
thread_local! {
static SEGMENTER: LineSegmenter = super::with_data_provider(|provider| {
LineSegmenter::try_new_auto_with_buffer_provider(provider, Default::default()).expect(
"src/i18n/data/icu_data.blob is committed and regenerated by \
scripts/make_icu_data.sh; a load failure here means its MARKERS \
no longer cover icu_segmenter (SegmenterBreakLineV1, \
SegmenterBreakGraphemeClusterV1, SegmenterLstmAutoV1)",
)
});
}
pub fn break_offsets(text: &str) -> Vec<usize> {
if text.is_empty() {
return Vec::new();
}
let mut offsets: Vec<usize> =
SEGMENTER.with(|segmenter| segmenter.as_borrowed().segment_str(text).skip(1).collect());
tailor_hyphen_digit_breaks(text, &mut offsets);
offsets
}
fn tailor_hyphen_digit_breaks(text: &str, offsets: &mut Vec<usize>) {
let mut prev: Option<char> = None;
let mut added = false;
let mut iter = text.char_indices().peekable();
while let Some((i, c)) = iter.next() {
if c == '-'
&& prev.is_some_and(|p| p.is_alphanumeric())
&& iter
.peek()
.is_some_and(|&(_, next)| next.general_category() == GeneralCategory::DecimalNumber)
{
offsets.push(i + 1);
added = true;
}
prev = Some(c);
}
if added {
offsets.sort_unstable();
offsets.dedup();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pieces(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut prev = 0;
for offset in break_offsets(text) {
out.push(&text[prev..offset]);
prev = offset;
}
out
}
#[test]
fn thai_breaks_between_words() {
assert_eq!(
pieces("ภาษาไทยเป็นภาษาที่ไม่มีการเว้นวรรค"),
[
"ภาษา",
"ไทย",
"เป็น",
"ภาษา",
"ที่",
"ไม่",
"มี",
"การ",
"เว้นวรรค"
],
);
}
#[test]
fn the_other_complex_scripts_have_models_too() {
for (script, text) in [
("Khmer", "ក្រុមហ៊ុនតូចមួយកំពុងធ្វើការ"),
("Lao", "ພາສາລາວບໍ່ມີການເວັ້ນວັກລະຫວ່າງຄໍາ"),
("Burmese", "မြန်မာဘာသာစကားသည်စကားလုံးများကိုကွာဟမှုမရှိဘဲရေးသားသည်"),
] {
let n = pieces(text).len();
assert!(
n > 1,
"{script}: no word boundaries found, got {n} piece(s)"
);
}
}
#[test]
fn japanese_keeps_punctuation_off_a_line_edge() {
assert_eq!(
pieces("日本語の文章。「引用」とか"),
["日", "本", "語", "の", "文", "章。", "「引", "用」", "と", "か"],
);
}
#[test]
fn ordinary_latin_text_breaks_exactly_where_it_used_to() {
assert_eq!(
pieces("The quick brown fox jumps over the lazy dog."),
["The ", "quick ", "brown ", "fox ", "jumps ", "over ", "the ", "lazy ", "dog."],
);
assert_eq!(pieces("Türöffner-Gerät"), ["Türöffner-", "Gerät"]);
assert_eq!(pieces("10:30–12:00 Uhr"), ["10:30–", "12:00 ", "Uhr"]);
assert_eq!(pieces("„Zitat“ und mehr"), ["„Zitat“ ", "und ", "mehr"]);
assert_eq!(pieces("e.g. i.e. etc."), ["e.g. ", "i.e. ", "etc."]);
assert_eq!(pieces("Datei_name.txt"), ["Datei_name.txt"]);
}
#[test]
fn a_hyphen_after_alphanumerics_breaks_before_digits() {
assert_eq!(pieces("ID-001"), ["ID-", "001"]);
assert_eq!(pieces("DIN VDE 0100-600"), ["DIN ", "VDE ", "0100-", "600"]);
assert_eq!(
pieces("TOKEN-1234567890"),
["TOKEN-", "1234567890"],
"letter-hyphen-digit tokens wrap after the hyphen"
);
assert_eq!(
pieces("ТОКЕН-1234567890-АБВГДЕЖЗ"),
["ТОКЕН-", "1234567890-", "АБВГДЕЖЗ"],
"non-ASCII letters count as letters"
);
assert_eq!(pieces("Anlagen-freigabe"), ["Anlagen-", "freigabe"]);
}
#[test]
fn a_hyphen_after_letters_breaks_before_non_ascii_digits() {
assert_eq!(
pieces("TOKEN-\u{0661}\u{0662}\u{0663}"),
["TOKEN-", "\u{0661}\u{0662}\u{0663}"],
"Arabic-Indic digits (Nd) are digits too"
);
assert_eq!(
pieces("TOKEN-\u{0967}\u{0968}\u{0969}"),
["TOKEN-", "\u{0967}\u{0968}\u{0969}"],
"Devanagari digits (Nd) are digits too"
);
}
#[test]
fn a_minus_sign_still_glues_to_its_digits() {
assert_eq!(pieces("-42"), ["-42"]);
assert_eq!(pieces("t -42"), ["t ", "-42"]);
}
#[test]
fn a_non_breaking_hyphen_holds_a_token_together() {
assert_eq!(pieces("ID\u{2011}001"), ["ID\u{2011}001"]);
}
#[test]
fn a_figure_dash_is_a_break_opportunity() {
assert_eq!(pieces("co\u{2012}operate"), ["co\u{2012}", "operate"]);
}
#[test]
fn slashes_and_em_dashes_gain_opportunities() {
assert_eq!(pieces("a/b/c"), ["a/", "b/", "c"]);
assert_eq!(
pieces("https://example.com/some/path"),
["https://", "example.com/", "some/", "path"],
);
assert_eq!(pieces("long—dash—text"), ["long", "—", "dash", "—", "text"],);
}
#[test]
fn a_tab_breaks_after_itself() {
assert_eq!(pieces("a\tb"), ["a\t", "b"]);
}
#[test]
fn empty_text_has_no_offsets() {
assert!(break_offsets("").is_empty());
}
#[test]
fn offsets_are_ascending_char_boundaries_ending_at_the_length() {
for text in [
"hello world",
"ภาษาไทยเป็นภาษา",
"日本語の文章。",
"e\u{301}x—y",
"x",
] {
let offsets = break_offsets(text);
assert_eq!(offsets.last(), Some(&text.len()), "{text:?}");
assert!(!offsets.contains(&0), "{text:?} must not report offset 0");
assert!(
offsets.windows(2).all(|w| w[0] < w[1]),
"{text:?} offsets must ascend: {offsets:?}",
);
for &o in &offsets {
assert!(text.is_char_boundary(o), "{text:?} offset {o}");
}
}
}
#[test]
fn a_combining_mark_is_never_cut_from_its_base() {
assert_eq!(pieces("e\u{301} x"), ["e\u{301} ", "x"]);
assert_eq!(pieces("e\u{301}"), ["e\u{301}"]);
}
}