#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Word {
pub start: usize,
pub end: usize,
pub text: String,
}
fn is_connector(c: char) -> bool {
matches!(c, '\'' | '\u{2019}' | '-' | '\u{2010}')
}
fn is_mark(c: char) -> bool {
matches!(c, '\u{0300}'..='\u{036F}')
}
pub fn strip_marks(word: &str) -> Option<String> {
word.chars()
.any(is_mark)
.then(|| word.chars().filter(|c| !is_mark(*c)).collect())
}
fn is_link_token(token: &str) -> bool {
if token.contains("://") {
return true;
}
let core = token.trim_matches(|c: char| !c.is_alphanumeric() && c != '/');
if core
.get(..4)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("www."))
{
return core.len() > 4;
}
if core.contains('@') {
return is_email(core);
}
is_bare_domain(core, DomainCase::AsWritten)
}
fn is_email(core: &str) -> bool {
let core = match core.get(..7) {
Some(p) if p.eq_ignore_ascii_case("mailto:") => &core[7..],
_ => core,
};
let Some((local, host)) = core.rsplit_once('@') else {
return false;
};
let local_ok = !local.is_empty()
&& local
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._%+-'".contains(c));
local_ok && is_bare_domain(host, DomainCase::Insensitive)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DomainCase {
AsWritten,
Insensitive,
}
fn is_bare_domain(core: &str, case: DomainCase) -> bool {
let host = core.split(['/', '?', '#', ':']).next().unwrap_or_default();
let host = match case {
DomainCase::AsWritten => host.to_string(),
DomainCase::Insensitive => host.to_ascii_lowercase(),
};
let labels: Vec<&str> = host.split('.').collect();
if labels.len() < 2 {
return false;
}
let label_ok = |l: &&str| {
!l.is_empty()
&& l.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
};
if !labels.iter().all(label_ok) {
return false;
}
let tld = labels[labels.len() - 1];
(2..=24).contains(&tld.len()) && tld.chars().all(|c| c.is_ascii_lowercase())
}
fn link_spans(chars: &[char]) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
let start = i;
while i < chars.len() && !chars[i].is_whitespace() {
i += 1;
}
let token: String = chars[start..i].iter().collect();
if is_link_token(&token) {
spans.push((start, i));
}
}
spans
}
fn word_end(chars: &[char], start: usize) -> usize {
let mut i = start + 1;
loop {
if i < chars.len() && (chars[i].is_alphabetic() || is_mark(chars[i])) {
i += 1;
} else if i + 1 < chars.len() && is_connector(chars[i]) && chars[i + 1].is_alphabetic() {
i += 2;
} else {
return i;
}
}
}
pub fn words(line: &str) -> Vec<Word> {
let chars: Vec<char> = line.chars().collect();
let links = link_spans(&chars);
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if let Some(&(_, end)) = links.iter().find(|&&(s, e)| i >= s && i < e) {
i = end;
continue;
}
if !chars[i].is_alphabetic() {
i += 1;
continue;
}
let start = i;
i = word_end(&chars, start);
out.push(Word {
start,
end: i,
text: chars[start..i].iter().collect(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(line: &str) -> Vec<String> {
words(line).into_iter().map(|w| w.text).collect()
}
#[test]
fn splits_plain_words() {
assert_eq!(texts("hello world"), ["hello", "world"]);
}
#[test]
fn mixed_ru_en() {
assert_eq!(texts("привет hello мир"), ["привет", "hello", "мир"]);
}
#[test]
fn keeps_internal_apostrophe_and_hyphen() {
assert_eq!(texts("don't well-known"), ["don't", "well-known"]);
assert_eq!(texts("don\u{2019}t"), ["don\u{2019}t"]);
}
#[test]
fn drops_trailing_and_leading_connectors() {
assert_eq!(texts("-word- 'quote'"), ["word", "quote"]);
}
#[test]
fn ranges_are_char_indices() {
let w = words("ёж и");
assert_eq!(w.len(), 2);
assert_eq!((w[0].start, w[0].end), (0, 2));
assert_eq!((w[1].start, w[1].end), (3, 4));
}
#[test]
fn a_combining_mark_stays_inside_its_word() {
assert_eq!(texts("И\u{301}стинно так"), ["И\u{301}стинно", "так"]);
assert_eq!(texts("по-мо\u{301}ему"), ["по-мо\u{301}ему"]);
assert_eq!(texts("хорошо\u{301}!"), ["хорошо\u{301}"]);
assert_eq!(texts("е\u{308}жик"), ["е\u{308}жик"]);
assert_eq!(texts("cafe\u{301}"), ["cafe\u{301}"]);
}
#[test]
fn a_marked_word_range_covers_its_mark() {
let w = words("И\u{301}стинно");
assert_eq!(w.len(), 1);
assert_eq!((w[0].start, w[0].end), (0, 8));
}
#[test]
fn an_orphaned_mark_starts_no_word() {
assert_eq!(texts("да \u{301} нет"), ["да", "нет"]);
assert!(words("\u{301}").is_empty());
}
#[test]
fn strip_marks_leaves_an_unmarked_word_alone() {
assert_eq!(strip_marks("И\u{301}стинно").as_deref(), Some("Истинно"));
assert_eq!(strip_marks("е\u{308}жик").as_deref(), Some("ежик"));
assert_eq!(strip_marks("Истинно"), None);
assert_eq!(strip_marks("café"), None);
}
#[test]
fn punctuation_and_digits_separate_words() {
assert_eq!(texts("foo, bar! 42 baz"), ["foo", "bar", "baz"]);
}
#[test]
fn empty_and_no_words() {
assert!(words("").is_empty());
assert!(words("123 !!! ---").is_empty());
}
#[test]
fn urls_are_skipped_whole() {
assert_eq!(
texts("см https://github.com/vshylov/mindfork-rs/blob/main тут"),
["см", "тут"]
);
assert_eq!(texts("open www.Example.COM now"), ["open", "now"]);
assert_eq!(texts("see example.com/path?q=1 ok"), ["see", "ok"]);
assert_eq!(texts("host example.com:8080 up"), ["host", "up"]);
assert_eq!(texts("ftp://host/file.txt done"), ["done"]);
}
#[test]
fn emails_are_skipped_whole() {
assert_eq!(
texts("пиши vladimir.shylov@outlook.com сюда"),
["пиши", "сюда"]
);
assert_eq!(texts("на Vladimir.Shylov@Outlook.COM ок"), ["на", "ок"]);
assert_eq!(texts("тег user+tag@mail.example.co.uk да"), ["тег", "да"]);
assert_eq!(texts("mailto:user@example.io готово"), ["готово"]);
assert_eq!(texts("<user@example.com>, ок"), ["ок"]);
}
#[test]
fn an_at_sign_alone_is_not_an_address() {
assert_eq!(
texts("привет @username и @self"),
["привет", "username", "и", "self"]
);
assert_eq!(texts("собака@дома"), ["собака", "дома"]);
assert_eq!(texts("a@b пиши"), ["a", "b", "пиши"]);
}
#[test]
fn punctuation_around_a_url_does_not_hide_it() {
assert_eq!(texts("(https://example.com), да"), ["да"]);
assert_eq!(texts("«example.com». Всё"), ["Всё"]);
}
#[test]
fn a_run_on_sentence_is_not_a_domain() {
assert_eq!(texts("конец.Начало"), ["конец", "Начало"]);
assert_eq!(texts("end.Next"), ["end", "Next"]);
assert_eq!(texts("и т.д. дальше"), ["и", "т", "д", "дальше"]);
assert_eq!(texts("версия 3.14 сборки"), ["версия", "сборки"]);
}
#[test]
fn word_ranges_survive_a_url_in_the_line() {
let w = words("а https://x.com б");
assert_eq!(w.len(), 2);
assert_eq!((w[0].start, w[0].end), (0, 1));
assert_eq!((w[1].start, w[1].end), (16, 17));
}
}