sentencex 0.1.30

Sentence segmentation library with wide language support optimized for speed and utility.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
use regex::Regex;
use rustc_hash::FxHashSet;
use std::sync::LazyLock;

pub const ROMAN_NUMERALS: [&str; 20] = [
    "i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii", "xiii", "xiv", "xv",
    "xvi", "xvii", "xviii", "xix", "xx",
];

/// A quoted-region delimiter pair used to build [`QUOTES_REGEX`].
#[derive(Debug)]
pub struct QuotePair {
    pub open: &'static str,
    pub close: &'static str,
    /// Set for pairs whose delimiters are ambiguous with non-quote uses — e.g.
    /// `'` clashes with contractions/possessives, `` ` `` with code ticks (so
    /// ``don`t`` with a backtick instead of an apostrophe could trigger a
    /// false-positive match). The regex builder wraps these with `\B` boundary
    /// guards. Unambiguous symbols like `«` or `「` need no special handling.
    pub ambiguous: bool,
}

// Order matters: the regex engine tries alternatives left to right, so longer
// openers/closers must come before shorter ones (e.g. `''` before `'`).
pub const QUOTE_PAIRS: &[QuotePair] = &[
    QuotePair {
        open: "``",
        close: "''",
        ambiguous: false,
    },
    QuotePair {
        open: "``",
        close: "``",
        ambiguous: false,
    },
    QuotePair {
        open: "''",
        close: "''",
        ambiguous: false,
    },
    QuotePair {
        open: "`",
        close: "'",
        ambiguous: true,
    },
    QuotePair {
        open: "`",
        close: "`",
        ambiguous: true,
    },
    QuotePair {
        open: "'",
        close: "'",
        ambiguous: true,
    },
    QuotePair {
        open: "\"",
        close: "\"",
        ambiguous: false,
    },
    QuotePair {
        open: "«",
        close: "»",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "»",
        close: "«",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
    QuotePair {
        open: "",
        close: "",
        ambiguous: false,
    },
];

pub static PARENS_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"[\((<{\[](?:[^\)\]}>)]|\\[\)\]}>)])*[\)\]}>)]").unwrap());

pub static EMAIL_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}").unwrap());

pub static NUMBERED_REFERENCE_REGEX: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^(\s*\[\d+])+").unwrap());

pub static SPACE_AFTER_SEPARATOR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+").unwrap());

pub static QUOTE_CLOSERS_BY_LEN: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
    let mut v: Vec<&'static str> = QUOTE_PAIRS.iter().map(|p| p.close).collect();
    v.sort_by_key(|s| std::cmp::Reverse(s.len()));
    v.dedup();
    v
});

/// Escape `s` so it is safe to interpolate inside a regex character
/// class `[...]`. `regex::escape`'s public contract only covers the
/// general regex context, not character classes, so we handle the
/// four class-special chars explicitly: `\ ] ^ -`.
fn escape_for_char_class(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if matches!(c, '\\' | ']' | '^' | '-') {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

pub static QUOTES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    let mut patterns: Vec<String> = QUOTE_PAIRS
        .iter()
        .map(|p| {
            let open = regex::escape(p.open);
            let close = regex::escape(p.close);
            if p.ambiguous {
                // `close_in_class` uses char-class-aware escaping;
                // `regex::escape` is not contracted safe inside `[...]`.
                let close_in_class = escape_for_char_class(p.close);
                format!(r"\B{open}\b(?s:.*?[^\s{close_in_class}]){close}\B")
            } else {
                format!(r"{open}(?s:.*?){close}")
            }
        })
        .collect();

    // Line-anchored variant for guarded openers. A guarded delimiter (e.g.
    // `'`, `` ` ``) sitting at start-of-line and matched by another `'` at
    // end-of-line cannot be a possessive: there is no preceding noun to
    // attach to at line-start, and at line-end the symmetric pairing across
    // the same line provides structural evidence that this is a quotation.
    // Both guards (`\B…\b` opener-side, `[^\s…]…\B` closer-side) are
    // dropped, allowing whitespace immediately inside the quote pair
    // (`' Hello, world. '`).
    for pair in QUOTE_PAIRS.iter().filter(|p| p.ambiguous) {
        let open = regex::escape(pair.open);
        let close = regex::escape(pair.close);
        patterns.push(format!(r"(?m:^{open}.+?{close}$)"));
    }

    Regex::new(&patterns.join("|")).unwrap()
});

