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 {
#[must_use]
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
}
}
#[must_use]
pub fn resolve(&self, source: &ConceptId) -> Option<ConceptId> {
self.resolve_all(source).into_iter().next()
}
#[must_use]
pub fn resolve_all(&self, source: &ConceptId) -> Vec<ConceptId> {
let mut out = Vec::new();
let mut push = |target: &str| {
let id = match self.kind {
LinkKind::Absolute => resolve_absolute(target),
LinkKind::Relative => resolve_relative(target, source),
_ => None,
};
if let Some(id) = id {
if !out.contains(&id) {
out.push(id);
}
}
};
push(&self.target);
if let Some(decoded) = percent_decode(&self.target) {
push(&decoded);
}
out
}
}
fn percent_decode(s: &str) -> Option<String> {
if !s.contains('%') {
return None;
}
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut decoded_any = false;
let mut i = 0;
while i < bytes.len() {
let escape = (bytes[i] == b'%' && i + 3 <= bytes.len())
.then(|| &bytes[i + 1..i + 3])
.filter(|hex| hex.iter().all(u8::is_ascii_hexdigit));
if let Some(hex) = escape {
let hex = std::str::from_utf8(hex).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?);
decoded_any = true;
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
if !decoded_any {
return None;
}
String::from_utf8(out).ok()
}
#[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 {
t.starts_with("//") || has_uri_scheme(t)
}
fn has_uri_scheme(t: &str) -> bool {
let Some((scheme, _)) = t.split_once(':') else {
return false;
};
let mut chars = scheme.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn strip_anchor(target: &str) -> &str {
target.find('#').map_or(target, |i| &target[..i])
}
fn resolve_absolute(target: &str) -> Option<ConceptId> {
let t = strip_anchor(target);
if t.ends_with('/') {
return None; }
strip_md(normalize_segments(t, &[])).and_then(|segs| 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 base = source
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
strip_md(normalize_segments(t, &base)).and_then(|segs| ConceptId::new(segs).ok())
}
fn normalize_segments(path: &str, base: &[String]) -> Vec<String> {
let mut segs = base.to_vec();
for comp in path.split('/') {
match comp {
"" | "." => {}
".." => {
segs.pop();
}
other => segs.push(other.to_string()),
}
}
segs
}
fn strip_md(mut segs: Vec<String>) -> Option<Vec<String>> {
let last = segs.last_mut()?;
if let Some(s) = last.strip_suffix(".md") {
*last = s.to_string();
}
Some(segs)
}
#[must_use]
pub fn field_path_candidates(raw: &str, from: &ConceptId) -> Vec<String> {
let target = raw.trim();
match Link::classify(target) {
LinkKind::Absolute => {
vec![normalize_segments(strip_anchor(target), &[]).join("/")]
}
LinkKind::Relative => {
let base = from
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let stripped = strip_anchor(target);
let mut out = vec![normalize_segments(stripped, &base).join("/")];
let from_root = normalize_segments(stripped, &[]).join("/");
if !out.contains(&from_root) {
out.push(from_root);
}
out.retain(|p| !p.is_empty());
out
}
_ => Vec::new(),
}
}
#[must_use]
pub fn concept_id_for_path(path: &str) -> Option<ConceptId> {
let stem = path.strip_suffix(".md")?;
ConceptId::parse(stem).ok()
}
#[must_use]
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
}
pub(crate) fn code_free_lines(body: &str) -> Vec<(usize, String)> {
let mut out = Vec::new();
let mut fence: Option<char> = None;
for (i, line) in body.lines().enumerate() {
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((i + 1, 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 = clean_destination(&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 clean_destination(dest: &str) -> String {
let d = dest.trim();
if let Some(rest) = d.strip_prefix('<') {
if let Some(end) = rest.find('>') {
return rest[..end].to_string();
}
}
strip_title(d)
}
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()
}
#[must_use]
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(clean_destination(&dest));
}
}
Some(Citation {
number,
text,
target,
raw: after,
})
}