///|
/// Count the sentences of `text` (spec §4.3.1 row 4; upstream
/// `_count_sentences.py:24-32`).
///
/// Upstream returns 0 for the empty string and otherwise iterates
/// `re.findall(r"\b[^.!?]+[.!?]*", text, re.UNICODE)`, discarding candidates
/// whose default word count is at most two and returning
/// `max(1, candidates - discarded)`. This port reproduces that scan without a
/// regex library, left to right and non-overlapping exactly like `findall`: at
/// each position `p` it tries to match the pattern starting there; on success
/// it records the candidate and resumes at the match end, otherwise it
/// advances `p` by one code point.
///
/// The three parts of the pattern are reproduced literally:
///
/// 1. `\b` is a word boundary at `p`: exactly one of the code points before and
/// at `p` is a word character, with the start and end of the text counted as
/// non-word. `is_python_word_char` (`letter_count.mbt`) supplies the
/// word-character approximation; its doc comment states the limits.
/// 2. `[^.!?]+` consumes one or more code points that are not `.`, `!` or `?`.
/// Only these three ASCII terminators split a sentence — newlines, `;`, `:`
/// and `…` (U+2026) are ordinary content (spec §4.3.2).
/// 3. `[.!?]*` consumes the whole run of terminators that follows.
///
/// A boundary in front of a terminator (a word character followed by `.`, `!`
/// or `?`) starts no match, because `[^.!?]+` cannot consume the terminator
/// itself; the scan advances one position instead.
///
/// Each candidate is word-counted with the package-private `list_words` under
/// its documented defaults (`removepunct=true`, no lowercasing, no contraction
/// or hyphen splitting), which is the same call upstream's
/// `count_words(sentence)` makes. Candidates with at most two words are
/// discarded, and the result is `max(1, candidates - discarded)`, so every
/// non-empty text yields at least 1: whitespace-only and punctuation-only input
/// both yield 1 while the empty string yields 0 (spec §4.3.3). The function is
/// pure: no I/O, no exceptions.
pub fn sentence_count(text : String) -> Int32 {
let chars = text.to_array()
let n = chars.length()
if n == 0 {
return 0
}
let mut kept = 0
let mut discarded = 0
let mut p = 0
while p < n {
if sentence_start(chars, p) {
let end = sentence_match_end(chars, p)
if list_words(chars_to_string(chars, p, end)).length() <= 2 {
discarded = discarded + 1
} else {
kept = kept + 1
}
p = end
} else {
p = p + 1
}
}
if kept < 1 {
1
} else {
kept
}
}
///|
/// The `.`/`!`/`?` alternation of upstream's pattern. No other code point ends
/// a sentence (spec §4.3.2).
fn is_sentence_terminator(ch : Char) -> Bool {
ch == '.' || ch == '!' || ch == '?'
}
///|
/// `\b` tested at `position`, which must be a valid index into `chars`: true
/// iff exactly one of the preceding and the current code point is a word
/// character. The start of the text is treated as non-word, so position 0 is a
/// boundary exactly when the first code point is a word character.
fn sentence_boundary(chars : Array[Char], position : Int32) -> Bool {
let previous_is_word = position > 0 &&
is_python_word_char(chars[position - 1])
let current_is_word = is_python_word_char(chars[position])
previous_is_word != current_is_word
}
///|
/// True iff a match of `\b[^.!?]+[.!?]*` can start at `position`: a word
/// boundary whose current code point is not a terminator, so that `[^.!?]+` has
/// at least one code point to consume.
fn sentence_start(chars : Array[Char], position : Int32) -> Bool {
sentence_boundary(chars, position) && !is_sentence_terminator(chars[position])
}
///|
/// The exclusive end of the match that starts at `start`, where `chars[start]`
/// is known not to be a terminator: `[^.!?]+` runs to the next terminator or
/// the end of the text, then `[.!?]*` consumes the whole terminator run.
fn sentence_match_end(chars : Array[Char], start : Int32) -> Int32 {
let n = chars.length()
let mut end = start
while end < n && !is_sentence_terminator(chars[end]) {
end = end + 1
}
while end < n && is_sentence_terminator(chars[end]) {
end = end + 1
}
end
}
///|
/// The candidate `chars[start, end)` as a `String`, handed to the tokenizer.
/// Requires `0 <= start <= end <= chars.length()`.
fn chars_to_string(chars : Array[Char], start : Int32, end : Int32) -> String {
let buf = StringBuilder::StringBuilder()
for i = start; i < end; i = i + 1 {
buf.write_char(chars[i])
}
buf.to_string()
}