use std::collections::HashSet;
use super::rules::{fold, fold_flat, FillerRule, FUNCTION_WORDS, KNOWN_VARIANTS, DEFAULT_FILLERS, Position};
use super::tokens::{Token, TokenKind, TokenStream};
use super::CleanConfig;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PassStats {
pub fillers_removed: usize,
pub variants_fixed: usize,
pub repetitions_removed: usize,
}
pub(crate) fn fix_variants(text: &str, vocab: &[String]) -> (String, usize) {
if text.is_empty() || vocab.is_empty() {
return (text.to_string(), 0);
}
let mut terms: Vec<&String> = vocab.iter().filter(|v| v.chars().count() >= 3).collect();
terms.sort_by_key(|v| std::cmp::Reverse(v.chars().count()));
let mut replacements: Vec<(String, String)> = Vec::new();
let mut seen_targets: HashSet<String> = HashSet::new();
for term in terms {
let target = fold_flat(term);
if target.is_empty() || !seen_targets.insert(target.clone()) {
continue;
}
for (canonical, variants) in KNOWN_VARIANTS {
if fold_flat(canonical) == target {
for v in *variants {
if seen_targets.insert(fold_flat(v)) {
replacements.push((fold_flat(v), canonical.to_string()));
}
}
}
}
replacements.push((target, term.to_string()));
}
let mut out = text.to_string();
let mut fixed = 0usize;
for (target, replacement) in replacements {
let (t, n) = replace_word_matches_counted(&out, &target, &replacement);
out = t;
fixed += n;
}
(out, fixed)
}
pub fn replace_word_matches(text: &str, target: &str, replacement: &str) -> String {
replace_word_matches_counted(text, target, replacement).0
}
fn replace_word_matches_counted(text: &str, target: &str, replacement: &str) -> (String, usize) {
let needle: Vec<char> = fold_flat(target).chars().collect();
if needle.is_empty() {
return (text.to_string(), 0);
}
let flat = fold_flat(text);
if !flat.contains(&needle.iter().collect::<String>()) {
return (text.to_string(), 0);
}
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0usize;
let mut count = 0usize;
while i < chars.len() {
let mut k = 0usize;
let mut j = i;
while j < chars.len() && k < needle.len() {
if chars[j].is_whitespace() {
j += 1;
continue;
}
let f: Vec<char> = chars[j].to_lowercase().collect();
if f.is_empty() {
j += 1;
continue;
}
if f.len() <= needle.len() - k && f.as_slice() == &needle[k..k + f.len()] {
j += 1;
k += f.len();
} else {
break;
}
}
let matched = k == needle.len();
let before_ok = !matched || i == 0 || !chars[i - 1].is_alphanumeric();
let after_ok = !matched || j >= chars.len() || !chars[j].is_alphanumeric();
if matched && before_ok && after_ok {
out.push_str(replacement);
i = j;
count += 1;
} else {
out.push(chars[i]);
i += 1;
}
}
(out, count)
}
pub(crate) fn remove_fillers(
stream: &TokenStream,
config: &CleanConfig,
) -> (TokenStream, bool, usize) {
let toks = &stream.tokens;
let mut matched_rule: Vec<Option<FillerRule>> = vec![None; toks.len()];
for (i, tok) in toks.iter().enumerate() {
if tok.kind != TokenKind::Word {
continue;
}
let word = fold(&tok.text);
let rule = if config.user_fillers.iter().any(|f| f.to_lowercase() == word) {
Some(USER_FILLER_RULE)
} else {
DEFAULT_FILLERS.iter().copied().find(|r| r.word == word)
};
let Some(rule) = rule else { continue };
let prev = stream.prev_significant(i);
let next = stream.next_significant(i);
let prev_word = prev
.filter(|t| t.kind == TokenKind::Word)
.map(|t| fold(&t.text));
let next_word = next
.filter(|t| t.kind == TokenKind::Word)
.map(|t| fold(&t.text));
let is_content = |t: &TokenKind| matches!(t, TokenKind::Word | TokenKind::Number | TokenKind::Url | TokenKind::Email);
let boundary_before = prev.is_none_or(|t| !is_content(&t.kind));
let boundary_after = next.is_none_or(|t| !is_content(&t.kind));
let position_ok = rule.positions.iter().any(|p| match p {
Position::Start => boundary_before,
Position::End => boundary_after,
Position::Isolated => boundary_before && boundary_after,
});
let compound_ok = rule
.compound_prev
.iter()
.any(|w| prev_word.as_deref() == Some(*w))
|| (rule
.compound_next
.iter()
.any(|w| next_word.as_deref() == Some(*w))
&& (!rule.compound_requires_boundary_before || boundary_before));
let requires_ok = rule.requires_prev.is_empty()
|| rule.requires_prev.iter().any(|w| prev_word.as_deref() == Some(*w));
let guards_ok = !rule.not_if_prev.iter().any(|w| prev_word.as_deref() == Some(*w))
&& !rule.not_if_next.iter().any(|w| next_word.as_deref() == Some(*w));
let comma_ok = !rule.requires_after_comma
|| matches!(
next,
Some(t) if t.kind == TokenKind::Punct && t.text == ","
);
if (position_ok || compound_ok) && requires_ok && guards_ok && comma_ok {
matched_rule[i] = Some(rule);
}
}
let last_word_idx = toks
.iter()
.rposition(|t| matches!(t.kind, TokenKind::Word | TokenKind::Number));
let mut spans: Vec<(usize, usize)> = Vec::new();
let mut trailing_filler = false;
for (i, rule) in matched_rule.iter().enumerate() {
let Some(rule) = rule else { continue };
let mut s = i;
let mut e = i + 1;
if let Some(p) = stream.prev_significant(i) {
if p.kind == TokenKind::Punct && p.text == "," {
s = toks.iter().position(|t| t.start == p.start).unwrap_or(i);
}
}
if let Some(n) = stream.next_significant(i) {
if n.kind == TokenKind::Punct && n.text == "," {
let n_idx = toks.iter().position(|t| t.start == n.start).unwrap_or(i);
let after = stream.next_significant(n_idx);
let glued = matches!(
after,
Some(t)
if matches!(
t.kind,
TokenKind::Word
| TokenKind::Number
| TokenKind::Url
| TokenKind::Email
) && t.start == n.end
);
if !glued {
e = n_idx + 1;
}
}
}
if Some(i) == last_word_idx {
match stream.next_significant(i) {
Some(n) if n.kind == TokenKind::Punct && matches!(n.text.as_str(), "." | "!" | "?") => {
if !rule.keep_terminal {
e = toks.iter().position(|t| t.start == n.start).unwrap_or(e) + 1;
}
trailing_filler = true;
}
None => trailing_filler = true,
_ => {}
}
}
spans.push((s, e));
}
let fillers_removed = spans.len();
if spans.is_empty() {
return (stream.clone(), false, 0);
}
spans.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
for (s, e) in spans {
match merged.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => merged.push((s, e)),
}
}
let kept: Vec<Token> = toks
.iter()
.enumerate()
.filter(|(idx, _)| !merged.iter().any(|(s, e)| idx >= s && idx < e))
.map(|(_, t)| t.clone())
.collect();
(TokenStream { tokens: kept }, trailing_filler, fillers_removed)
}
const USER_FILLER_RULE: FillerRule = FillerRule {
word: "«usuário»",
positions: &[Position::Isolated],
not_if_prev: &[],
not_if_next: &[],
compound_prev: &[],
compound_next: &[],
compound_requires_boundary_before: false,
requires_prev: &[],
requires_after_comma: false,
keep_terminal: false,
};
pub(crate) fn dedupe_repetitions(stream: &TokenStream) -> (TokenStream, usize) {
let toks = &stream.tokens;
let mut remove = vec![false; toks.len()];
let mut i = 0usize;
while i < toks.len() {
let t = &toks[i];
if !matches!(t.kind, TokenKind::Word | TokenKind::Number) {
i += 1;
continue;
}
let mut j = i;
let mut run = 1usize;
loop {
let mut k = j + 1;
while k < toks.len() && toks[k].kind == TokenKind::Whitespace {
k += 1;
}
if k >= toks.len()
|| toks[k].kind != t.kind
|| toks[k].text.to_lowercase() != t.text.to_lowercase()
{
break;
}
j = k;
run += 1;
}
let is_function = FUNCTION_WORDS.contains(&t.text.to_lowercase().as_str());
let is_number = t.kind == TokenKind::Number;
if (run >= 2 && (is_function || is_number)) || run >= 3 {
for k in (i + 1)..=j {
if matches!(toks[k].kind, TokenKind::Word | TokenKind::Number) {
remove[k] = true;
}
}
}
i = j + 1;
}
let removed = (0..toks.len())
.filter(|k| remove[*k] && matches!(toks[*k].kind, TokenKind::Word | TokenKind::Number))
.count();
if removed == 0 {
return (stream.clone(), 0);
}
let kept: Vec<Token> = toks
.iter()
.enumerate()
.filter(|(k, _)| !remove[*k])
.map(|(_, t)| t.clone())
.collect();
(TokenStream { tokens: kept }, removed)
}
pub(crate) fn normalize(stream: &TokenStream, add_final_period: bool) -> String {
let toks = &stream.tokens;
let mut out = String::with_capacity(64);
let mut pending_space = false;
let mut at_sentence_start = true;
for tok in toks {
if matches!(tok.kind, TokenKind::Url | TokenKind::Email) {
if pending_space {
out.push(' ');
pending_space = false;
}
out.push_str(&tok.text);
at_sentence_start = false;
continue;
}
for c in tok.text.chars() {
if c.is_whitespace() {
if !out.is_empty() {
pending_space = true;
}
continue;
}
let drop_space = matches!(c, ',' | '.' | ';' | ':' | '!' | '?' | ')' | ']' | '}');
if pending_space && !drop_space {
out.push(' ');
}
pending_space = false;
if at_sentence_start && c.is_alphabetic() {
for ch in c.to_uppercase() {
out.push(ch);
}
at_sentence_start = false;
} else {
if matches!(c, '.' | '!' | '?') {
while out.ends_with(',') {
out.pop();
}
}
out.push(c);
if matches!(c, '.' | '!' | '?') {
at_sentence_start = true;
} else if !c.is_whitespace() {
at_sentence_start = false;
}
}
if matches!(c, ',' | ';' | ':' | '!' | '?') {
pending_space = true;
}
}
}
let mut s = out.trim().to_string();
while s.starts_with(',') {
s.remove(0);
}
s = s.trim().to_string();
if let Some(first) = s.chars().next() {
if first.is_alphabetic() {
let upper: String = first.to_uppercase().collect();
s = upper + &s[first.len_utf8()..];
}
}
if add_final_period && !s.ends_with(['.', '!', '?']) {
s.push('.');
}
s
}
#[cfg(test)]
mod tests {
use super::super::clean_text_with;
use super::super::CleanConfig;
use super::*;
fn clean(text: &str) -> String {
clean_with(text, &[], &CleanConfig::default())
}
fn clean_with(text: &str, vocab: &[String], config: &CleanConfig) -> String {
clean_text_with(text, vocab, config).text
}
#[test]
fn trailing_ne_keeps_question_mark() {
assert_eq!(clean("a gente precisa disso né?"), "A gente precisa disso?");
}
#[test]
fn trailing_ne_keeps_exclamation() {
assert_eq!(clean("que dia incrível né!"), "Que dia incrível!");
}
#[test]
fn trailing_ne_without_punct_adds_period() {
assert_eq!(clean("a gente precisa disso, né"), "A gente precisa disso.");
}
#[test]
fn ne_nao_keeps_question_mark() {
assert_eq!(clean("isso vai dar certo, né não?"), "Isso vai dar certo?");
}
#[test]
fn declarative_tags_stay_declarative() {
assert_eq!(clean("o projeto está bom, sabe?"), "O projeto está bom.");
assert_eq!(clean("vamos mudar tudo, entendeu?"), "Vamos mudar tudo.");
assert_eq!(clean("é assim mesmo, viu"), "É assim mesmo.");
assert_eq!(clean("vai dar certo, tá?"), "Vai dar certo.");
}
#[test]
fn mid_sentence_question_not_lost() {
assert_eq!(clean("você vem amanhã, né?"), "Você vem amanhã?");
}
#[test]
fn removes_leading_tipo() {
assert_eq!(clean("tipo, o Sam Altman usa o Claude Code"), "O Sam Altman usa o Claude Code");
}
#[test]
fn removes_tipo_assim_phrase() {
assert_eq!(
clean("tipo assim, eu acho que a gente deveria melhorar isso né"),
"Eu acho que a gente deveria melhorar isso."
);
}
#[test]
fn removes_tipo_assim_mid_sentence_without_boundary() {
assert_eq!(clean("é tipo assim, vamos começar"), "É vamos começar");
}
#[test]
fn removes_mid_sentence_tipo_between_commas() {
assert_eq!(clean("eu acho que, tipo, a gente vai"), "Eu acho que a gente vai");
}
#[test]
fn removes_leading_assim() {
assert_eq!(clean("assim, vamos começar pelo básico"), "Vamos começar pelo básico");
}
#[test]
fn removes_leading_ne() {
assert_eq!(clean("né, vamos lá"), "Vamos lá");
}
#[test]
fn removes_isolated_entao() {
assert_eq!(clean("Então, vamos começar"), "Vamos começar");
assert_eq!(clean("vamos, então, começar"), "Vamos começar");
}
#[test]
fn removes_isolated_ta() {
assert_eq!(clean("vai dar certo, tá?"), "Vai dar certo.");
assert_eq!(clean("tá, vamos lá"), "Vamos lá");
}
#[test]
fn removes_leading_olha() {
assert_eq!(clean("olha, isso aqui é importante"), "Isso aqui é importante");
}
#[test]
fn cleans_english_word_mix_without_touching_terms() {
assert_eq!(
clean("tipo, eu falo GitHub e ele escreve GitHub, né?"),
"Eu falo GitHub e ele escreve GitHub?"
);
}
#[test]
fn keeps_numbers_and_dates_untouched() {
assert_eq!(
clean("a reunião é dia 15, tipo, às 14h30 né"),
"A reunião é dia 15 às 14h30."
);
}
#[test]
fn removes_adjacent_fillers_with_commas() {
assert_eq!(clean("tipo, assim, vamos agora"), "Vamos agora");
}
#[test]
fn removes_adjacent_fillers_without_commas() {
assert_eq!(clean("tipo assim vamos agora"), "Vamos agora");
}
#[test]
fn removes_compound_chain_with_trailing_punct() {
assert_eq!(clean("é tipo assim, né?"), "É?");
}
#[test]
fn keeps_nominal_tipo_de_que() {
assert_eq!(clean("que tipo de pessoa é essa"), "Que tipo de pessoa é essa");
assert_eq!(clean("um tipo de coisa que eu gosto"), "Um tipo de coisa que eu gosto");
assert_eq!(clean("é o tipo de assunto que eu evito"), "É o tipo de assunto que eu evito");
}
#[test]
fn keeps_functional_assim() {
assert_eq!(clean("assim como o João disse"), "Assim como o João disse");
assert_eq!(clean("mesmo assim eu vou"), "Mesmo assim eu vou");
assert_eq!(clean("faz assim que é melhor"), "Faz assim que é melhor");
assert_eq!(clean("é assim que funciona"), "É assim que funciona");
}
#[test]
fn keeps_conjunction_entao() {
assert_eq!(clean("estudei, então passei"), "Estudei, então passei");
}
#[test]
fn keeps_verb_sabe_in_middle_and_end() {
assert_eq!(clean("você sabe o que eu quero dizer"), "Você sabe o que eu quero dizer");
assert_eq!(clean("ele sabe?"), "Ele sabe?");
assert_eq!(clean("isso é ótimo sabe?"), "Isso é ótimo sabe?");
}
#[test]
fn keeps_ta_bom_and_verb_ta() {
assert_eq!(clean("tá bom, vamos nessa"), "Tá bom, vamos nessa");
assert_eq!(clean("ela tá cansada"), "Ela tá cansada");
assert_eq!(clean("ele tá."), "Ele tá.");
}
#[test]
fn keeps_imperative_olha() {
assert_eq!(clean("olha isso aqui"), "Olha isso aqui");
}
#[test]
fn keeps_mid_sentence_ne_without_punctuation() {
assert_eq!(clean("você né que sabe disso"), "Você né que sabe disso");
}
#[test]
fn url_internal_words_are_never_fillers() {
assert_eq!(
clean("acesse https://tipo.com e o site www.sabe.com.br"),
"Acesse https://tipo.com e o site www.sabe.com.br"
);
}
#[test]
fn plain_domains_are_protected() {
assert_eq!(
clean("visite tipo.com e fale com o suporte"),
"Visite tipo.com e fale com o suporte"
);
}
#[test]
fn emails_are_protected() {
assert_eq!(
clean("escreva para fulano@sabe.com, tá?"),
"Escreva para fulano@sabe.com."
);
}
#[test]
fn dedupes_function_word_doubles() {
assert_eq!(clean("eu eu acho que devemos"), "Eu acho que devemos");
assert_eq!(clean("o o modelo está pronto"), "O modelo está pronto");
assert_eq!(clean("eu vou vou testar"), "Eu vou testar");
assert_eq!(clean("O o modelo"), "O modelo");
}
#[test]
fn dedupes_three_plus_any_word() {
assert_eq!(clean("OpenAI OpenAI OpenAI e o resto"), "OpenAI e o resto");
}
#[test]
fn dedupes_number_doubles() {
assert_eq!(clean("a reunião é 14h30 14h30"), "A reunião é 14h30");
assert_eq!(clean("ano 2026 2026"), "Ano 2026");
}
#[test]
fn keeps_repetition_separated_by_comma() {
assert_eq!(clean("não, não, não quero"), "Não, não, não quero");
}
#[test]
fn keeps_content_word_double() {
assert_eq!(clean("muito muito bom"), "Muito muito bom");
}
#[test]
fn keeps_number_repetition_separated_by_comma() {
assert_eq!(clean("14h30, 14h30"), "14h30, 14h30");
}
#[test]
fn fixes_space_before_punctuation_and_double_spaces() {
assert_eq!(clean("oi ,tudo bem"), "Oi, tudo bem");
assert_eq!(clean(" duplo espaço "), "Duplo espaço");
}
#[test]
fn capitalizes_sentence_starts() {
assert_eq!(clean("primeiro item. segundo item!"), "Primeiro item. Segundo item!");
}
#[test]
fn cleans_comma_before_question_mark() {
assert_eq!(clean("disso,?"), "Disso?");
}
#[test]
fn keeps_existing_terminal_punctuation() {
assert_eq!(clean("vamos lá."), "Vamos lá.");
assert_eq!(clean("já terminamos!"), "Já terminamos!");
}
#[test]
fn empty_and_whitespace_are_untouched() {
assert_eq!(clean(""), "");
assert_eq!(clean(" "), " ");
}
#[test]
fn fixes_phonetic_variants_of_vocab_terms() {
let vocab = vec!["Sam Altman".to_string(), "Claude Code".to_string()];
assert_eq!(
clean_with("o sematlman usa o cloud code", &vocab, &CleanConfig::default()),
"O Sam Altman usa o Claude Code"
);
assert_eq!(
clean_with("semautman e samautiman", &vocab, &CleanConfig::default()),
"Sam Altman e Sam Altman"
);
}
#[test]
fn fixes_case_and_spacing_of_terms() {
let vocab = vec![
"GitHub".to_string(),
"ChatGPT".to_string(),
"OpenAI".to_string(),
"Anthropic".to_string(),
];
assert_eq!(
clean_with("sam usa github e openai", &vocab, &CleanConfig::default()),
"Sam usa GitHub e OpenAI"
);
assert_eq!(
clean_with("git hub e chat gpt e antropic", &vocab, &CleanConfig::default()),
"GitHub e ChatGPT e Anthropic"
);
}
#[test]
fn variant_fix_respects_word_boundaries() {
let vocab = vec!["Sam Altman".to_string(), "Claude Code".to_string()];
assert_eq!(
clean_with("sematlmanx e xcloud code", &vocab, &CleanConfig::default()),
"Sematlmanx e xcloud code"
);
}
#[test]
fn variant_only_applies_when_term_is_in_vocab() {
let vocab = vec!["Claude".to_string()];
assert_eq!(
clean_with("o sematlman", &vocab, &CleanConfig::default()),
"O sematlman"
);
}
#[test]
fn keeps_english_words_intact() {
let vocab = vec!["GitHub".to_string()];
assert_eq!(
clean_with("eu uso o GitHub para versionar, tipo, todos os dias né", &vocab, &CleanConfig::default()),
"Eu uso o GitHub para versionar todos os dias."
);
}
#[test]
fn fixes_accented_vocab_terms() {
let vocab = vec!["José".to_string(), "São Paulo".to_string()];
assert_eq!(
clean_with("moro em são paulo e falo com o josé", &vocab, &CleanConfig::default()),
"Moro em São Paulo e falo com o José"
);
}
#[test]
fn user_fillers_are_removed_when_isolated() {
let config = CleanConfig {
user_fillers: vec!["mano".to_string()],
..CleanConfig::default()
};
assert_eq!(clean_with("mano, vamos lá", &[], &config), "Vamos lá");
assert_eq!(clean_with("o mano veio", &[], &config), "O mano veio");
}
#[test]
fn replace_word_matches_joins_spaced_variants() {
assert_eq!(
replace_word_matches("o modelo da Deep Seek no Git Hub", "DeepSeek", "DeepSeek"),
"o modelo da DeepSeek no Git Hub"
);
}
#[test]
fn replace_word_matches_is_case_insensitive() {
assert_eq!(replace_word_matches("deep seek agora", "DeepSeek", "DeepSeek"), "DeepSeek agora");
}
#[test]
fn replace_word_matches_never_touches_similar_words() {
assert_eq!(
replace_word_matches("Cloud e Chat EPT e HTTP", "Claude", "Claude"),
"Cloud e Chat EPT e HTTP"
);
}
#[test]
fn replace_word_matches_respects_boundaries() {
assert_eq!(
replace_word_matches("deepseekx e xdeepseek", "DeepSeek", "DeepSeek"),
"deepseekx e xdeepseek"
);
}
#[test]
fn replace_word_matches_flat_matches_across_spaces() {
assert_eq!(
replace_word_matches("o sem atlman veio", "sematlman", "Sam Altman"),
"o Sam Altman veio"
);
}
#[test]
fn replace_word_matches_full_unicode_fold() {
assert_eq!(replace_word_matches("Café ÇAÇA", "çaça", "CAÇA"), "Café CAÇA");
assert_eq!(replace_word_matches("İstanbul", "istanbul", "ISTANBUL"), "İstanbul");
}
#[test]
fn stats_are_counted_per_pass() {
let cfg = CleanConfig::default();
let mut t = "tipo, eu eu falo github né".to_string();
let (t2, variants_fixed) = fix_variants(&t, &["GitHub".to_string()]);
t = t2;
assert_eq!(variants_fixed, 1);
let stream = TokenStream::tokenize(&t);
let (stream, trailing, fillers) = remove_fillers(&stream, &cfg);
assert_eq!(fillers, 2); assert!(trailing);
let (stream, reps) = dedupe_repetitions(&stream);
assert_eq!(reps, 1); assert_eq!(normalize(&stream, trailing), "Eu falo GitHub.");
}
#[test]
fn filler_removal_never_glues_words() {
assert_eq!(clean("Étipo assim,sabe,t.U2S5"), "Étipo assim, t.U2S5");
assert_eq!(clean("assim,sabe,t.U2S5"), "T.U2S5");
assert_eq!(clean("tipo,o Sam"), "O Sam");
}
#[test]
fn removes_filler_between_commas_both_sides() {
assert_eq!(clean("disso, né, vamos agora"), "Disso vamos agora");
assert_eq!(clean("bom, sabe, vamos lá"), "Bom vamos lá");
}
}