use regex::Regex;
use std::sync::OnceLock;
struct Patterns {
url: Regex,
mention: Regex,
hashtag: Regex,
ticker: Regex,
subreddit: Regex,
invite: Regex,
empty_brackets: Regex,
spaces: Regex,
space_before_punct: Regex,
}
fn patterns() -> &'static Patterns {
static P: OnceLock<Patterns> = OnceLock::new();
P.get_or_init(|| Patterns {
url: Regex::new(r"https?://\S+").unwrap(),
invite: Regex::new(r"(?:discord\.gg|discord\.com/invite)/\S+").unwrap(),
mention: Regex::new(r"(^|[^\w])@\w+").unwrap(),
hashtag: Regex::new(r"#\w+").unwrap(),
ticker: Regex::new(r"\$[A-Za-z]{1,6}\b").unwrap(),
subreddit: Regex::new(r"\br/\w+").unwrap(),
empty_brackets: Regex::new(r"\(\s*\)|\[\s*\]|\{\s*\}").unwrap(),
spaces: Regex::new(r"[ \t]{2,}").unwrap(),
space_before_punct: Regex::new(r"\s+([,.;:!?])").unwrap(),
})
}
fn strip_invisible(input: &str) -> String {
input
.chars()
.filter(|c| {
!matches!(
*c,
'\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{feff}' | '\u{fffd}'
)
})
.collect()
}
pub fn clean_text(input: &str) -> String {
let p = patterns();
let mut s = strip_invisible(input);
s = s.replace("\r\n", "\n").replace('\r', "\n");
s = p.invite.replace_all(&s, "").into_owned();
s = p.url.replace_all(&s, "").into_owned();
s = p.subreddit.replace_all(&s, "").into_owned();
s = p.mention.replace_all(&s, "$1").into_owned();
s = p.hashtag.replace_all(&s, "").into_owned();
s = p.ticker.replace_all(&s, "").into_owned();
s = p.empty_brackets.replace_all(&s, "").into_owned();
s = p.empty_brackets.replace_all(&s, "").into_owned();
let mut out: Vec<String> = Vec::new();
let mut blank = false;
for line in s.split('\n') {
let mut line = p.spaces.replace_all(line, " ").into_owned();
line = p.space_before_punct.replace_all(&line, "$1").into_owned();
let trimmed = line.trim_end().to_string();
if trimmed.trim().is_empty() {
if !blank {
out.push(String::new());
}
blank = true;
} else {
blank = false;
out.push(trimmed);
}
}
out.join("\n").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::clean_text;
#[test]
fn strips_mentions() {
assert_eq!(clean_text("hey @alice and @bob_123 ok"), "hey and ok");
}
#[test]
fn strips_urls() {
assert_eq!(
clean_text("see https://example.com/x?y=1 here"),
"see here"
);
}
#[test]
fn strips_hashtags_and_tickers() {
assert_eq!(clean_text("up #rust and $TSLA today"), "up and today");
}
#[test]
fn strips_subreddits_and_invites() {
assert_eq!(
clean_text("join r/rust via discord.gg/abc123 now"),
"join via now"
);
}
#[test]
fn strips_zero_width_and_replacement() {
assert_eq!(clean_text("a\u{200b}b\u{fffd}c"), "abc");
}
#[test]
fn removes_empty_brackets_left_after_stripping() {
assert_eq!(clean_text("call (@alice) maybe"), "call maybe");
assert_eq!(clean_text("note [#tag] end"), "note end");
}
#[test]
fn collapses_whitespace_and_blank_lines() {
assert_eq!(clean_text("a b\n\n\n\nc \n"), "a b\n\nc");
}
#[test]
fn preserves_email_addresses() {
assert_eq!(clean_text("email me@example.com here"), "email me@example.com here");
assert_eq!(clean_text("ping user@example.com ok"), "ping user@example.com ok");
}
#[test]
fn keeps_normal_prose_untouched() {
let s = "The quick brown fox jumps.";
assert_eq!(clean_text(s), s);
}
}