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: ®ex::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}