1#![forbid(unsafe_code)]
62#![warn(missing_docs)]
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum CitationForm {
67 HtmlAnchor,
69 MarkdownLink,
71 Footnote,
73 BareUrl,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Citation {
80 pub url: String,
82 pub label: String,
85 pub marker: Option<u32>,
87 pub form: CitationForm,
89 pub offset: usize,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct HostCount {
96 pub host: String,
98 pub citations: usize,
100}
101
102fn is_http(url: &str) -> bool {
104 let b = url.as_bytes();
105 let lower = |i: usize| b.get(i).map(|c| c.to_ascii_lowercase());
106 if lower(0) == Some(b'h') && lower(1) == Some(b't') && lower(2) == Some(b't') && lower(3) == Some(b'p') {
107 match lower(4) {
108 Some(b':') => url.get(5..7) == Some("//"),
109 Some(b's') => lower(5) == Some(b':') && url.get(6..8) == Some("//"),
110 _ => false,
111 }
112 } else {
113 false
114 }
115}
116
117fn decode_entities(s: &str) -> String {
120 if !s.contains('&') {
121 return s.to_string();
122 }
123 let mut out = String::with_capacity(s.len());
124 let mut rest = s;
125 while let Some(i) = rest.find('&') {
126 out.push_str(&rest[..i]);
127 let tail = &rest[i..];
128 let end = tail.find(';').filter(|&e| e <= 10);
129 match end {
130 Some(e) => {
131 let ent = &tail[1..e];
132 let decoded = match ent {
133 "amp" => Some('&'),
134 "lt" => Some('<'),
135 "gt" => Some('>'),
136 "quot" => Some('"'),
137 "apos" => Some('\''),
138 "nbsp" => Some(' '),
139 _ if ent.starts_with("#x") || ent.starts_with("#X") => {
140 u32::from_str_radix(&ent[2..], 16).ok().and_then(char::from_u32)
141 }
142 _ if ent.starts_with('#') => ent[1..].parse::<u32>().ok().and_then(char::from_u32),
143 _ => None,
144 };
145 match decoded {
146 Some(c) => {
147 out.push(c);
148 rest = &tail[e + 1..];
149 }
150 None => {
151 out.push('&');
152 rest = &tail[1..];
153 }
154 }
155 }
156 None => {
157 out.push('&');
158 rest = &tail[1..];
159 }
160 }
161 }
162 out.push_str(rest);
163 out
164}
165
166fn clean_label(raw: &str) -> String {
168 let mut out = String::with_capacity(raw.len());
169 let mut in_tag = false;
170 for c in raw.chars() {
171 match c {
172 '<' => in_tag = true,
173 '>' if in_tag => in_tag = false,
174 _ if in_tag => {}
175 _ => out.push(c),
176 }
177 }
178 let decoded = decode_entities(&out);
179 let mut collapsed = String::with_capacity(decoded.len());
180 let mut pending_space = false;
181 for c in decoded.chars() {
182 if c.is_whitespace() {
183 pending_space = !collapsed.is_empty();
184 } else {
185 if pending_space {
186 collapsed.push(' ');
187 pending_space = false;
188 }
189 collapsed.push(c);
190 }
191 }
192 collapsed
193}
194
195fn marker_of(label: &str) -> Option<u32> {
197 let t = label.trim();
198 let t = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')).unwrap_or(t);
199 let t = t.strip_prefix('(').and_then(|s| s.strip_suffix(')')).unwrap_or(t);
200 let t = t.strip_prefix('^').unwrap_or(t);
201 let t = t.strip_suffix('.').unwrap_or(t);
202 if t.is_empty() || t.len() > 4 || !t.bytes().all(|b| b.is_ascii_digit()) {
203 return None;
204 }
205 t.parse().ok()
206}
207
208fn ends_bare_url(c: char) -> bool {
210 c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'' | ')' | ']' | '}' | '|' | '`')
211}
212
213fn trim_bare_url(s: &str) -> &str {
216 let mut end = s.len();
217 while end > 0 {
218 let last = s[..end].chars().next_back().unwrap();
219 if matches!(last, '.' | ',' | ';' | ':' | '!' | '?' | '*' | '_' | '~') {
220 end -= last.len_utf8();
221 } else {
222 break;
223 }
224 }
225 &s[..end]
226}
227
228fn scan_html(text: &str, out: &mut Vec<(usize, usize, String, String)>) {
230 let lower = text.to_ascii_lowercase();
231 let mut pos = 0;
232 while let Some(rel) = lower[pos..].find("<a") {
233 let start = pos + rel;
234 let after = start + 2;
235 match lower.as_bytes().get(after) {
237 Some(b) if b.is_ascii_whitespace() => {}
238 _ => {
239 pos = after;
240 continue;
241 }
242 }
243 let Some(tag_close_rel) = lower[after..].find('>') else { break };
244 let tag_close = after + tag_close_rel;
245 let attrs = &text[after..tag_close];
246 let attrs_lower = &lower[after..tag_close];
247 pos = tag_close + 1;
248 let Some(href_rel) = attrs_lower.find("href") else { continue };
249 let mut i = href_rel + 4;
250 let ab = attrs.as_bytes();
251 while i < ab.len() && ab[i].is_ascii_whitespace() {
252 i += 1;
253 }
254 if ab.get(i) != Some(&b'=') {
255 continue;
256 }
257 i += 1;
258 while i < ab.len() && ab[i].is_ascii_whitespace() {
259 i += 1;
260 }
261 let raw_href = match ab.get(i) {
262 Some(&q @ (b'"' | b'\'')) => {
263 let vs = i + 1;
264 match attrs[vs..].find(q as char) {
265 Some(ve) => &attrs[vs..vs + ve],
266 None => continue,
267 }
268 }
269 Some(_) => {
270 let vs = i;
271 let ve = attrs[vs..]
272 .find(|c: char| c.is_ascii_whitespace())
273 .map(|e| vs + e)
274 .unwrap_or(attrs.len());
275 &attrs[vs..ve]
276 }
277 None => continue,
278 };
279 let href = decode_entities(raw_href.trim());
280 let end_rel = lower[pos..].find("</a>");
282 let (label_raw, end) = match end_rel {
283 Some(e) => (&text[pos..pos + e], pos + e + 4),
284 None => ("", pos),
285 };
286 let label = clean_label(label_raw);
287 out.push((start, end, href, label));
288 pos = end;
289 }
290}
291
292fn scan_markdown(text: &str, out: &mut Vec<(usize, usize, String, String, Option<CitationForm>)>) {
294 let b = text.as_bytes();
295 let mut i = 0;
296 while i < b.len() {
297 if b[i] != b'[' {
298 i += 1;
299 continue;
300 }
301 let is_image = i > 0 && b[i - 1] == b'!';
304 let mut depth = 0usize;
306 let mut j = i;
307 let mut close = None;
308 while j < b.len() {
309 match b[j] {
310 b'[' => depth += 1,
311 b']' => {
312 depth -= 1;
313 if depth == 0 {
314 close = Some(j);
315 break;
316 }
317 }
318 b'\n' if depth == 1 && j + 1 < b.len() && b[j + 1] == b'\n' => break,
319 _ => {}
320 }
321 j += 1;
322 }
323 let Some(close) = close else {
324 i += 1;
325 continue;
326 };
327 let label = &text[i + 1..close];
328 match b.get(close + 1) {
329 Some(b'(') => {
330 let us = close + 2;
332 let mut k = us;
333 let mut paren = 0usize;
334 while k < b.len() {
335 match b[k] {
336 b'(' => paren += 1,
337 b')' if paren == 0 => break,
338 b')' => paren -= 1,
339 b' ' | b'\t' | b'\n' => break,
340 _ => {}
341 }
342 k += 1;
343 }
344 let url = &text[us..k];
345 let Some(close_paren_rel) = text[k..].find(')') else {
346 i = close + 1;
347 continue;
348 };
349 let end = k + close_paren_rel + 1;
350 out.push((i, end, url.to_string(), clean_label(label), if is_image { None } else { Some(CitationForm::MarkdownLink) }));
351 i = end;
352 }
353 Some(b':') => {
354 let at_line_start = text[..i].trim_end_matches([' ', '\t']).ends_with('\n') || text[..i].trim().is_empty();
356 if !at_line_start {
357 i = close + 1;
358 continue;
359 }
360 let us = close + 2;
361 let rest = &text[us..];
362 let url_start = us + (rest.len() - rest.trim_start_matches([' ', '\t']).len());
363 let url_end = text[url_start..]
364 .find(|c: char| c.is_whitespace())
365 .map(|e| url_start + e)
366 .unwrap_or(text.len());
367 let url = text[url_start..url_end].trim_matches(['<', '>']);
368 out.push((i, url_end, url.to_string(), clean_label(label), Some(CitationForm::Footnote)));
369 i = url_end;
370 }
371 _ => {
372 i = close + 1;
373 }
374 }
375 }
376}
377
378pub fn extract(text: &str) -> Vec<Citation> {
394 let mut found: Vec<Citation> = Vec::new();
395 let mut spans: Vec<(usize, usize)> = Vec::new();
396
397 let mut html = Vec::new();
398 scan_html(text, &mut html);
399 for (start, end, href, label) in html {
400 spans.push((start, end));
401 if is_http(&href) {
402 found.push(Citation {
403 marker: marker_of(&label),
404 url: href,
405 label,
406 form: CitationForm::HtmlAnchor,
407 offset: start,
408 });
409 }
410 }
411
412 let mut md = Vec::new();
413 scan_markdown(text, &mut md);
414 for (start, end, url, label, form) in md {
415 if spans.iter().any(|&(s, e)| start >= s && start < e) {
416 continue;
417 }
418 spans.push((start, end));
419 let Some(form) = form else { continue };
420 if is_http(&url) {
421 found.push(Citation {
422 marker: marker_of(&label),
423 url,
424 label: if form == CitationForm::Footnote { String::new() } else { label },
425 form,
426 offset: start,
427 });
428 }
429 }
430
431 let lower = text.to_ascii_lowercase();
433 let mut pos = 0;
434 while let Some(rel) = lower[pos..].find("http") {
435 let start = pos + rel;
436 if !is_http(&text[start..]) {
437 pos = start + 4;
438 continue;
439 }
440 let end = text[start..]
441 .find(ends_bare_url)
442 .map(|e| start + e)
443 .unwrap_or(text.len());
444 let url = trim_bare_url(&text[start..end]);
445 pos = end.max(start + 1);
446 if spans.iter().any(|&(s, e)| start >= s && start < e) {
447 continue;
448 }
449 if url.len() <= 8 || (start > 0 && !ends_bare_url(text[..start].chars().next_back().unwrap()) && !text[..start].ends_with('(')) {
451 continue;
452 }
453 found.push(Citation {
454 url: url.to_string(),
455 label: String::new(),
456 marker: None,
457 form: CitationForm::BareUrl,
458 offset: start,
459 });
460 }
461
462 found.sort_by_key(|c| c.offset);
463 found
464}
465
466pub const TRACKING_KEYS: &[&str] = &[
469 "fbclid", "gclid", "dclid", "gbraid", "wbraid", "msclkid", "srsltid", "igshid", "mc_cid",
470 "mc_eid", "yclid", "twclid", "ttclid", "_hsenc", "_hsmi", "vero_id", "oly_enc_id", "oly_anon_id",
471];
472
473pub fn canonical(url: &str) -> String {
488 if !is_http(url) {
489 return url.to_string();
490 }
491 let scheme_end = url.find("://").unwrap();
492 let scheme = url[..scheme_end].to_ascii_lowercase();
493 let rest = &url[scheme_end + 3..];
494 let rest = rest.split('#').next().unwrap_or("");
495 let (authority, path_query) = match rest.find(['/', '?']) {
496 Some(i) => (&rest[..i], &rest[i..]),
497 None => (rest, ""),
498 };
499 let mut authority = authority.to_ascii_lowercase();
500 let default_port = if scheme == "https" { ":443" } else { ":80" };
501 if authority.ends_with(default_port) {
502 authority.truncate(authority.len() - default_port.len());
503 }
504 let (path, query) = match path_query.find('?') {
505 Some(i) => (&path_query[..i], Some(&path_query[i + 1..])),
506 None => (path_query, None),
507 };
508 let path = if path.is_empty() { "/" } else { path };
509 let mut out = format!("{scheme}://{authority}{path}");
510 if let Some(q) = query {
511 let kept: Vec<&str> = q
512 .split('&')
513 .filter(|kv| !kv.is_empty())
514 .filter(|kv| {
515 let key = kv.split('=').next().unwrap_or("").to_ascii_lowercase();
516 !(key.starts_with("utm_") || TRACKING_KEYS.contains(&key.as_str()))
517 })
518 .collect();
519 if !kept.is_empty() {
520 out.push('?');
521 out.push_str(&kept.join("&"));
522 }
523 }
524 out
525}
526
527pub fn host(url: &str) -> Option<String> {
536 if !is_http(url) {
537 return None;
538 }
539 let rest = &url[url.find("://").unwrap() + 3..];
540 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
541 let authority = authority.rsplit('@').next().unwrap_or(authority);
542 let h = authority.split(':').next().unwrap_or("").to_ascii_lowercase();
543 if h.is_empty() {
544 return None;
545 }
546 Some(h.strip_prefix("www.").unwrap_or(&h).to_string())
547}
548
549pub fn chatgpt_tagged(url: &str) -> bool {
558 let Some(q) = url.split('#').next().and_then(|u| u.split_once('?')).map(|(_, q)| q) else {
559 return false;
560 };
561 q.split('&').any(|kv| kv.eq_ignore_ascii_case("utm_source=chatgpt.com"))
562}
563
564pub fn tally(citations: &[Citation]) -> Vec<HostCount> {
568 let mut counts: Vec<HostCount> = Vec::new();
569 for c in citations {
570 let Some(h) = host(&c.url) else { continue };
571 match counts.iter_mut().find(|e| e.host == h) {
572 Some(e) => e.citations += 1,
573 None => counts.push(HostCount { host: h, citations: 1 }),
574 }
575 }
576 counts.sort_by(|a, b| b.citations.cmp(&a.citations).then_with(|| a.host.cmp(&b.host)));
577 counts
578}
579
580pub fn distinct_pages(citations: &[Citation]) -> Vec<String> {
588 let mut seen: Vec<String> = Vec::new();
589 for c in citations {
590 let k = canonical(&c.url);
591 if !seen.contains(&k) {
592 seen.push(k);
593 }
594 }
595 seen
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn html_anchor_with_marker() {
604 let c = extract(r#"text<a href="https://x.example/p">[2]</a>"#);
605 assert_eq!(c.len(), 1);
606 assert_eq!(c[0].url, "https://x.example/p");
607 assert_eq!(c[0].label, "[2]");
608 assert_eq!(c[0].marker, Some(2));
609 assert_eq!(c[0].form, CitationForm::HtmlAnchor);
610 assert_eq!(c[0].offset, 4);
611 }
612
613 #[test]
614 fn html_anchor_single_quotes_and_unquoted() {
615 let c = extract("<a href='https://a.example/'>A</a> <a href=https://b.example/ target=_blank>B</a>");
616 assert_eq!(c.len(), 2);
617 assert_eq!(c[0].url, "https://a.example/");
618 assert_eq!(c[1].url, "https://b.example/");
619 assert_eq!(c[1].label, "B");
620 }
621
622 #[test]
623 fn html_anchor_entities_decoded_in_href_and_label() {
624 let c = extract(r#"<a href="https://x.example/?a=1&b=2">Tom & Jerry</a>"#);
625 assert_eq!(c[0].url, "https://x.example/?a=1&b=2");
626 assert_eq!(c[0].label, "Tom & Jerry");
627 }
628
629 #[test]
630 fn html_label_tags_stripped_and_whitespace_collapsed() {
631 let c = extract("<a href=\"https://x.example/\">\n <span>Site</span>\n <b>name</b>\n</a>");
632 assert_eq!(c[0].label, "Site name");
633 }
634
635 #[test]
636 fn html_case_insensitive_and_abbr_not_matched() {
637 let c = extract(r#"<abbr title="x">y</abbr><A HREF="HTTPS://x.example/">Z</A>"#);
638 assert_eq!(c.len(), 1);
639 assert_eq!(c[0].url, "HTTPS://x.example/");
640 }
641
642 #[test]
643 fn non_http_hrefs_skipped() {
644 let c = extract(r##"<a href="#top">t</a><a href="mailto:a@b.c">m</a><a href="/rel">r</a><a href="javascript:void(0)">j</a>"##);
645 assert!(c.is_empty());
646 }
647
648 #[test]
649 fn anchor_without_href_skipped() {
650 let c = extract(r#"<a name="x">anchor</a> https://y.example/"#);
651 assert_eq!(c.len(), 1);
652 assert_eq!(c[0].form, CitationForm::BareUrl);
653 }
654
655 #[test]
656 fn markdown_inline_link() {
657 let c = extract("See [the study](https://s.example/r \"Title\") today.");
658 assert_eq!(c.len(), 1);
659 assert_eq!(c[0].url, "https://s.example/r");
660 assert_eq!(c[0].label, "the study");
661 assert_eq!(c[0].form, CitationForm::MarkdownLink);
662 assert_eq!(c[0].marker, None);
663 }
664
665 #[test]
666 fn markdown_marker_labels() {
667 let c = extract("[1](https://a.example/) [^2](https://b.example/) [(3)](https://c.example/)");
668 assert_eq!(c.iter().map(|c| c.marker).collect::<Vec<_>>(), vec![Some(1), Some(2), Some(3)]);
669 }
670
671 #[test]
672 fn markdown_image_not_a_citation() {
673 let c = extract(" and [ok](https://ok.example/)");
674 assert_eq!(c.len(), 1);
675 assert_eq!(c[0].url, "https://ok.example/");
676 }
677
678 #[test]
679 fn markdown_url_with_balanced_parens() {
680 let c = extract("[tea](https://en.wikipedia.org/wiki/Tea_(drink))!");
681 assert_eq!(c[0].url, "https://en.wikipedia.org/wiki/Tea_(drink)");
682 }
683
684 #[test]
685 fn markdown_reference_definitions() {
686 let c = extract("Body.\n\n[1]: https://a.example/x\n [2]: <https://b.example/y> \"t\"\n[site]: https://c.example/");
687 assert_eq!(c.len(), 3);
688 assert!(c.iter().all(|c| c.form == CitationForm::Footnote));
689 assert_eq!(c[0].marker, Some(1));
690 assert_eq!(c[1].url, "https://b.example/y");
691 assert_eq!(c[2].marker, None);
692 }
693
694 #[test]
695 fn reference_definition_mid_line_is_not_one() {
696 let c = extract("see [1]: https://a.example/x");
697 assert_eq!(c.len(), 1);
698 assert_eq!(c[0].form, CitationForm::BareUrl);
699 }
700
701 #[test]
702 fn bare_url_trailing_punctuation_trimmed() {
703 let c = extract("Read https://a.example/p. Then https://b.example/q, and (https://c.example/r).");
704 let urls: Vec<&str> = c.iter().map(|c| c.url.as_str()).collect();
705 assert_eq!(urls, vec!["https://a.example/p", "https://b.example/q", "https://c.example/r"]);
706 assert!(c.iter().all(|c| c.form == CitationForm::BareUrl));
707 }
708
709 #[test]
710 fn bare_url_inside_anchor_not_double_counted() {
711 let c = extract(r#"<a href="https://a.example/">https://a.example/</a>"#);
712 assert_eq!(c.len(), 1);
713 assert_eq!(c[0].form, CitationForm::HtmlAnchor);
714 }
715
716 #[test]
717 fn bare_scheme_alone_and_glued_scheme_skipped() {
718 let c = extract("https:// is a scheme; xhttps://a.example/ is glued");
719 assert!(c.is_empty());
720 }
721
722 #[test]
723 fn document_order_across_forms() {
724 let c = extract("https://one.example/ [two](https://two.example/) <a href=\"https://three.example/\">3</a>");
725 let hosts: Vec<Option<String>> = c.iter().map(|c| host(&c.url)).collect();
726 assert_eq!(hosts, vec![Some("one.example".into()), Some("two.example".into()), Some("three.example".into())]);
727 }
728
729 #[test]
730 fn empty_and_plain_text() {
731 assert!(extract("").is_empty());
732 assert!(extract("no links here, http not a url either").is_empty());
733 }
734
735 #[test]
736 fn canonical_rules() {
737 assert_eq!(canonical("http://Example.com:80/A/B?utm_campaign=x"), "http://example.com/A/B");
738 assert_eq!(canonical("https://e.com/p?b=2&fbclid=abc&a=1"), "https://e.com/p?b=2&a=1");
739 assert_eq!(canonical("https://e.com/p?GCLID=1"), "https://e.com/p");
740 assert_eq!(canonical("https://e.com/p/"), "https://e.com/p/");
741 assert_eq!(canonical("https://e.com/p?"), "https://e.com/p");
742 assert_eq!(canonical("https://e.com:8443/"), "https://e.com:8443/");
743 assert_eq!(canonical("not a url"), "not a url");
744 }
745
746 #[test]
747 fn host_rules() {
748 assert_eq!(host("https://user:pw@www.a.example:99/p"), Some("a.example".into()));
749 assert_eq!(host("http://A.EXAMPLE"), Some("a.example".into()));
750 assert_eq!(host("https:///nohost"), None);
751 assert_eq!(host("ftp://a.example/"), None);
752 }
753
754 #[test]
755 fn chatgpt_tag() {
756 assert!(chatgpt_tagged("https://a.example/?x=1&UTM_SOURCE=chatgpt.com#f"));
757 assert!(!chatgpt_tagged("https://a.example/?utm_source=chatgpt.com.evil"));
758 assert!(!chatgpt_tagged("https://a.example/"));
759 }
760
761 #[test]
762 fn tally_orders_by_count_then_host() {
763 let c = extract("https://b.example/1 https://a.example/1 https://b.example/2 https://c.example/1");
764 let t = tally(&c);
765 assert_eq!(t[0], HostCount { host: "b.example".into(), citations: 2 });
766 assert_eq!(t[1].host, "a.example");
767 assert_eq!(t[2].host, "c.example");
768 }
769
770 #[test]
771 fn distinct_pages_dedupes_by_canonical() {
772 let c = extract("https://a.example/x?utm_source=chatgpt.com https://A.example/x#h https://a.example/x/");
773 assert_eq!(distinct_pages(&c), vec!["https://a.example/x", "https://a.example/x/"]);
774 }
775
776 #[test]
777 fn perplexity_shaped_answer() {
778 let html = r#"<p>Tea steeps best at 80°C<a class="citation" href="https://tea.example/temp?ref=pplx" target="_blank" rel="noopener">1</a>, though some prefer hotter water<a href="https://other.example/hot">2</a>.</p><div class="sources"><a href="https://tea.example/temp?ref=pplx">tea.example</a></div>"#;
779 let c = extract(html);
780 assert_eq!(c.len(), 3);
781 assert_eq!(c[0].marker, Some(1));
782 assert_eq!(c[1].marker, Some(2));
783 assert_eq!(c[2].marker, None);
784 assert_eq!(distinct_pages(&c).len(), 2);
785 assert_eq!(tally(&c)[0].host, "tea.example");
786 }
787
788 #[test]
789 fn decode_entities_edge_cases() {
790 assert_eq!(decode_entities("a & b 'c' A &unknown; &"), "a & b 'c' A &unknown; &");
791 }
792}