pub const EXCLAMATION_WORDS: [&str; 17] = [
    "!Xũ",
    "!Kung",
    "ǃʼOǃKung",
    "!Xuun",
    "!Kung-Ekoka",
    "ǃHu",
    "ǃKhung",
    "ǃKu",
    "ǃung",
    "ǃXo",
    "ǃXû",
    "ǃXung",
    "ǃXũ",
    "!Xun",
    "Yahoo!",
    "Y!J",
    "Yum!",
];

// unicode code points generated with Unicode::Tussle perl script:
// unichars -aBbs '[\p{Sentence_Break=STerm}\p{Sentence_Break=ATerm}]' | awk '$2="\""$2"\", //"'
// Refer: https://www.unicode.org/Public/UCD/latest/ucd/auxiliary/SentenceBreakProperty.txt
pub const GLOBAL_SENTENCE_TERMINATORS: [char; 155] = [
    '!',  // U+00021 BC=ON BLK=Basic_Latin SC=Common EXCLAMATION MARK
    '.',  // U+0002E BC=CS BLK=Basic_Latin SC=Common FULL STOP
    '?',  // U+0003F BC=ON BLK=Basic_Latin SC=Common QUESTION MARK
    '։',  // U+00589 BC=L BLK=Armenian SC=Armenian ARMENIAN FULL STOP
    '؝',  // U+0061D BC=AL BLK=Arabic SC=Arabic ARABIC END OF TEXT MARK
    '؞',  // U+0061E BC=AL BLK=Arabic SC=Arabic ARABIC TRIPLE DOT PUNCTUATION MARK
    '؟',  // U+0061F BC=AL BLK=Arabic SC=Common ARABIC QUESTION MARK
    '۔',  // U+006D4 BC=AL BLK=Arabic SC=Arabic ARABIC FULL STOP
    '܀',  // U+00700 BC=AL BLK=Syriac SC=Syriac SYRIAC END OF PARAGRAPH
    '܁',  // U+00701 BC=AL BLK=Syriac SC=Syriac SYRIAC SUPRALINEAR FULL STOP
    '܂',  // U+00702 BC=AL BLK=Syriac SC=Syriac SYRIAC SUBLINEAR FULL STOP
    '߹',  // U+007F9 BC=ON BLK=NKo SC=Nko NKO EXCLAMATION MARK
    '',  // U+00837 BC=R BLK=Samaritan SC=Samaritan SAMARITAN PUNCTUATION MELODIC QITSA
    '',  // U+00839 BC=R BLK=Samaritan SC=Samaritan SAMARITAN PUNCTUATION QITSA
    '',  // U+0083D BC=R BLK=Samaritan SC=Samaritan SAMARITAN PUNCTUATION SOF MASHFAAT
    '',  // U+0083E BC=R BLK=Samaritan SC=Samaritan SAMARITAN PUNCTUATION ANNAAU
    '',  // U+00964 BC=L BLK=Devanagari SC=Common DEVANAGARI DANDA
    '',  // U+00965 BC=L BLK=Devanagari SC=Common DEVANAGARI DOUBLE DANDA
    '',  // U+0104A BC=L BLK=Myanmar SC=Myanmar MYANMAR SIGN LITTLE SECTION
    '',  // U+0104B BC=L BLK=Myanmar SC=Myanmar MYANMAR SIGN SECTION
    '',  // U+01362 BC=L BLK=Ethiopic SC=Ethiopic ETHIOPIC FULL STOP
    '',  // U+01367 BC=L BLK=Ethiopic SC=Ethiopic ETHIOPIC QUESTION MARK
    '',  // U+01368 BC=L BLK=Ethiopic SC=Ethiopic ETHIOPIC PARAGRAPH SEPARATOR
    '', // U+0166E BC=L BLK=Unified_Canadian_Aboriginal_Syllabics SC=Canadian_Aboriginal CANADIAN SYLLABICS FULL STOP
    '', // U+01735 BC=L BLK=Hanunoo SC=Common PHILIPPINE SINGLE PUNCTUATION
    '', // U+01736 BC=L BLK=Hanunoo SC=Common PHILIPPINE DOUBLE PUNCTUATION
    '', // U+01803 BC=ON BLK=Mongolian SC=Common MONGOLIAN FULL STOP
    '', // U+01809 BC=ON BLK=Mongolian SC=Mongolian MONGOLIAN MANCHU FULL STOP
    '', // U+01944 BC=ON BLK=Limbu SC=Limbu LIMBU EXCLAMATION MARK
    '', // U+01945 BC=ON BLK=Limbu SC=Limbu LIMBU QUESTION MARK
    '', // U+01AA8 BC=L BLK=Tai_Tham SC=Tai_Tham TAI THAM SIGN KAAN
    '', // U+01AA9 BC=L BLK=Tai_Tham SC=Tai_Tham TAI THAM SIGN KAANKUU
    '', // U+01AAA BC=L BLK=Tai_Tham SC=Tai_Tham TAI THAM SIGN SATKAAN
    '', // U+01AAB BC=L BLK=Tai_Tham SC=Tai_Tham TAI THAM SIGN SATKAANKUU
    '', // U+01B5A BC=L BLK=Balinese SC=Balinese BALINESE PANTI
    '', // U+01B5B BC=L BLK=Balinese SC=Balinese BALINESE PAMADA
    '', // U+01B5E BC=L BLK=Balinese SC=Balinese BALINESE CARIK SIKI
    '', // U+01B5F BC=L BLK=Balinese SC=Balinese BALINESE CARIK PAREREN
    '', // U+01B7D BC=L BLK=Balinese SC=Balinese BALINESE PANTI LANTANG
    '', // U+01B7E BC=L BLK=Balinese SC=Balinese BALINESE PAMADA LANTANG
    '', // U+01C3B BC=L BLK=Lepcha SC=Lepcha LEPCHA PUNCTUATION TA-ROL
    '', // U+01C3C BC=L BLK=Lepcha SC=Lepcha LEPCHA PUNCTUATION NYET THYOOM TA-ROL
    '', // U+01C7E BC=L BLK=Ol_Chiki SC=Ol_Chiki OL CHIKI PUNCTUATION MUCAAD
    '᱿', // U+01C7F BC=L BLK=Ol_Chiki SC=Ol_Chiki OL CHIKI PUNCTUATION DOUBLE MUCAAD
    '', // U+02024 BC=ON BLK=General_Punctuation SC=Common ONE DOT LEADER
    '', // U+0203C BC=ON BLK=General_Punctuation SC=Common DOUBLE EXCLAMATION MARK
    '', // U+0203D BC=ON BLK=General_Punctuation SC=Common INTERROBANG
    '', // U+02047 BC=ON BLK=General_Punctuation SC=Common DOUBLE QUESTION MARK
    '', // U+02048 BC=ON BLK=General_Punctuation SC=Common QUESTION EXCLAMATION MARK
    '', // U+02049 BC=ON BLK=General_Punctuation SC=Common EXCLAMATION QUESTION MARK
    '', // U+02E2E BC=ON BLK=Supplemental_Punctuation SC=Common REVERSED QUESTION MARK
    '', // U+02E3C BC=ON BLK=Supplemental_Punctuation SC=Common STENOGRAPHIC FULL STOP
    '', // U+02E53 BC=ON BLK=Supplemental_Punctuation SC=Common MEDIEVAL EXCLAMATION MARK
    '', // U+02E54 BC=ON BLK=Supplemental_Punctuation SC=Common MEDIEVAL QUESTION MARK
    '', // U+0A4FF BC=L BLK=Lisu SC=Lisu LISU PUNCTUATION FULL STOP
    '', // U+0A60E BC=ON BLK=Vai SC=Vai VAI FULL STOP
    '', // U+0A60F BC=ON BLK=Vai SC=Vai VAI QUESTION MARK
    '', // U+0A6F3 BC=L BLK=Bamum SC=Bamum BAMUM FULL STOP
    '', // U+0A6F7 BC=L BLK=Bamum SC=Bamum BAMUM QUESTION MARK
    '', // U+0A876 BC=ON BLK=Phags-pa SC=Phags_Pa PHAGS-PA MARK SHAD
    '', // U+0A877 BC=ON BLK=Phags-pa SC=Phags_Pa PHAGS-PA MARK DOUBLE SHAD
    '', // U+0A8CE BC=L BLK=Saurashtra SC=Saurashtra SAURASHTRA DANDA
    '', // U+0A8CF BC=L BLK=Saurashtra SC=Saurashtra SAURASHTRA DOUBLE DANDA
    '', // U+0A92F BC=L BLK=Kayah_Li SC=Kayah_Li KAYAH LI SIGN SHYA
    '', // U+0A9C8 BC=L BLK=Javanese SC=Javanese JAVANESE PADA LINGSA
    '', // U+0A9C9 BC=L BLK=Javanese SC=Javanese JAVANESE PADA LUNGSI
    '', // U+0AA5D BC=L BLK=Cham SC=Cham CHAM PUNCTUATION DANDA
    '', // U+0AA5E BC=L BLK=Cham SC=Cham CHAM PUNCTUATION DOUBLE DANDA
    '', // U+0AA5F BC=L BLK=Cham SC=Cham CHAM PUNCTUATION TRIPLE DANDA
    '', // U+0AAF0 BC=L BLK=Meetei_Mayek_Extensions SC=Meetei_Mayek MEETEI MAYEK CHEIKHAN
    '', // U+0AAF1 BC=L BLK=Meetei_Mayek_Extensions SC=Meetei_Mayek MEETEI MAYEK AHANG KHUDAM
    '', // U+0ABEB BC=L BLK=Meetei_Mayek SC=Meetei_Mayek MEETEI MAYEK CHEIKHEI
    '', // U+0FE52 BC=CS BLK=Small_Form_Variants SC=Common SMALL FULL STOP
    '', // U+0FE56 BC=ON BLK=Small_Form_Variants SC=Common SMALL QUESTION MARK
    '', // U+0FE57 BC=ON BLK=Small_Form_Variants SC=Common SMALL EXCLAMATION MARK
    '', // U+0FF01 BC=ON BLK=Halfwidth_and_Fullwidth_Forms SC=Common FULLWIDTH EXCLAMATION MARK
    '', // U+0FF0E BC=CS BLK=Halfwidth_and_Fullwidth_Forms SC=Common FULLWIDTH FULL STOP
    '', // U+0FF1F BC=ON BLK=Halfwidth_and_Fullwidth_Forms SC=Common FULLWIDTH QUESTION MARK
    '𐩖', // U+10A56 BC=R BLK=Kharoshthi SC=Kharoshthi KHAROSHTHI PUNCTUATION DANDA
    '𐩗', // U+10A57 BC=R BLK=Kharoshthi SC=Kharoshthi KHAROSHTHI PUNCTUATION DOUBLE DANDA
    '𐽕', // U+10F55 BC=AL BLK=Sogdian SC=Sogdian SOGDIAN PUNCTUATION TWO VERTICAL BARS
    '𐽖', // U+10F56 BC=AL BLK=Sogdian SC=Sogdian SOGDIAN PUNCTUATION TWO VERTICAL BARS WITH DOTS
    '𐽗', // U+10F57 BC=AL BLK=Sogdian SC=Sogdian SOGDIAN PUNCTUATION CIRCLE WITH DOT
    '𐽘', // U+10F58 BC=AL BLK=Sogdian SC=Sogdian SOGDIAN PUNCTUATION TWO CIRCLES WITH DOTS
    '𐽙', // U+10F59 BC=AL BLK=Sogdian SC=Sogdian SOGDIAN PUNCTUATION HALF CIRCLE WITH DOT
    '𐾆', // U+10F86 BC=R BLK=Old_Uyghur SC=Old_Uyghur OLD UYGHUR PUNCTUATION BAR
    '𐾇', // U+10F87 BC=R BLK=Old_Uyghur SC=Old_Uyghur OLD UYGHUR PUNCTUATION TWO BARS
    '𐾈', // U+10F88 BC=R BLK=Old_Uyghur SC=Old_Uyghur OLD UYGHUR PUNCTUATION TWO DOTS
    '𐾉', // U+10F89 BC=R BLK=Old_Uyghur SC=Old_Uyghur OLD UYGHUR PUNCTUATION FOUR DOTS
    '𑁇', // U+11047 BC=L BLK=Brahmi SC=Brahmi BRAHMI DANDA
    '𑁈', // U+11048 BC=L BLK=Brahmi SC=Brahmi BRAHMI DOUBLE DANDA
    '𑂾', // U+110BE BC=L BLK=Kaithi SC=Kaithi KAITHI SECTION MARK
    '𑂿', // U+110BF BC=L BLK=Kaithi SC=Kaithi KAITHI DOUBLE SECTION MARK
    '𑃀', // U+110C0 BC=L BLK=Kaithi SC=Kaithi KAITHI DANDA
    '𑃁', // U+110C1 BC=L BLK=Kaithi SC=Kaithi KAITHI DOUBLE DANDA
    '𑅁', // U+11141 BC=L BLK=Chakma SC=Chakma CHAKMA DANDA
    '𑅂', // U+11142 BC=L BLK=Chakma SC=Chakma CHAKMA DOUBLE DANDA
    '𑅃', // U+11143 BC=L BLK=Chakma SC=Chakma CHAKMA QUESTION MARK
    '𑇅', // U+111C5 BC=L BLK=Sharada SC=Sharada SHARADA DANDA
    '𑇆', // U+111C6 BC=L BLK=Sharada SC=Sharada SHARADA DOUBLE DANDA
    '𑇍', // U+111CD BC=L BLK=Sharada SC=Sharada SHARADA SUTRA MARK
    '𑇞', // U+111DE BC=L BLK=Sharada SC=Sharada SHARADA SECTION MARK-1
    '𑇟', // U+111DF BC=L BLK=Sharada SC=Sharada SHARADA SECTION MARK-2
    '𑈸', // U+11238 BC=L BLK=Khojki SC=Khojki KHOJKI DANDA
    '𑈹', // U+11239 BC=L BLK=Khojki SC=Khojki KHOJKI DOUBLE DANDA
    '𑈻', // U+1123B BC=L BLK=Khojki SC=Khojki KHOJKI SECTION MARK
    '𑈼', // U+1123C BC=L BLK=Khojki SC=Khojki KHOJKI DOUBLE SECTION MARK
    '𑊩', // U+112A9 BC=L BLK=Multani SC=Multani MULTANI SECTION MARK
    '𑑋', // U+1144B BC=L BLK=Newa SC=Newa NEWA DANDA
    '𑑌', // U+1144C BC=L BLK=Newa SC=Newa NEWA DOUBLE DANDA
    '𑗂', // U+115C2 BC=L BLK=Siddham SC=Siddham SIDDHAM DANDA
    '𑗃', // U+115C3 BC=L BLK=Siddham SC=Siddham SIDDHAM DOUBLE DANDA
    '𑗉', // U+115C9 BC=L BLK=Siddham SC=Siddham SIDDHAM END OF TEXT MARK
    '𑗊', // U+115CA BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH TRIDENT AND U-SHAPED ORNAMENTS
    '𑗋', // U+115CB BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH TRIDENT AND DOTTED CRESCENTS
    '𑗌', // U+115CC BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH RAYS AND DOTTED CRESCENTS
    '𑗍', // U+115CD BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH RAYS AND DOTTED DOUBLE CRESCENTS
    '𑗎', // U+115CE BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH RAYS AND DOTTED TRIPLE CRESCENTS
    '𑗏', // U+115CF BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK DOUBLE RING
    '𑗐', // U+115D0 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK DOUBLE RING WITH RAYS
    '𑗑', // U+115D1 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH DOUBLE CRESCENTS
    '𑗒', // U+115D2 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH TRIPLE CRESCENTS
    '𑗓', // U+115D3 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH QUADRUPLE CRESCENTS
    '𑗔', // U+115D4 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH SEPTUPLE CRESCENTS
    '𑗕', // U+115D5 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH CIRCLES AND RAYS
    '𑗖', // U+115D6 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH CIRCLES AND TWO ENCLOSURES
    '𑗗', // U+115D7 BC=L BLK=Siddham SC=Siddham SIDDHAM SECTION MARK WITH CIRCLES AND FOUR ENCLOSURES
    '𑙁', // U+11641 BC=L BLK=Modi SC=Modi MODI DANDA
    '𑙂', // U+11642 BC=L BLK=Modi SC=Modi MODI DOUBLE DANDA
    '𑜼', // U+1173C BC=L BLK=Ahom SC=Ahom AHOM SIGN SMALL SECTION
    '𑜽', // U+1173D BC=L BLK=Ahom SC=Ahom AHOM SIGN SECTION
    '𑜾', // U+1173E BC=L BLK=Ahom SC=Ahom AHOM SIGN RULAI
    '𑥄', // U+11944 BC=L BLK=Dives_Akuru SC=Dives_Akuru DIVES AKURU DOUBLE DANDA
    '𑥆', // U+11946 BC=L BLK=Dives_Akuru SC=Dives_Akuru DIVES AKURU END OF TEXT MARK
    '𑩂', // U+11A42 BC=L BLK=Zanabazar_Square SC=Zanabazar_Square ZANABAZAR SQUARE MARK SHAD
    '𑩃', // U+11A43 BC=L BLK=Zanabazar_Square SC=Zanabazar_Square ZANABAZAR SQUARE MARK DOUBLE SHAD
    '𑪛', // U+11A9B BC=L BLK=Soyombo SC=Soyombo SOYOMBO MARK SHAD
    '𑪜', // U+11A9C BC=L BLK=Soyombo SC=Soyombo SOYOMBO MARK DOUBLE SHAD
    '𑱁', // U+11C41 BC=L BLK=Bhaiksuki SC=Bhaiksuki BHAIKSUKI DANDA
    '𑱂', // U+11C42 BC=L BLK=Bhaiksuki SC=Bhaiksuki BHAIKSUKI DOUBLE DANDA
    '𑻷', // U+11EF7 BC=L BLK=Makasar SC=Makasar MAKASAR PASSIMBANG
    '𑻸', // U+11EF8 BC=L BLK=Makasar SC=Makasar MAKASAR END OF SECTION
    '𑽃', // U+11F43 BC=L BLK=Kawi SC=Kawi KAWI DANDA
    '𑽄', // U+11F44 BC=L BLK=Kawi SC=Kawi KAWI DOUBLE DANDA
    '𖩮', // U+16A6E BC=L BLK=Mro SC=Mro MRO DANDA
    '𖩯', // U+16A6F BC=L BLK=Mro SC=Mro MRO DOUBLE DANDA
    '𖫵', // U+16AF5 BC=L BLK=Bassa_Vah SC=Bassa_Vah BASSA VAH FULL STOP
    '𖬷', // U+16B37 BC=L BLK=Pahawh_Hmong SC=Pahawh_Hmong PAHAWH HMONG SIGN VOS THOM
    '𖬸', // U+16B38 BC=L BLK=Pahawh_Hmong SC=Pahawh_Hmong PAHAWH HMONG SIGN VOS TSHAB CEEB
    '𖭄', // U+16B44 BC=L BLK=Pahawh_Hmong SC=Pahawh_Hmong PAHAWH HMONG SIGN XAUS
    '𖺘', // U+16E98 BC=L BLK=Medefaidrin SC=Medefaidrin MEDEFAIDRIN FULL STOP
    '𛲟', // U+1BC9F BC=L BLK=Duployan SC=Duployan DUPLOYAN PUNCTUATION CHINOOK FULL STOP
    '𝪈', // U+1DA88 BC=L BLK=Sutton_SignWriting SC=SignWriting SIGNWRITING FULL STOP
    //  Additional manual entries.
    '', // U+3002 IDEOGRAPHIC FULL STOP
    '',  // U+FF61 HALFWIDTH IDEOGRAPHIC FULL STOP
];

