use std::sync::LazyLock;
use regex::Regex;
use crate::config::{CleanupConfig, UrlPolicy};
static CODE_FENCE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)```.*?(?:```|$)").expect("static regex"));
static URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"https?://[^\s<>\)\]*`]+").expect("static regex"));
static HYPHEN_BREAK: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\w)-\s*\n\s*(\w)").expect("static regex"));
static EMPHASIS: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\*\*|\*|__|_|`)").expect("static regex"));
static LIST_OR_HEADING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^\s*(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+)").expect("static regex"));
static ACRONYM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b[A-Z]{3,}\b").expect("static regex"));
static WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("static regex"));
pub fn clean(text: &str, cfg: &CleanupConfig) -> String {
let mut s = text.to_string();
if cfg.drop_code_blocks {
s = CODE_FENCE.replace_all(&s, " ").into_owned();
}
let mut out = String::with_capacity(s.len());
let mut last = 0;
for m in URL.find_iter(&s) {
let segment = &s[last..m.start()];
let prev = s[..last].chars().next_back();
let next = s[m.start()..].chars().next();
out.push_str(&clean_non_url(segment, prev, next, cfg));
out.push_str(&resolve_url(m.as_str(), cfg));
last = m.end();
}
let segment = &s[last..];
let prev = s[..last].chars().next_back();
out.push_str(&clean_non_url(segment, prev, None, cfg));
s = out;
s = s
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect();
if cfg.collapse_whitespace {
s = WHITESPACE.replace_all(&s, " ").trim().to_string();
}
s
}
fn clean_non_url(
segment: &str,
prev: Option<char>,
next: Option<char>,
cfg: &CleanupConfig,
) -> String {
let mut s = segment.to_string();
if cfg.rejoin_hyphenation {
s = HYPHEN_BREAK.replace_all(&s, "$1$2").into_owned();
}
if cfg.strip_markdown {
s = strip_list_or_heading(&s, prev);
s = EMPHASIS.replace_all(&s, "").into_owned();
s = s.replace('|', " ");
}
if cfg.spell_acronyms {
const SENTINEL: char = 'x';
let prepend = prev.is_some_and(|c| c.is_alphanumeric());
let append = next.is_some_and(|c| c.is_alphanumeric());
let mut padded = String::with_capacity(s.len() + 2);
if prepend {
padded.push(SENTINEL);
}
padded.push_str(&s);
if append {
padded.push(SENTINEL);
}
let replaced = ACRONYM
.replace_all(&padded, |caps: ®ex::Captures| {
caps[0]
.chars()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(" ")
})
.into_owned();
let start = if prepend { SENTINEL.len_utf8() } else { 0 };
let end = replaced.len() - if append { SENTINEL.len_utf8() } else { 0 };
s = replaced[start..end].to_string();
}
s
}
fn strip_list_or_heading(segment: &str, prev: Option<char>) -> String {
const SENTINEL: char = '\u{1}';
let needs_sentinel = !matches!(prev, None | Some('\n'));
let padded = if needs_sentinel {
let mut p = String::with_capacity(segment.len() + SENTINEL.len_utf8());
p.push(SENTINEL);
p.push_str(segment);
p
} else {
segment.to_string()
};
let replaced = LIST_OR_HEADING.replace_all(&padded, "").into_owned();
if needs_sentinel {
replaced.strip_prefix(SENTINEL).map(str::to_string).unwrap_or(replaced)
} else {
replaced
}
}
fn resolve_url(url: &str, cfg: &CleanupConfig) -> String {
match cfg.urls {
UrlPolicy::Link => "link".to_string(),
UrlPolicy::Domain => host_of(url),
UrlPolicy::Keep => url.to_string(),
}
}
fn host_of(url: &str) -> String {
let after_scheme = url.split("://").nth(1).unwrap_or(url);
let authority_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..authority_end];
authority
.rsplit('@')
.next()
.unwrap_or(authority)
.split(':')
.next()
.unwrap_or(authority)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CleanupConfig, UrlPolicy};
fn all_on() -> CleanupConfig {
CleanupConfig::default()
}
#[test]
fn collapses_whitespace_runs() {
let c = all_on();
assert_eq!(clean("a b\n\n\tc", &c), "a b c");
}
#[test]
fn rejoins_hyphenated_line_breaks() {
let c = all_on();
assert_eq!(clean("inter-\nnational", &c), "international");
}
#[test]
fn replaces_urls_with_the_word_link() {
let c = all_on();
assert_eq!(
clean("see https://example.com/x?y=1 now", &c),
"see link now"
);
}
#[test]
fn url_policy_domain_keeps_the_host() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("see https://example.com/x now", &c),
"see example.com now"
);
}
#[test]
fn url_policy_keep_leaves_it_alone() {
let mut c = all_on();
c.urls = UrlPolicy::Keep;
assert_eq!(
clean("see https://example.com now", &c),
"see https://example.com now"
);
}
#[test]
fn strips_markdown_emphasis_and_code_ticks() {
let c = all_on();
assert_eq!(
clean("**bold** and `code` and _em_", &c),
"bold and code and em"
);
}
#[test]
fn strips_heading_hashes_and_list_bullets() {
let c = all_on();
assert_eq!(
clean("# Title\n- one\n* two\n1. three", &c),
"Title one two three"
);
}
#[test]
fn drops_fenced_code_blocks_entirely() {
let c = all_on();
let input = "before\n```rust\nfn main() {}\n```\nafter";
assert_eq!(clean(input, &c), "before after");
}
#[test]
fn unterminated_code_fence_drops_to_end_of_text() {
let c = all_on();
assert_eq!(clean("before\n```\nnever closed", &c), "before");
}
#[test]
fn spells_out_allcaps_acronyms() {
let c = all_on();
assert_eq!(clean("the HTLC failed", &c), "the H T L C failed");
}
#[test]
fn leaves_single_letters_and_normal_words_alone() {
let c = all_on();
assert_eq!(
clean("I am OK with A and the DKG", &c),
"I am OK with A and the D K G"
);
}
#[test]
fn strips_control_characters() {
let c = all_on();
assert_eq!(clean("a\u{0007}b\u{001b}c", &c), "abc");
}
#[test]
fn every_transform_can_be_disabled() {
let c = CleanupConfig {
collapse_whitespace: false,
rejoin_hyphenation: false,
urls: UrlPolicy::Keep,
strip_markdown: false,
drop_code_blocks: false,
spell_acronyms: false,
};
let input = "**x** https://a.b\nHTLC";
assert_eq!(clean(input, &c), input);
}
#[test]
fn a_realistic_terminal_selection() {
let c = all_on();
let input = "error[E0308]: mismatched types\n --> src/main.rs:12:5\n\nsee https://doc.rust-lang.org/E0308";
let out = clean(input, &c);
assert!(out.contains("mismatched types"));
assert!(out.contains("link"));
assert!(!out.contains('\n'));
}
#[test]
fn empty_input_stays_empty() {
assert_eq!(clean("", &all_on()), "");
}
#[test]
fn strips_embedded_nul_bytes() {
let c = all_on();
assert_eq!(clean("before\u{0000}after", &c), "beforeafter");
}
#[test]
fn url_policy_domain_strips_query_string_from_host() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("go to https://example.com?x=1&y=2 now", &c),
"go to example.com now"
);
}
#[test]
fn url_policy_domain_strips_fragment_from_host() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("see https://example.com#section", &c),
"see example.com"
);
}
#[test]
fn url_policy_domain_preserves_underscore_in_hostname() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("see https://my_site.example.com/path now", &c),
"see my_site.example.com now"
);
}
#[test]
fn url_policy_keep_preserves_underscore_when_stripping_markdown() {
let mut c = all_on();
c.urls = UrlPolicy::Keep;
c.strip_markdown = true;
assert_eq!(
clean("see https://example.com/foo_bar/baz now", &c),
"see https://example.com/foo_bar/baz now"
);
}
#[test]
fn url_policy_domain_strips_credentials_and_port() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("see https://user:pass@example.com:8080/path now", &c),
"see example.com now"
);
}
#[test]
fn stray_placeholder_codepoints_alongside_a_real_url_are_not_swapped() {
let c = all_on();
let out = clean("\u{E000}0\u{E001} see https://good.example.com/x now", &c);
assert!(out.contains('\u{E000}') && out.contains('\u{E001}'));
assert!(out.contains("link"));
assert_ne!(out, "link see link now");
}
#[test]
fn private_use_codepoints_with_no_url_pass_through_unmolested() {
let c = all_on();
let input = "prompt \u{E0B0} branch \u{E000}\u{E001} done";
assert_eq!(clean(input, &c), input);
}
#[test]
fn url_policy_keep_strips_surrounding_markdown_emphasis() {
let mut c = all_on();
c.urls = UrlPolicy::Keep;
c.strip_markdown = true;
assert_eq!(
clean("**https://example.com/a_b**", &c),
"https://example.com/a_b"
);
}
#[test]
fn multiple_urls_domain_policy() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean(
"see https://a.example.com/x and https://b.example.com/y now",
&c
),
"see a.example.com and b.example.com now"
);
}
#[test]
fn multiple_urls_keep_policy() {
let mut c = all_on();
c.urls = UrlPolicy::Keep;
assert_eq!(
clean(
"see https://a.example.com/x and https://b.example.com/y now",
&c
),
"see https://a.example.com/x and https://b.example.com/y now"
);
}
#[test]
fn host_of_mangles_ipv6_literals_known_wrong_deferred() {
let mut c = all_on();
c.urls = UrlPolicy::Domain;
assert_eq!(
clean("see https://[2001:db8::1]:8080/path now", &c),
"see [2001]:8080/path now"
);
}
#[test]
fn url_followed_by_punctuation_does_not_lose_its_separator() {
let c = all_on();
assert_eq!(
clean("see https://example.com/x - continued sentence", &c),
"see link - continued sentence"
);
assert_eq!(
clean("see https://example.com/x # not a heading", &c),
"see link # not a heading"
);
assert_eq!(
clean("call https://example.com/x 1. not a list", &c),
"call link 1. not a list"
);
}
#[test]
fn bullet_immediately_followed_by_url_is_still_stripped() {
let c = all_on();
assert_eq!(clean("- https://example.com/x", &c), "link");
}
#[test]
fn heading_and_numbered_list_still_stripped_alongside_a_url() {
let c = all_on();
let input = "# Title\n- see https://example.com/x\n1. done";
assert_eq!(clean(input, &c), "Title see link done");
}
#[test]
fn acronym_glued_directly_to_a_url_is_not_spelled_out() {
let c = all_on();
assert_eq!(clean("HTLChttps://example.com/x", &c), "HTLClink");
}
#[test]
fn acronym_separated_from_a_url_by_whitespace_is_still_spelled_out() {
let c = all_on();
assert_eq!(clean("HTLC https://example.com/x", &c), "H T L C link");
}
#[test]
fn url_at_very_start_and_very_end_of_input() {
let c = all_on();
assert_eq!(
clean("https://example.com/a middle https://example.com/b", &c),
"link middle link"
);
}
#[test]
fn hyphen_wrap_onto_a_marker_line_does_not_fuse_the_two_words() {
let c = all_on();
assert_eq!(
clean("topics include machine-\n- learning models", &c),
"topics include machine- learning models"
);
assert_eq!(
clean("topics include machine-\n# learning models", &c),
"topics include machine- learning models"
);
assert_eq!(
clean("topics include machine-\n* learning models", &c),
"topics include machine- learning models"
);
assert_eq!(
clean("topics include machine-\n1. learning models", &c),
"topics include machine1. learning models"
);
}
#[test]
fn genuine_bullet_heading_and_numbered_list_are_still_stripped() {
let c = all_on();
assert_eq!(clean("- one\n- two\n- three", &c), "one two three");
assert_eq!(clean("# Heading text", &c), "Heading text");
assert_eq!(clean("1. first\n2. second", &c), "first second");
}
#[test]
fn marker_after_interior_newline_is_still_stripped() {
let c = all_on();
assert_eq!(
clean("intro line\n- bullet after newline", &c),
"intro line bullet after newline"
);
}
}