Skip to main content

sayd_core/
cleanup.rs

1//! Turn selected text into something worth hearing.
2//!
3//! Order matters: code fences are dropped before anything inspects their
4//! contents, and whitespace is collapsed last so earlier removals do not
5//! leave gaps.
6//!
7//! URLs get special handling. After code fences are dropped, the remaining
8//! text is segmented into alternating runs of non-URL text and URL matches.
9//! Transforms that must never touch URL text — hyphenation rejoin, markdown
10//! stripping, acronym spelling — run only on the non-URL segments; the
11//! `UrlPolicy` replacement (the literal word "link", a bare host, or the URL
12//! verbatim) is computed only for the URL segments. The results are
13//! concatenated back together in their original order.
14//!
15//! This replaces an earlier placeholder-based scheme (hide URLs behind
16//! `\u{E000}<index>\u{E001}` markers, restore after the markdown/acronym
17//! passes) that assumed those private-use codepoints never occur in real
18//! input. That assumption doesn't hold: Nerd Font and Powerline glyphs live
19//! in exactly that codepoint range, and this daemon's primary input is
20//! terminal selections, so users routinely paste text containing them.
21//! Segmentation makes no assumption about which codepoints appear anywhere
22//! in the input — URL text and non-URL text are simply never in the same
23//! string at the same time while the URL-unsafe transforms run.
24//!
25//! The two remaining passes — the control-character strip and whitespace
26//! collapse — run once, globally, on the concatenated result, and that is
27//! safe:
28//!
29//! - The control-character strip is unconditional and must stay that way:
30//!   it is the only thing standing between an embedded NUL and a downstream
31//!   FFI `CString::new` call, and a test pins that guarantee. Running it
32//!   globally cannot corrupt a URL span because it only ever *removes*
33//!   characters, and a legitimate URL cannot contain a control character in
34//!   the first place — there is nothing there to protect.
35//! - Whitespace collapse is safe to run globally because the `URL` regex's
36//!   exclusion set already excludes whitespace from a URL match, so a URL
37//!   span can never contain, start with, or end with whitespace. Collapsing
38//!   whitespace runs elsewhere in the string can therefore never reach into
39//!   a URL span or merge two URL spans together.
40//!
41//! Two of the transforms that *do* run per-segment inside `clean_non_url`
42//! are anchor-based, and an anchor evaluated on an isolated segment does not
43//! necessarily correspond to a real boundary in the original, unsegmented
44//! input. Both are handled the same way: `clean_non_url` is given the real
45//! character that precedes (and, for `ACRONYM`, follows) the segment in the
46//! original input, and a sentinel is temporarily glued onto the segment edge
47//! so the anchor sees what it would have seen unsegmented, then stripped
48//! back off before the result is used.
49//!
50//! - `LIST_OR_HEADING` is anchored on `^` (line start, via `(?m)`). Handing
51//!   it an isolated segment is wrong: position 0 of a segment that begins
52//!   right after a URL is *not* a line start in the original text, but `^`
53//!   would match there anyway (position 0 of *any* string it is handed is a
54//!   line-start match, per `(?m)` semantics), misreading ordinary
55//!   punctuation that follows a URL (` - `, ` # `, ` 1. `) as a
56//!   bullet/heading/list marker. Position 0 of a segment is a genuine line
57//!   start only when `prev` — the character immediately preceding the
58//!   segment in the original input — is absent (segment starts at input
59//!   position 0) or is a newline. When `prev` is anything else, a sentinel
60//!   character is prepended before `LIST_OR_HEADING` runs, then stripped
61//!   back off; `(?m)^` still correctly matches after any newline *inside*
62//!   the segment, since the sentinel only occupies the position before the
63//!   segment, not any position within it. The sentinel used is `'\u{1}'`
64//!   (SOH, a C0 control character): it is not `\n`, so it never itself
65//!   becomes a line start for `^` to match after; it is not whitespace, so
66//!   `LIST_OR_HEADING`'s `\s*` cannot absorb it and then continue matching
67//!   into the segment's real leading whitespace; and it is none of `#`,
68//!   `-`, `*`, `+`, or a digit, so it can never itself begin a marker match.
69//!   No other transform runs while it is present — it is pushed and popped
70//!   within a single tightly-scoped step — so there is no window for it to
71//!   be matched or mangled by `EMPHASIS`, `HYPHEN_BREAK`, or anything else.
72//!   It also cannot leak into output even if some future bug skipped the
73//!   strip-back-off step: the global, unconditional control-character
74//!   filter at the end of `clean` removes every control character except
75//!   `\n`/`\t`, and SOH is neither. A test
76//!   (`bullet_immediately_followed_by_url_is_still_stripped`) confirms a
77//!   marker is still stripped when a URL immediately follows it, and
78//!   another (`marker_after_interior_newline_is_still_stripped`) confirms
79//!   the sentinel does not suppress a legitimate match after a newline
80//!   inside the segment.
81//! - `ACRONYM` is anchored on `\b` (word boundary) at both ends. Evaluated
82//!   on an isolated segment, `\b` at position 0 or at the end of the string
83//!   is computed against "nothing" on the outside — even when the original
84//!   input actually had an alphanumeric character right there (typically
85//!   the edge of an adjacent URL), which would have suppressed the
86//!   boundary. To reproduce full-string semantics, `clean_non_url` is given
87//!   the real character that precedes and follows the segment in the
88//!   original input; if that neighbor is alphanumeric, a one-character
89//!   lowercase-letter sentinel is temporarily glued onto that side of the
90//!   segment before `ACRONYM` runs (lowercase so it can never itself match
91//!   `[A-Z]{3,}`), reproducing the same "word character on the other side"
92//!   `\b` would have seen, and is stripped back off afterward.
93//!
94//! `LIST_OR_HEADING` and `HYPHEN_BREAK` both run inside `clean_non_url`, and
95//! their relative order is load-bearing in the other direction: hyphenation
96//! rejoin must run *first*. A hyphen-wrapped word can wrap onto a line that
97//! is itself a marker line (`"machine-\n- learning"`), and `HYPHEN_BREAK`
98//! needs to see the marker's leading `-` still in place to know there is a
99//! non-word character between the two word halves and decline to touch
100//! them; if the marker were stripped first, `HYPHEN_BREAK` would see
101//! `"machine-\nlearning"` and rejoin it, silently fusing what was — genuinely
102//! ambiguously, see the test below — either a wrapped hyphenated word or a
103//! new list item.
104
105use std::sync::LazyLock;
106
107use regex::Regex;
108
109use crate::config::{CleanupConfig, UrlPolicy};
110
111static CODE_FENCE: LazyLock<Regex> =
112    LazyLock::new(|| Regex::new(r"(?s)```.*?(?:```|$)").expect("static regex"));
113// Excludes whitespace and the delimiters a URL is typically wrapped in
114// (`<>()[]`), plus `*` and backtick, which are markdown emphasis/code
115// syntax rather than realistic URL content. `_` and `-` are deliberately
116// left in: both are common and legal in URLs (including hostnames).
117static URL: LazyLock<Regex> =
118    LazyLock::new(|| Regex::new(r"https?://[^\s<>\)\]*`]+").expect("static regex"));
119static HYPHEN_BREAK: LazyLock<Regex> =
120    LazyLock::new(|| Regex::new(r"(\w)-\s*\n\s*(\w)").expect("static regex"));
121static EMPHASIS: LazyLock<Regex> =
122    LazyLock::new(|| Regex::new(r"(\*\*|\*|__|_|`)").expect("static regex"));
123static LIST_OR_HEADING: LazyLock<Regex> =
124    LazyLock::new(|| Regex::new(r"(?m)^\s*(?:#{1,6}\s+|[-*+]\s+|\d+\.\s+)").expect("static regex"));
125// Match only 3+ letter acronyms; two-letter words like OK and ID are left alone intentionally.
126static ACRONYM: LazyLock<Regex> =
127    LazyLock::new(|| Regex::new(r"\b[A-Z]{3,}\b").expect("static regex"));
128static WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("static regex"));
129
130pub fn clean(text: &str, cfg: &CleanupConfig) -> String {
131    let mut s = text.to_string();
132
133    if cfg.drop_code_blocks {
134        s = CODE_FENCE.replace_all(&s, " ").into_owned();
135    }
136
137    // Segment into alternating non-URL / URL runs so no transform below can
138    // ever see both a URL and URL-unsafe syntax at once. See the module doc
139    // comment for the full reasoning.
140    let mut out = String::with_capacity(s.len());
141    let mut last = 0;
142    for m in URL.find_iter(&s) {
143        let segment = &s[last..m.start()];
144        let prev = s[..last].chars().next_back();
145        let next = s[m.start()..].chars().next();
146        out.push_str(&clean_non_url(segment, prev, next, cfg));
147        out.push_str(&resolve_url(m.as_str(), cfg));
148        last = m.end();
149    }
150    let segment = &s[last..];
151    let prev = s[..last].chars().next_back();
152    out.push_str(&clean_non_url(segment, prev, None, cfg));
153    s = out;
154
155    s = s
156        .chars()
157        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
158        .collect();
159
160    if cfg.collapse_whitespace {
161        s = WHITESPACE.replace_all(&s, " ").trim().to_string();
162    }
163
164    s
165}
166
167/// Apply the transforms that must never touch URL text to a non-URL segment.
168///
169/// `prev`/`next` are the characters that flank this segment in the
170/// *original, unsegmented* input (e.g. the last character of a preceding
171/// URL, or the first character of a following one) — `None` at the true
172/// start/end of the input. They exist so `LIST_OR_HEADING`'s `^` and
173/// `ACRONYM`'s `\b` anchors can be evaluated with full-string semantics; see
174/// the module doc comment.
175fn clean_non_url(
176    segment: &str,
177    prev: Option<char>,
178    next: Option<char>,
179    cfg: &CleanupConfig,
180) -> String {
181    let mut s = segment.to_string();
182
183    if cfg.rejoin_hyphenation {
184        s = HYPHEN_BREAK.replace_all(&s, "$1$2").into_owned();
185    }
186
187    if cfg.strip_markdown {
188        s = strip_list_or_heading(&s, prev);
189        s = EMPHASIS.replace_all(&s, "").into_owned();
190        s = s.replace('|', " ");
191    }
192
193    if cfg.spell_acronyms {
194        // `\b` at position 0 / end-of-string is computed against "nothing"
195        // outside the segment, even when the real input had an alphanumeric
196        // character right there. Reproduce that character with a lowercase
197        // (never matched by `[A-Z]{3,}`) sentinel so the boundary check
198        // sees what it would have seen unsegmented, then strip it back off.
199        const SENTINEL: char = 'x';
200        let prepend = prev.is_some_and(|c| c.is_alphanumeric());
201        let append = next.is_some_and(|c| c.is_alphanumeric());
202
203        let mut padded = String::with_capacity(s.len() + 2);
204        if prepend {
205            padded.push(SENTINEL);
206        }
207        padded.push_str(&s);
208        if append {
209            padded.push(SENTINEL);
210        }
211
212        let replaced = ACRONYM
213            .replace_all(&padded, |caps: &regex::Captures| {
214                caps[0]
215                    .chars()
216                    .map(|c| c.to_string())
217                    .collect::<Vec<_>>()
218                    .join(" ")
219            })
220            .into_owned();
221
222        let start = if prepend { SENTINEL.len_utf8() } else { 0 };
223        let end = replaced.len() - if append { SENTINEL.len_utf8() } else { 0 };
224        s = replaced[start..end].to_string();
225    }
226
227    s
228}
229
230/// Strip markdown list/heading markers from `segment`, giving `(?m)^`
231/// correct boundary knowledge about the *original, unsegmented* input.
232///
233/// Position 0 of `segment` is a genuine line start only when `prev` (the
234/// character immediately preceding this segment in the original input) is
235/// absent or is a newline; `(?m)^` cannot otherwise tell the difference
236/// between that and a segment that merely begins wherever a URL was cut out
237/// of the middle of a line. Whenever `prev` denotes anything else, a
238/// sentinel is prepended before `LIST_OR_HEADING` runs and stripped back off
239/// after. `(?m)^` still matches correctly after any newline *inside* the
240/// segment regardless, since the sentinel only ever occupies the position
241/// immediately before the segment's own text. See the module doc comment
242/// for why `'\u{1}'` was chosen as the sentinel.
243fn strip_list_or_heading(segment: &str, prev: Option<char>) -> String {
244    const SENTINEL: char = '\u{1}';
245    let needs_sentinel = !matches!(prev, None | Some('\n'));
246
247    let padded = if needs_sentinel {
248        let mut p = String::with_capacity(segment.len() + SENTINEL.len_utf8());
249        p.push(SENTINEL);
250        p.push_str(segment);
251        p
252    } else {
253        segment.to_string()
254    };
255
256    let replaced = LIST_OR_HEADING.replace_all(&padded, "").into_owned();
257
258    if needs_sentinel {
259        // The sentinel can never itself be consumed by `LIST_OR_HEADING`
260        // (see doc comment above), so it is always still there to strip.
261        replaced.strip_prefix(SENTINEL).map(str::to_string).unwrap_or(replaced)
262    } else {
263        replaced
264    }
265}
266
267/// Resolve a single matched URL span per `UrlPolicy`.
268fn resolve_url(url: &str, cfg: &CleanupConfig) -> String {
269    match cfg.urls {
270        UrlPolicy::Link => "link".to_string(),
271        UrlPolicy::Domain => host_of(url),
272        UrlPolicy::Keep => url.to_string(),
273    }
274}
275
276/// The host part of a URL, without scheme, port, path, query, fragment or
277/// credentials.
278///
279/// Known-wrong, deferred: this splits the authority on `:` to strip the
280/// port, which mangles IPv6 literals in brackets (e.g.
281/// `https://[2001:db8::1]:8080/path`) because they contain colons of their
282/// own. See `host_of_mangles_ipv6_literals_known_wrong_deferred` below for
283/// the pinned baseline.
284fn host_of(url: &str) -> String {
285    let after_scheme = url.split("://").nth(1).unwrap_or(url);
286    // The authority ends at the first path, query, or fragment delimiter.
287    let authority_end = after_scheme
288        .find(['/', '?', '#'])
289        .unwrap_or(after_scheme.len());
290    let authority = &after_scheme[..authority_end];
291    authority
292        .rsplit('@')
293        .next()
294        .unwrap_or(authority)
295        .split(':')
296        .next()
297        .unwrap_or(authority)
298        .to_string()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::config::{CleanupConfig, UrlPolicy};
305
306    fn all_on() -> CleanupConfig {
307        CleanupConfig::default()
308    }
309
310    #[test]
311    fn collapses_whitespace_runs() {
312        let c = all_on();
313        assert_eq!(clean("a   b\n\n\tc", &c), "a b c");
314    }
315
316    #[test]
317    fn rejoins_hyphenated_line_breaks() {
318        let c = all_on();
319        assert_eq!(clean("inter-\nnational", &c), "international");
320    }
321
322    #[test]
323    fn replaces_urls_with_the_word_link() {
324        let c = all_on();
325        assert_eq!(
326            clean("see https://example.com/x?y=1 now", &c),
327            "see link now"
328        );
329    }
330
331    #[test]
332    fn url_policy_domain_keeps_the_host() {
333        let mut c = all_on();
334        c.urls = UrlPolicy::Domain;
335        assert_eq!(
336            clean("see https://example.com/x now", &c),
337            "see example.com now"
338        );
339    }
340
341    #[test]
342    fn url_policy_keep_leaves_it_alone() {
343        let mut c = all_on();
344        c.urls = UrlPolicy::Keep;
345        assert_eq!(
346            clean("see https://example.com now", &c),
347            "see https://example.com now"
348        );
349    }
350
351    #[test]
352    fn strips_markdown_emphasis_and_code_ticks() {
353        let c = all_on();
354        assert_eq!(
355            clean("**bold** and `code` and _em_", &c),
356            "bold and code and em"
357        );
358    }
359
360    #[test]
361    fn strips_heading_hashes_and_list_bullets() {
362        let c = all_on();
363        assert_eq!(
364            clean("# Title\n- one\n* two\n1. three", &c),
365            "Title one two three"
366        );
367    }
368
369    #[test]
370    fn drops_fenced_code_blocks_entirely() {
371        let c = all_on();
372        let input = "before\n```rust\nfn main() {}\n```\nafter";
373        assert_eq!(clean(input, &c), "before after");
374    }
375
376    #[test]
377    fn unterminated_code_fence_drops_to_end_of_text() {
378        let c = all_on();
379        assert_eq!(clean("before\n```\nnever closed", &c), "before");
380    }
381
382    #[test]
383    fn spells_out_allcaps_acronyms() {
384        let c = all_on();
385        assert_eq!(clean("the HTLC failed", &c), "the H T L C failed");
386    }
387
388    #[test]
389    fn leaves_single_letters_and_normal_words_alone() {
390        let c = all_on();
391        assert_eq!(
392            clean("I am OK with A and the DKG", &c),
393            "I am OK with A and the D K G"
394        );
395    }
396
397    #[test]
398    fn strips_control_characters() {
399        let c = all_on();
400        assert_eq!(clean("a\u{0007}b\u{001b}c", &c), "abc");
401    }
402
403    #[test]
404    fn every_transform_can_be_disabled() {
405        let c = CleanupConfig {
406            collapse_whitespace: false,
407            rejoin_hyphenation: false,
408            urls: UrlPolicy::Keep,
409            strip_markdown: false,
410            drop_code_blocks: false,
411            spell_acronyms: false,
412        };
413        let input = "**x**  https://a.b\nHTLC";
414        assert_eq!(clean(input, &c), input);
415    }
416
417    #[test]
418    fn a_realistic_terminal_selection() {
419        let c = all_on();
420        let input = "error[E0308]: mismatched types\n  --> src/main.rs:12:5\n\nsee https://doc.rust-lang.org/E0308";
421        let out = clean(input, &c);
422        assert!(out.contains("mismatched types"));
423        assert!(out.contains("link"));
424        assert!(!out.contains('\n'));
425    }
426
427    #[test]
428    fn empty_input_stays_empty() {
429        assert_eq!(clean("", &all_on()), "");
430    }
431
432    #[test]
433    fn strips_embedded_nul_bytes() {
434        let c = all_on();
435        assert_eq!(clean("before\u{0000}after", &c), "beforeafter");
436    }
437
438    #[test]
439    fn url_policy_domain_strips_query_string_from_host() {
440        let mut c = all_on();
441        c.urls = UrlPolicy::Domain;
442        assert_eq!(
443            clean("go to https://example.com?x=1&y=2 now", &c),
444            "go to example.com now"
445        );
446    }
447
448    #[test]
449    fn url_policy_domain_strips_fragment_from_host() {
450        let mut c = all_on();
451        c.urls = UrlPolicy::Domain;
452        assert_eq!(
453            clean("see https://example.com#section", &c),
454            "see example.com"
455        );
456    }
457
458    #[test]
459    fn url_policy_domain_preserves_underscore_in_hostname() {
460        let mut c = all_on();
461        c.urls = UrlPolicy::Domain;
462        assert_eq!(
463            clean("see https://my_site.example.com/path now", &c),
464            "see my_site.example.com now"
465        );
466    }
467
468    #[test]
469    fn url_policy_keep_preserves_underscore_when_stripping_markdown() {
470        let mut c = all_on();
471        c.urls = UrlPolicy::Keep;
472        c.strip_markdown = true;
473        assert_eq!(
474            clean("see https://example.com/foo_bar/baz now", &c),
475            "see https://example.com/foo_bar/baz now"
476        );
477    }
478
479    #[test]
480    fn url_policy_domain_strips_credentials_and_port() {
481        let mut c = all_on();
482        c.urls = UrlPolicy::Domain;
483        assert_eq!(
484            clean("see https://user:pass@example.com:8080/path now", &c),
485            "see example.com now"
486        );
487    }
488
489    // -- Placeholder-delimiter collision (Finding 1) -----------------------
490
491    #[test]
492    fn stray_placeholder_codepoints_alongside_a_real_url_are_not_swapped() {
493        // The old scheme hid URLs behind `\u{E000}<index>\u{E001}` and
494        // restored them with a whole-string `str::replace`. Input already
495        // containing those exact codepoints collided with a real
496        // placeholder of the same index and got silently overwritten with
497        // unrelated URL text. With segmentation there is no placeholder to
498        // collide with, so the stray codepoints must survive untouched and
499        // the URL must resolve independently.
500        let c = all_on();
501        let out = clean("\u{E000}0\u{E001} see https://good.example.com/x now", &c);
502        assert!(out.contains('\u{E000}') && out.contains('\u{E001}'));
503        assert!(out.contains("link"));
504        assert_ne!(out, "link see link now");
505    }
506
507    #[test]
508    fn private_use_codepoints_with_no_url_pass_through_unmolested() {
509        // Nerd Font / Powerline glyphs live in this exact private-use
510        // range, and terminal selections are this daemon's primary input,
511        // so these codepoints show up with no URL anywhere nearby. The old
512        // scheme leaked raw, unresolved placeholder codepoints into speech
513        // whenever the index didn't match a real URL; segmentation never
514        // introduces a placeholder in the first place.
515        let c = all_on();
516        let input = "prompt \u{E0B0} branch \u{E000}\u{E001} done";
517        assert_eq!(clean(input, &c), input);
518    }
519
520    // -- URL regex absorbing trailing markdown (Finding 2) ------------------
521
522    #[test]
523    fn url_policy_keep_strips_surrounding_markdown_emphasis() {
524        let mut c = all_on();
525        c.urls = UrlPolicy::Keep;
526        c.strip_markdown = true;
527        assert_eq!(
528            clean("**https://example.com/a_b**", &c),
529            "https://example.com/a_b"
530        );
531    }
532
533    #[test]
534    fn multiple_urls_domain_policy() {
535        let mut c = all_on();
536        c.urls = UrlPolicy::Domain;
537        assert_eq!(
538            clean(
539                "see https://a.example.com/x and https://b.example.com/y now",
540                &c
541            ),
542            "see a.example.com and b.example.com now"
543        );
544    }
545
546    #[test]
547    fn multiple_urls_keep_policy() {
548        let mut c = all_on();
549        c.urls = UrlPolicy::Keep;
550        assert_eq!(
551            clean(
552                "see https://a.example.com/x and https://b.example.com/y now",
553                &c
554            ),
555            "see https://a.example.com/x and https://b.example.com/y now"
556        );
557    }
558
559    // -- Deferred: IPv6 literal baseline -------------------------------------
560
561    #[test]
562    fn host_of_mangles_ipv6_literals_known_wrong_deferred() {
563        // `host_of` strips the port by splitting the authority on `:`,
564        // which is wrong for a bracketed IPv6 literal: the literal's own
565        // colons get split too, truncating the host to `[2001`. This
566        // predates both fix rounds and is intentionally left as-is; this
567        // test only pins the current (wrong) behaviour as a baseline for a
568        // later fix.
569        let mut c = all_on();
570        c.urls = UrlPolicy::Domain;
571        // The URL regex also stops at the literal's own `]` (excluded as a
572        // URL-wrapping delimiter), so only `https://[2001:db8::1` is
573        // matched as the URL; the rest becomes a trailing non-URL segment.
574        assert_eq!(
575            clean("see https://[2001:db8::1]:8080/path now", &c),
576            "see [2001]:8080/path now"
577        );
578    }
579
580    // -- Segment-boundary anchors (Finding 1: LIST_OR_HEADING's `^`) --------
581
582    #[test]
583    fn url_followed_by_punctuation_does_not_lose_its_separator() {
584        // Regression: when LIST_OR_HEADING ran per non-URL segment, the
585        // segment right after a URL started at position 0 of its own
586        // string, which `^` (a line-start anchor) matched even though that
587        // position is not a real line start in the original input. That
588        // misread ordinary punctuation after a URL as a markdown marker and
589        // silently ate it along with its whitespace, gluing words together.
590        let c = all_on();
591        assert_eq!(
592            clean("see https://example.com/x - continued sentence", &c),
593            "see link - continued sentence"
594        );
595        assert_eq!(
596            clean("see https://example.com/x # not a heading", &c),
597            "see link # not a heading"
598        );
599        assert_eq!(
600            clean("call https://example.com/x 1. not a list", &c),
601            "call link 1. not a list"
602        );
603    }
604
605    #[test]
606    fn bullet_immediately_followed_by_url_is_still_stripped() {
607        // Confirms the reasoning in the module doc comment: LIST_OR_HEADING
608        // now runs once over the whole input before segmentation, but a
609        // marker that is genuinely at a line start — even one immediately
610        // followed by a URL — must still be recognized and stripped. This
611        // guards against over-correcting Finding 1 into never stripping
612        // anything near a URL.
613        let c = all_on();
614        assert_eq!(clean("- https://example.com/x", &c), "link");
615    }
616
617    #[test]
618    fn heading_and_numbered_list_still_stripped_alongside_a_url() {
619        let c = all_on();
620        let input = "# Title\n- see https://example.com/x\n1. done";
621        assert_eq!(clean(input, &c), "Title see link done");
622    }
623
624    // -- Segment-boundary anchors (Finding 2: ACRONYM's `\b`) ----------------
625
626    #[test]
627    fn acronym_glued_directly_to_a_url_is_not_spelled_out() {
628        // Regression: an acronym with no separating whitespace before a URL
629        // has no real word boundary between them in the original text, so
630        // it must not be spelled out. Both later designs (placeholder swap,
631        // naive segmentation) regressed this by different mechanisms; the
632        // round-1 implementation got it right because it ran ACRONYM once
633        // over the whole string.
634        let c = all_on();
635        assert_eq!(clean("HTLChttps://example.com/x", &c), "HTLClink");
636    }
637
638    #[test]
639    fn acronym_separated_from_a_url_by_whitespace_is_still_spelled_out() {
640        // Guards against over-correcting Finding 2: the sentinel padding
641        // must not suppress spelling out a legitimate acronym just because
642        // a URL happens to follow later in the segment.
643        let c = all_on();
644        assert_eq!(clean("HTLC https://example.com/x", &c), "H T L C link");
645    }
646
647    #[test]
648    fn url_at_very_start_and_very_end_of_input() {
649        let c = all_on();
650        assert_eq!(
651            clean("https://example.com/a middle https://example.com/b", &c),
652            "link middle link"
653        );
654    }
655
656    // -- Fourth round: LIST_OR_HEADING restored to per-segment, sentinel-padded ^ --
657
658    #[test]
659    fn hyphen_wrap_onto_a_marker_line_does_not_fuse_the_two_words() {
660        // "topics include machine-\n- learning models": whether this is a
661        // hyphenated word wrapped across a line that happens to start with
662        // "- learning", or a genuinely new list item reading "learning
663        // models", is not decidable from the text alone — the source is
664        // ambiguous. What matters is that HYPHEN_BREAK must not silently
665        // fuse "machine" and "learning" into "machinelearning" with the
666        // marker deleted out from under it (the bug this round fixes).
667        // Restoring hyphenation-then-markers order means HYPHEN_BREAK sees
668        // the marker character still in place and (for the non-digit
669        // markers) declines to match, so the two halves stay separated once
670        // the marker is later stripped.
671        let c = all_on();
672        assert_eq!(
673            clean("topics include machine-\n- learning models", &c),
674            "topics include machine- learning models"
675        );
676        assert_eq!(
677            clean("topics include machine-\n# learning models", &c),
678            "topics include machine- learning models"
679        );
680        assert_eq!(
681            clean("topics include machine-\n* learning models", &c),
682            "topics include machine- learning models"
683        );
684        // The numbered-list marker pins a different (also not-fused, but not
685        // identical) outcome: `\d` is itself a `\w` character, so
686        // HYPHEN_BREAK's own `(\w)` capture matches the leading digit of
687        // "1." directly, rejoining across the newline before LIST_OR_HEADING
688        // gets a chance to see a line-start "1." to strip. The digit ends up
689        // glued to "machine" with the hyphen dropped, rather than a bullet
690        // marker being stripped and a space surviving. This is pinned as
691        // observed behavior, not endorsed as "correct" — the ambiguity
692        // above applies here too, and `\d`-vs-`\w` overlap is a pre-existing
693        // property of these two regexes, not something this round's fix
694        // introduced or is scoped to change.
695        assert_eq!(
696            clean("topics include machine-\n1. learning models", &c),
697            "topics include machine1. learning models"
698        );
699    }
700
701    #[test]
702    fn genuine_bullet_heading_and_numbered_list_are_still_stripped() {
703        // Guards against under-correcting this round's fix: a real bullet
704        // list, heading, and numbered list (no URL, no ambiguity) must still
705        // be recognized and stripped exactly as before.
706        let c = all_on();
707        assert_eq!(clean("- one\n- two\n- three", &c), "one two three");
708        assert_eq!(clean("# Heading text", &c), "Heading text");
709        assert_eq!(clean("1. first\n2. second", &c), "first second");
710    }
711
712    #[test]
713    fn marker_after_interior_newline_is_still_stripped() {
714        // Proves the sentinel prepended at the *segment start* does not
715        // suppress a legitimate `(?m)^` match after a newline further inside
716        // the same segment — the sentinel only ever occupies the position
717        // immediately before the segment, never a position within it.
718        let c = all_on();
719        assert_eq!(
720            clean("intro line\n- bullet after newline", &c),
721            "intro line bullet after newline"
722        );
723    }
724}