use crate::concept_id::ConceptId;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkKind {
Absolute,
Relative,
External,
Anchor,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Link {
pub text: String,
pub target: String,
pub kind: LinkKind,
}
impl Link {
pub fn classify(target: &str) -> LinkKind {
let t = target.trim();
if t.is_empty() {
LinkKind::Other
} else if t.starts_with('#') {
LinkKind::Anchor
} else if is_external(t) {
LinkKind::External
} else if t.starts_with('/') {
LinkKind::Absolute
} else {
LinkKind::Relative
}
}
pub fn resolve(&self, source: &ConceptId) -> Option<ConceptId> {
match self.kind {
LinkKind::Absolute => resolve_absolute(&self.target),
LinkKind::Relative => resolve_relative(&self.target, source),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Citation {
pub number: u32,
pub text: Option<String>,
pub target: Option<String>,
pub raw: String,
}
fn is_external(t: &str) -> bool {
let lower = t.to_ascii_lowercase();
lower.starts_with("//") || lower.contains("://")
|| lower.starts_with("mailto:")
|| lower.starts_with("tel:")
|| lower.starts_with("data:")
}
fn strip_anchor(target: &str) -> &str {
match target.find('#') {
Some(i) => &target[..i],
None => target,
}
}
fn resolve_absolute(target: &str) -> Option<ConceptId> {
let t = strip_anchor(target);
if t.ends_with('/') {
return None; }
let mut segs: Vec<String> = Vec::new();
for comp in t.trim_start_matches('/').split('/') {
match comp {
"" | "." => continue,
".." => {
segs.pop();
}
other => segs.push(other.to_string()),
}
}
if let Some(last) = segs.last_mut() {
if let Some(s) = last.strip_suffix(".md") {
*last = s.to_string();
}
}
ConceptId::new(segs).ok()
}
fn resolve_relative(target: &str, source: &ConceptId) -> Option<ConceptId> {
let t = strip_anchor(target);
if t.is_empty() || t.ends_with('/') {
return None;
}
let mut segs: Vec<String> = match source.parent() {
Some(p) => p.segments().to_vec(),
None => Vec::new(),
};
for comp in t.split('/') {
match comp {
"" | "." => continue,
".." => {
segs.pop();
}
other => segs.push(other.to_string()),
}
}
if let Some(last) = segs.last_mut() {
if let Some(s) = last.strip_suffix(".md") {
*last = s.to_string();
}
}
ConceptId::new(segs).ok()
}
pub fn extract_links(body: &str) -> Vec<Link> {
let mut links = Vec::new();
for line in code_free_lines(body) {
scan_line_links(&line, &mut links);
}
links
}
fn code_free_lines(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut fence: Option<char> = None;
for line in body.lines() {
let trimmed = line.trim_start();
if let Some(f) = fence {
if trimmed.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
continue;
}
if trimmed.starts_with("```") {
fence = Some('`');
continue;
}
if trimmed.starts_with("~~~") {
fence = Some('~');
continue;
}
out.push(blank_inline_code(line));
}
out
}
fn blank_inline_code(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut in_code = false;
for c in line.chars() {
if c == '`' {
in_code = !in_code;
out.push(' ');
} else if in_code {
out.push(' ');
} else {
out.push(c);
}
}
out
}
fn scan_line_links(line: &str, out: &mut Vec<Link>) {
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '[' {
if let Some((text, dest, next)) = parse_inline_link(&chars, i) {
let target = strip_title(&dest);
out.push(Link {
text,
kind: Link::classify(&target),
target,
});
i = next;
continue;
}
}
i += 1;
}
}
fn parse_inline_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
let mut i = start + 1;
let mut depth = 1;
let text_start = i;
while i < chars.len() {
match chars[i] {
'\\' => i += 1, '[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
if depth != 0 || i >= chars.len() {
return None;
}
let text: String = chars[text_start..i].iter().collect();
let mut j = i + 1;
if j >= chars.len() || chars[j] != '(' {
return None;
}
j += 1;
let dest_start = j;
let mut paren = 1;
while j < chars.len() {
match chars[j] {
'\\' => j += 1,
'(' => paren += 1,
')' => {
paren -= 1;
if paren == 0 {
break;
}
}
_ => {}
}
j += 1;
}
if paren != 0 || j >= chars.len() {
return None;
}
let dest: String = chars[dest_start..j].iter().collect();
Some((text, dest, j + 1))
}
fn strip_title(dest: &str) -> String {
let d = dest.trim();
if let Some(idx) = d.find([' ', '\t']) {
let (url, rest) = d.split_at(idx);
let rest = rest.trim_start();
if rest.starts_with('"') || rest.starts_with('\'') {
return url.to_string();
}
}
d.to_string()
}
pub fn extract_citations(body: &str) -> Vec<Citation> {
let mut out = Vec::new();
let mut in_section = false;
for line in body.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix('#') {
let title = heading.trim_start_matches('#').trim();
if in_section {
break;
}
in_section = title.eq_ignore_ascii_case("citations");
continue;
}
if !in_section || trimmed.is_empty() {
continue;
}
if let Some(cit) = parse_citation_line(trimmed) {
out.push(cit);
}
}
out
}
fn parse_citation_line(line: &str) -> Option<Citation> {
let rest = line.strip_prefix('[')?;
let close = rest.find(']')?;
let number: u32 = rest[..close].trim().parse().ok()?;
let after = rest[close + 1..].trim().to_string();
let mut text = None;
let mut target = None;
let chars: Vec<char> = after.chars().collect();
if let Some(open) = chars.iter().position(|&c| c == '[') {
if let Some((t, dest, _)) = parse_inline_link(&chars, open) {
text = Some(t);
target = Some(strip_title(&dest));
}
}
Some(Citation {
number,
text,
target,
raw: after,
})
}