use crate::concept_id::ConceptId;
use crate::markdown::{clean_destination, code_free_lines, is_escaped, parse_inline_link};
use std::fmt;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LinkKind {
Absolute,
Relative,
External,
Anchor,
Other,
}
impl LinkKind {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Absolute => "absolute",
Self::Relative => "relative",
Self::External => "external",
Self::Anchor => "anchor",
Self::Other => "other",
}
}
}
impl fmt::Display for LinkKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl AsRef<str> for LinkKind {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParseLinkKindError(pub String);
impl fmt::Display for ParseLinkKindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown link kind: {:?}", self.0)
}
}
impl std::error::Error for ParseLinkKindError {}
impl std::str::FromStr for LinkKind {
type Err = ParseLinkKindError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"absolute" => Ok(Self::Absolute),
"relative" => Ok(Self::Relative),
"external" => Ok(Self::External),
"anchor" => Ok(Self::Anchor),
"other" => Ok(Self::Other),
other => Err(ParseLinkKindError(other.to_string())),
}
}
}
#[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_path(target),
LinkKind::Relative => resolve_relative_path(target, source),
_ => None,
};
if let Some(id) = id
&& !out.contains(&id)
{
out.push(id);
}
};
let target = strip_anchor(&self.target);
push(target);
if let Some(decoded) = percent_decode(target) {
push(&decoded);
}
out
}
#[must_use]
pub fn target_without_anchor(&self) -> &str {
strip_anchor(&self.target)
}
#[must_use]
pub fn anchor(&self) -> Option<&str> {
self.target.find('#').map(|i| &self.target[i + 1..])
}
}
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_path(t: &str) -> Option<ConceptId> {
if t.ends_with('/') {
return None; }
normalize_segments(t, &[])
.and_then(strip_md)
.and_then(|segs| ConceptId::new(segs).ok())
}
fn resolve_relative_path(t: &str, source: &ConceptId) -> Option<ConceptId> {
if t.is_empty() || t.ends_with('/') {
return None;
}
let base = source
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
normalize_segments(t, &base)
.and_then(strip_md)
.and_then(|segs| ConceptId::new(segs).ok())
}
fn normalize_segments(path: &str, base: &[String]) -> Option<Vec<String>> {
let mut segs = base.to_vec();
for comp in path.split('/') {
match comp {
"" | "." => {}
".." => {
segs.pop()?;
}
other => segs.push(other.to_string()),
}
}
Some(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 => normalize_segments(strip_anchor(target), &[])
.map(|segments| segments.join("/"))
.into_iter()
.collect(),
LinkKind::Relative => {
let base = from
.parent()
.map(|p| p.segments().to_vec())
.unwrap_or_default();
let stripped = strip_anchor(target);
let mut out = Vec::new();
if let Some(path) = normalize_segments(stripped, &base) {
out.push(path.join("/"));
}
if let Some(path) = normalize_segments(stripped, &[]) {
let from_root = path.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
}
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] == '['
&& !is_escaped(&chars, i)
&& 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;
}
}
#[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 == '[')
&& let Some((t, dest, _)) = parse_inline_link(&chars, open)
{
text = Some(t);
target = Some(clean_destination(&dest));
}
Some(Citation {
number,
text,
target,
raw: after,
})
}