pub static GLOBAL_SENTENCE_TERMINATORS_SET: LazyLock<FxHashSet<char>> =
    LazyLock::new(|| GLOBAL_SENTENCE_TERMINATORS.into_iter().collect());

#[inline]
pub fn is_sentence_terminator(c: char) -> bool {
    // Fast check for ASCII to avoid unicode hashing
    if c.is_ascii() {
        return matches!(c, '!' | '.' | '?');
    }

    GLOBAL_SENTENCE_TERMINATORS_SET.contains(&c)
}

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

    #[test]
    fn test_is_sentence_terminator_matches_table() {
        for &c in GLOBAL_SENTENCE_TERMINATORS.iter() {
            assert!(
                is_sentence_terminator(c),
                "{c:?} is in the table but is_sentence_terminator returned false"
            );
        }

        for c in ['a', 'Z', '0', ' ', ',', ';', ':', 'é', '', '«'] {
            assert!(
                !is_sentence_terminator(c),
                "{c:?} wrongly reported as a terminator"
            );
        }
    }

    #[test]
    fn test_quotes_regex_greek_basic() {
        let text = "Ο γιατρός είπε: «Η κατάσταση είναι σταθερή».";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["«Η κατάσταση είναι σταθερή»"]);
    }

    #[test]
    fn test_quotes_regex_greek_multiple() {
        let text = "«Καλημέρα» είπε. «Πώς είσαι;»";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["«Καλημέρα»", "«Πώς είσαι;»"]);
    }

    #[test]
    fn test_quotes_regex_greek_multiline() {
        let text = "Παράδειγμα:\n«Πρώτη γραμμή\nΔεύτερη γραμμή» τέλος.";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["«Πρώτη γραμμή\nΔεύτερη γραμμή»"]);
    }

    #[test]
    fn test_quotes_regex_other_double_quotes() {
        // Ensure standard ASCII quotes still work
        let text = r#"He said, "Hello" and left."#;
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["\"Hello\""]);
    }

    #[test]
    fn test_quotes_regex_all_quote_types() {
        // Test all quote pairs defined in QUOTE_PAIRS
        let test_cases = vec![
            ("Standard double: \"Hello\"", vec!["\"Hello\""]),
            ("Greek: «Γεια σου»", vec!["«Γεια σου»"]),
            ("Curved single: 'Hi'", vec!["'Hi'"]),
            ("German-style: „Hallo“", vec!["„Hallo“"]),
            ("Single angular: ‹Bonjour›", vec!["‹Bonjour›"]),
            ("CJK: 「こんにちは」", vec!["「こんにちは」"]),
            ("Chinese: 《你好》", vec!["《你好》"]),
            ("LaTeX double: ``Hello''", vec!["``Hello''"]),
            ("LaTeX single: `Hello'", vec!["`Hello'"]),
            ("Single backtick: `Hello`", vec!["`Hello`"]),
            ("Double backtick: ``Hello``", vec!["``Hello``"]),
            ("Double apostrophe: ''Hello''", vec!["''Hello''"]),
        ];

        for (text, expected) in test_cases {
            let matches: Vec<_> = QUOTES_REGEX
                .find_iter(text)
                .map(|m| &text[m.start()..m.end()])
                .collect();
            assert_eq!(matches, expected, "Failed for text: {}", text);
        }
    }

    #[test]
    fn test_quotes_regex_edge_cases() {
        // Empty quotes
        let text = "Empty: «»";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["«»"]);

        // Nested quotes (should match the outer ones)
        let text = "Nested: «Outer 'inner' quotes»";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, vec!["«Outer 'inner' quotes»"]);

        // No quotes
        let text = "No quotes here at all.";
        let matches: Vec<_> = QUOTES_REGEX
            .find_iter(text)
            .map(|m| &text[m.start()..m.end()])
            .collect();
        assert_eq!(matches, Vec::<&str>::new());
    }

    #[test]
    fn test_quotes_regex_with_punctuation() {
        // Quotes with various punctuation inside
        let test_cases = vec![
            ("Question: «Πώς είσαι;»", vec!["«Πώς είσαι;»"]),
            ("Exclamation: «Γεια σου!»", vec!["«Γεια σου!»"]),
            ("Period: «Καλημέρα.»", vec!["«Καλημέρα.»"]),
            (
                "Complex: «Ελα, πώς είσαι; Καλά!»",
                vec!["«Ελα, πώς είσαι; Καλά!»"],
            ),
        ];

        for (text, expected) in test_cases {
            let matches: Vec<_> = QUOTES_REGEX
                .find_iter(text)
                .map(|m| &text[m.start()..m.end()])
                .collect();
            assert_eq!(matches, expected, "Failed for text: {}", text);
        }
    }

    #[test]
    fn test_quotes_regex_debug_pattern() {
        // Let's see what the actual regex pattern looks like
        let patterns: Vec<String> = QUOTE_PAIRS
            .iter()
            .map(|p| {
                format!(
                    r"(?s){}.*?{}",
                    regex::escape(p.open),
                    regex::escape(p.close)
                )
            })
            .collect();
        let quotes_regex_str = patterns.join("|");

        println!("QUOTES_REGEX pattern: {}", quotes_regex_str);

        // Verify that Greek quotes are in the pattern
        assert!(quotes_regex_str.contains("«"));
        assert!(quotes_regex_str.contains("»"));
    }

    #[test]
    fn test_quotes_regex_metachar_closer_is_safe() {
        // Regression guard for the guarded-branch pattern construction:
        // if a future QuotePair has a `close` that is a regex character-
        // class metachar (`]`, `^`, `-`), interpolating it into the
        // `[^\s...]` class must still produce a valid regex where the
        // closer behaves as a literal - not as a class operator. We
        // rebuild the same shape of pattern locally with synthetic
        // pairs to exercise this without touching the const QUOTE_PAIRS.
        for &close in &["]", "^", "-"] {
            let open_lit = "<";
            let o = regex::escape(open_lit);
            let c_in_class = escape_for_char_class(close);
            let c_outside = regex::escape(close);
            let pat = format!(r"\B{o}\b(?s:.*?[^\s{c_in_class}]){c_outside}\B");
            let re = Regex::new(&pat)
                .unwrap_or_else(|e| panic!("pattern {pat:?} failed to compile: {e}"));

            let text = format!("x <hi{close} y");
            let m = re
                .find(&text)
                .unwrap_or_else(|| panic!("expected match in {text:?} with pattern {pat:?}"));
            assert_eq!(&text[m.start()..m.end()], format!("<hi{close}"));
        }
    }

    #[test]
    fn test_escape_for_char_class() {
        assert_eq!(escape_for_char_class("'"), "'");
        assert_eq!(escape_for_char_class("`"), "`");
        assert_eq!(escape_for_char_class("]"), r"\]");
        assert_eq!(escape_for_char_class("^"), r"\^");
        assert_eq!(escape_for_char_class("-"), r"\-");
        assert_eq!(escape_for_char_class("\\"), r"\\");
        assert_eq!(escape_for_char_class("a]b^c-d"), r"a\]b\^c\-d");
    }
}