kibble 0.1.0

chew through any source into clean datasets — a fast ingestion, RAG & fine-tuning toolkit
Documentation
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(),
        // invites BEFORE bare-domain handling; matched explicitly here
        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| {
            // zero-width chars and the replacement char
            !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");

    // Token strips (invites before URLs so the invite host isn't half-eaten).
    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();

    // Remove brackets that are now empty (run twice for nested cases).
    s = p.empty_brackets.replace_all(&s, "").into_owned();
    s = p.empty_brackets.replace_all(&s, "").into_owned();

    // Whitespace normalization, line by line.
    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() {
        // U+200B zero-width space, U+FFFD replacement char
        assert_eq!(clean_text("a\u{200b}b\u{fffd}c"), "abc");
    }

    #[test]
    fn removes_empty_brackets_left_after_stripping() {
        // "(@alice)" -> "()" -> removed
        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);
    }
}