use crate::UtilsResult;
use std::cmp;
use std::collections::{HashMap, HashSet};
pub struct TextParser;
impl TextParser {
pub fn tokenize(text: &str, delimiters: &[char]) -> Vec<String> {
if delimiters.is_empty() {
return vec![text.to_string()];
}
let mut tokens = Vec::new();
let mut current_token = String::new();
for ch in text.chars() {
if delimiters.contains(&ch) {
if !current_token.is_empty() {
tokens.push(current_token.trim().to_string());
current_token.clear();
}
} else {
current_token.push(ch);
}
}
if !current_token.is_empty() {
tokens.push(current_token.trim().to_string());
}
tokens
}
pub fn extract_numbers(text: &str) -> Vec<f64> {
let mut numbers = Vec::new();
let mut current_number = String::new();
for ch in text.chars() {
if ch.is_ascii_digit() || ch == '.' || ch == '-' || ch == '+' {
current_number.push(ch);
} else if !current_number.is_empty() {
if let Ok(num) = current_number.parse::<f64>() {
numbers.push(num);
}
current_number.clear();
}
}
if !current_number.is_empty() {
if let Ok(num) = current_number.parse::<f64>() {
numbers.push(num);
}
}
numbers
}
pub fn extract_key_value_pairs(
text: &str,
pair_delimiter: char,
kv_delimiter: char,
) -> HashMap<String, String> {
let mut pairs = HashMap::new();
for pair in text.split(pair_delimiter) {
if let Some(kv_pos) = pair.find(kv_delimiter) {
let key = pair[..kv_pos].trim().to_string();
let value = pair[kv_pos + 1..].trim().to_string();
pairs.insert(key, value);
}
}
pairs
}
pub fn parse_structured_lines<F, T>(lines: &[String], parser: F) -> UtilsResult<Vec<T>>
where
F: Fn(&str) -> Option<T>,
{
let mut results = Vec::new();
for line in lines {
if let Some(parsed) = parser(line) {
results.push(parsed);
}
}
Ok(results)
}
pub fn word_frequency(text: &str) -> HashMap<String, usize> {
let mut frequencies = HashMap::new();
let words = Self::tokenize(text, &[' ', '\t', '\n', '\r', '.', ',', '!', '?', ';', ':']);
for word in words {
let word_lower = word.to_lowercase();
if !word_lower.is_empty() {
*frequencies.entry(word_lower).or_insert(0) += 1;
}
}
frequencies
}
}
pub struct StringSimilarity;
impl StringSimilarity {
pub fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
let m = s1_chars.len();
let n = s2_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut dp = vec![vec![0; n + 1]; m + 1];
for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
row[0] = i;
}
for (j, cell) in dp[0].iter_mut().enumerate() {
*cell = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if s1_chars[i - 1] == s2_chars[j - 1] {
0
} else {
1
};
dp[i][j] = cmp::min(
dp[i - 1][j] + 1, cmp::min(
dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost, ),
);
}
}
dp[m][n]
}
pub fn levenshtein_similarity(s1: &str, s2: &str) -> f64 {
let max_len = cmp::max(s1.len(), s2.len());
if max_len == 0 {
return 1.0;
}
let distance = Self::levenshtein_distance(s1, s2);
1.0 - (distance as f64 / max_len as f64)
}
pub fn jaccard_similarity(s1: &str, s2: &str, n: usize) -> f64 {
if n == 0 {
return 0.0;
}
let ngrams1 = Self::character_ngrams(s1, n);
let ngrams2 = Self::character_ngrams(s2, n);
if ngrams1.is_empty() && ngrams2.is_empty() {
return 1.0;
}
let intersection: HashSet<_> = ngrams1.intersection(&ngrams2).collect();
let union: HashSet<_> = ngrams1.union(&ngrams2).collect();
intersection.len() as f64 / union.len() as f64
}
pub fn cosine_similarity(s1: &str, s2: &str) -> f64 {
let freq1 = TextParser::word_frequency(s1);
let freq2 = TextParser::word_frequency(s2);
if freq1.is_empty() || freq2.is_empty() {
return 0.0;
}
let mut dot_product = 0.0;
let mut norm1 = 0.0;
let mut norm2 = 0.0;
let all_words: HashSet<_> = freq1.keys().chain(freq2.keys()).collect();
for word in all_words {
let f1 = *freq1.get(word).unwrap_or(&0) as f64;
let f2 = *freq2.get(word).unwrap_or(&0) as f64;
dot_product += f1 * f2;
norm1 += f1 * f1;
norm2 += f2 * f2;
}
if norm1 == 0.0 || norm2 == 0.0 {
return 0.0;
}
dot_product / (norm1.sqrt() * norm2.sqrt())
}
fn character_ngrams(s: &str, n: usize) -> HashSet<String> {
let chars: Vec<char> = s.chars().collect();
let mut ngrams = HashSet::new();
if chars.len() < n {
return ngrams;
}
for i in 0..=chars.len() - n {
let ngram: String = chars[i..i + n].iter().collect();
ngrams.insert(ngram);
}
ngrams
}
pub fn find_best_match(
target: &str,
candidates: &[String],
similarity_fn: fn(&str, &str) -> f64,
threshold: f64,
) -> Option<(String, f64)> {
let mut best_match = None;
let mut best_score = threshold;
for candidate in candidates {
let score = similarity_fn(target, candidate);
if score > best_score {
best_score = score;
best_match = Some((candidate.clone(), score));
}
}
best_match
}
}
pub struct RegexUtils;
impl RegexUtils {
pub fn is_email(text: &str) -> bool {
let email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$";
Self::matches_pattern(text, email_pattern)
}
pub fn is_url(text: &str) -> bool {
let url_pattern = r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$";
Self::matches_pattern(text, url_pattern)
}
pub fn is_numeric(text: &str) -> bool {
!text.is_empty() && text.chars().all(|c| c.is_ascii_digit())
}
pub fn is_alphanumeric(text: &str) -> bool {
!text.is_empty() && text.chars().all(|c| c.is_alphanumeric())
}
pub fn extract_words(text: &str) -> Vec<String> {
text.split_whitespace()
.map(|word| {
word.chars()
.filter(|c| c.is_alphabetic())
.collect::<String>()
})
.filter(|word| !word.is_empty())
.collect()
}
fn matches_pattern(text: &str, pattern: &str) -> bool {
match pattern {
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" => {
text.contains('@')
&& text.contains('.')
&& !text.starts_with('@')
&& !text.ends_with('@')
}
r"^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$" => {
text.starts_with("http://") || text.starts_with("https://")
}
_ => false,
}
}
pub fn find_all_occurrences(text: &str, pattern: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find(pattern) {
let absolute_pos = start + pos;
positions.push(absolute_pos);
start = absolute_pos + 1;
}
positions
}
}
pub struct UnicodeUtils;
impl UnicodeUtils {
pub fn simple_normalize(text: &str) -> String {
text.chars()
.map(|c| match c {
'À'..='Ä' | 'à'..='ä' => 'a',
'È'..='Ë' | 'è'..='ë' => 'e',
'Ì'..='Ï' | 'ì'..='ï' => 'i',
'Ò'..='Ö' | 'ò'..='ö' => 'o',
'Ù'..='Ü' | 'ù'..='ü' => 'u',
'Ñ' | 'ñ' => 'n',
'Ç' | 'ç' => 'c',
_ => c,
})
.collect()
}
pub fn remove_diacritics(text: &str) -> String {
Self::simple_normalize(text)
}
pub fn has_non_ascii(text: &str) -> bool {
!text.is_ascii()
}
pub fn char_count(text: &str) -> usize {
text.chars().count()
}
pub fn analyze_text(text: &str) -> TextAnalysis {
let mut analysis = TextAnalysis::default();
for ch in text.chars() {
analysis.total_chars += 1;
if ch.is_alphabetic() {
analysis.alphabetic += 1;
}
if ch.is_numeric() {
analysis.numeric += 1;
}
if ch.is_whitespace() {
analysis.whitespace += 1;
}
if ch.is_ascii_punctuation() {
analysis.punctuation += 1;
}
if !ch.is_ascii() {
analysis.non_ascii += 1;
}
}
analysis
}
}
#[derive(Debug, Default, Clone)]
pub struct TextAnalysis {
pub total_chars: usize,
pub alphabetic: usize,
pub numeric: usize,
pub whitespace: usize,
pub punctuation: usize,
pub non_ascii: usize,
}
pub struct TextNormalizer;
impl TextNormalizer {
pub fn normalize_for_ml(text: &str) -> String {
text.to_lowercase()
.trim()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn normalize_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn to_title_case(text: &str) -> String {
text.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>()
+ chars.as_str().to_lowercase().as_str()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn remove_html_tags(text: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in text.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
result
}
pub fn clean_for_analysis(text: &str) -> String {
let cleaned = Self::remove_html_tags(text);
let cleaned = UnicodeUtils::remove_diacritics(&cleaned);
Self::normalize_for_ml(&cleaned)
}
pub fn truncate(text: &str, max_length: usize, add_ellipsis: bool) -> String {
if text.len() <= max_length {
return text.to_string();
}
let truncated = &text[..max_length.saturating_sub(if add_ellipsis { 3 } else { 0 })];
if add_ellipsis {
format!("{truncated}...")
} else {
truncated.to_string()
}
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_parser() {
let text = "Hello, world! How are you?";
let tokens = TextParser::tokenize(text, &[' ', ',', '!', '?']);
assert_eq!(tokens, vec!["Hello", "world", "How", "are", "you"]);
let numbers = TextParser::extract_numbers("Price: $12.99, Quantity: 5, Discount: -2.5");
assert_eq!(numbers, vec![12.99, 5.0, -2.5]);
let freq = TextParser::word_frequency("hello world hello");
assert_eq!(*freq.get("hello").expect("operation should succeed"), 2);
assert_eq!(*freq.get("world").expect("operation should succeed"), 1);
}
#[test]
fn test_string_similarity() {
assert_eq!(StringSimilarity::levenshtein_distance("cat", "bat"), 1);
assert_eq!(
StringSimilarity::levenshtein_distance("kitten", "sitting"),
3
);
let similarity = StringSimilarity::levenshtein_similarity("hello", "hallo");
assert!(similarity > 0.5);
let jaccard = StringSimilarity::jaccard_similarity("hello", "hallo", 2);
assert!(jaccard > 0.0);
let cosine = StringSimilarity::cosine_similarity("hello world", "hello earth");
assert!(cosine > 0.0);
}
#[test]
fn test_regex_utils() {
assert!(RegexUtils::is_email("test@example.com"));
assert!(!RegexUtils::is_email("invalid.email"));
assert!(RegexUtils::is_url("https://example.com"));
assert!(!RegexUtils::is_url("not-a-url"));
assert!(RegexUtils::is_numeric("12345"));
assert!(!RegexUtils::is_numeric("123a45"));
let words = RegexUtils::extract_words("Hello, world! 123");
assert_eq!(words, vec!["Hello", "world"]);
let positions = RegexUtils::find_all_occurrences("hello hello world", "hello");
assert_eq!(positions, vec![0, 6]);
}
#[test]
fn test_unicode_utils() {
let normalized = UnicodeUtils::simple_normalize("café");
assert_eq!(normalized, "cafe");
assert!(UnicodeUtils::has_non_ascii("café"));
assert!(!UnicodeUtils::has_non_ascii("cafe"));
assert_eq!(UnicodeUtils::char_count("café"), 4);
let analysis = UnicodeUtils::analyze_text("Hello, 世界!");
assert!(analysis.total_chars > 0);
assert!(analysis.alphabetic > 0);
assert!(analysis.non_ascii > 0);
}
#[test]
fn test_text_normalizer() {
let normalized = TextNormalizer::normalize_for_ml(" Hello, WORLD! ");
assert_eq!(normalized, "hello world");
let whitespace = TextNormalizer::normalize_whitespace(" hello world ");
assert_eq!(whitespace, "hello world");
let title = TextNormalizer::to_title_case("hello world");
assert_eq!(title, "Hello World");
let no_html = TextNormalizer::remove_html_tags("<p>Hello <b>world</b>!</p>");
assert_eq!(no_html, "Hello world!");
let truncated = TextNormalizer::truncate("Hello, world!", 8, true);
assert_eq!(truncated, "Hello...");
}
#[test]
fn test_text_analysis() {
let analysis = UnicodeUtils::analyze_text("Hello123! ");
assert_eq!(analysis.total_chars, 10);
assert_eq!(analysis.alphabetic, 5);
assert_eq!(analysis.numeric, 3);
assert_eq!(analysis.whitespace, 1);
assert_eq!(analysis.punctuation, 1);
}
}