use crate::citation_structured::{
footnote_citations, numeric_ref_citations, sources_block_citations,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Citation {
pub start_index: usize,
pub end_index: usize,
pub url: String,
pub title: String,
}
pub fn merged_citations(content: &str) -> Option<Vec<Citation>> {
let bare = extract_url_citations(content);
let structured = extract(content);
let merged = merge_dedupe(bare, structured);
if merged.is_empty() {
None
} else {
Some(merged)
}
}
pub fn extract(content: &str) -> Vec<Citation> {
let mut out = Vec::new();
out.extend(footnote_citations(content));
out.extend(numeric_ref_citations(content));
out.extend(sources_block_citations(content));
out.sort_by_key(|c| c.start_index);
out
}
pub fn extract_url_citations(content: &str) -> Vec<Citation> {
let mut out: Vec<Citation> = Vec::new();
let masked = mask_code_spans(content);
let mut scan = 0usize;
while scan < masked.len() {
let rest = &masked[scan..];
let Some(lb_rel) = rest.find('[') else { break };
let lb = scan + lb_rel;
let after_lb = &masked[lb + 1..];
let Some(rb_rel) = after_lb.find(']') else {
scan = lb + 1;
continue;
};
let rb = lb + 1 + rb_rel;
if masked.as_bytes().get(rb + 1) != Some(&b'(') {
scan = rb + 1;
continue;
}
let after_paren = &masked[rb + 2..];
let Some(rparen_rel) = balanced_paren_close(after_paren) else {
scan = rb + 2;
continue;
};
let rparen = rb + 2 + rparen_rel;
let title = &content[lb + 1..rb];
let target = content[rb + 2..rparen].trim();
if (target.starts_with("http://") || target.starts_with("https://"))
&& target.len() > "https://".len()
{
out.push(Citation {
start_index: rb + 2,
end_index: rparen,
url: target.to_string(),
title: title.to_string(),
});
}
scan = rparen + 1;
}
let covered: Vec<(usize, usize)> = out.iter().map(|c| (c.start_index, c.end_index)).collect();
let mut i = 0usize;
while i < masked.len() {
let rest = &masked[i..];
let Some(off) = rest.find("http") else { break };
let abs_start = i + off;
let tail_masked = &masked[abs_start..];
let is_url = tail_masked.starts_with("http://") || tail_masked.starts_with("https://");
if !is_url {
i = abs_start + 4;
continue;
}
let tail = &content[abs_start..];
let end_rel = tail
.find(|c: char| {
c.is_whitespace() || matches!(c, ']' | '}' | '"' | '<' | '>' | '`' | '\\')
})
.unwrap_or(tail.len());
let mut raw = &tail[..end_rel];
while let Some(last) = raw.chars().last() {
let strip = match last {
'.' | ',' | ';' | ':' | '!' | '?' | '*' | '_' => true,
')' => {
let opens = raw.matches('(').count();
let closes = raw.matches(')').count();
closes > opens
}
_ => false,
};
if !strip {
break;
}
raw = &raw[..raw.len() - last.len_utf8()];
}
if raw.len() > "https://".len() {
let start = abs_start;
let end = abs_start + raw.len();
let overlaps = covered.iter().any(|(s, e)| start < *e && end > *s);
if !overlaps {
out.push(Citation {
start_index: start,
end_index: end,
url: raw.to_string(),
title: raw.to_string(),
});
}
}
i = abs_start + end_rel.max(1);
}
out.sort_by_key(|c| c.start_index);
out
}
pub fn merge_dedupe(mut primary: Vec<Citation>, secondary: Vec<Citation>) -> Vec<Citation> {
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
for c in &primary {
seen.insert(c.url.clone());
}
for c in secondary {
if seen.insert(c.url.clone()) {
primary.push(c);
}
}
primary.sort_by_key(|c| c.start_index);
primary
}
fn balanced_paren_close(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth: i32 = 0;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => {
if depth == 0 {
return Some(i);
}
depth -= 1;
}
_ => {}
}
}
None
}
fn mask_code_spans(content: &str) -> String {
let bytes = content.as_bytes();
let mut out: Vec<u8> = bytes.to_vec();
fn blank(out: &mut [u8], content: &str, start: usize, end: usize) {
let region = &content[start..end];
let mut cursor = start;
for ch in region.chars() {
let len = ch.len_utf8();
if ch != '\n' {
for b in out.iter_mut().skip(cursor).take(len) {
*b = b' ';
}
}
cursor += len;
}
}
let mut i = 0usize;
while i < bytes.len() {
if bytes[i..].starts_with(b"```") {
let after = i + 3;
let rest = &content[after..];
let end = match rest.find("```") {
Some(r) => after + r + 3,
None => bytes.len(),
};
blank(&mut out, content, i, end);
i = end;
continue;
}
if bytes[i] == b'`' {
let after = i + 1;
let rest = &content[after..];
let end = match rest.find('`') {
Some(r) => after + r + 1,
None => bytes.len(),
};
blank(&mut out, content, i, end);
i = end;
continue;
}
let step = match bytes[i] {
0x00..=0x7f => 1,
0xc0..=0xdf => 2,
0xe0..=0xef => 3,
0xf0..=0xf7 => 4,
_ => 1,
};
i += step;
}
String::from_utf8(out).expect("mask preserves UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_citations_returns_empty() {
let got = extract("plain text with no citations");
assert!(got.is_empty());
}
#[test]
fn merge_dedupe_by_url() {
let a = vec![Citation {
start_index: 0,
end_index: 20,
url: "https://example.com".into(),
title: "first".into(),
}];
let b = vec![Citation {
start_index: 50,
end_index: 70,
url: "https://example.com".into(),
title: "second".into(),
}];
let merged = merge_dedupe(a, b);
assert_eq!(merged.len(), 1);
}
#[test]
fn bare_url_extracted() {
let got = extract_url_citations("see https://example.com/foo for more");
assert_eq!(got.len(), 1);
assert_eq!(got[0].url, "https://example.com/foo");
assert_eq!(got[0].title, "https://example.com/foo");
}
#[test]
fn trailing_punctuation_stripped() {
let got = extract_url_citations("go to https://example.com.");
assert_eq!(got[0].url, "https://example.com");
}
#[test]
fn wikipedia_parens_survive() {
let got = extract_url_citations("see https://en.wikipedia.org/wiki/Foo_(bar) now");
assert_eq!(got[0].url, "https://en.wikipedia.org/wiki/Foo_(bar)");
}
#[test]
fn markdown_link_title_used() {
let got = extract_url_citations("read [the docs](https://example.com/api) today");
assert_eq!(got.len(), 1);
assert_eq!(got[0].url, "https://example.com/api");
assert_eq!(got[0].title, "the docs");
}
#[test]
fn markdown_link_with_parens_in_url() {
let got =
extract_url_citations("see [Foo (bar)](https://en.wikipedia.org/wiki/Foo_(bar)) here");
assert_eq!(got.len(), 1);
assert_eq!(got[0].url, "https://en.wikipedia.org/wiki/Foo_(bar)");
}
#[test]
fn code_spans_skipped() {
assert!(extract_url_citations("run `curl https://example.com` locally").is_empty());
assert!(extract_url_citations("```\ncurl https://example.com\n```\nno cite").is_empty());
}
#[test]
fn non_http_schemes_ignored() {
assert!(extract_url_citations("ftp://example.com not a citation").is_empty());
}
#[test]
fn empty_and_plain_content() {
assert!(extract_url_citations("").is_empty());
assert!(extract_url_citations("no URLs here").is_empty());
}
#[test]
fn query_and_fragment_kept() {
let got = extract_url_citations("see https://example.com/p?q=1&r=2#frag here");
assert_eq!(got[0].url, "https://example.com/p?q=1&r=2#frag");
}
#[test]
fn merged_citations_combines_and_dedupes() {
let input = "Intro https://a.example.com text.\n\nSources:\n- https://a.example.com\n- https://b.example.com\n";
let got = merged_citations(input).unwrap();
let mut u: Vec<&str> = got.iter().map(|c| c.url.as_str()).collect();
u.dedup();
assert_eq!(u.len(), 2);
}
}