use crate::config::Config;
use crate::corpus::Corpus;
use crate::graph::{Graph, Resolution};
use crate::markdown::body::wiki_re;
use crate::model::RefText;
use crate::remotes::{self, Remote};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Occurrence {
pub span: (usize, usize),
pub raw: String,
pub target: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Link {
pub span: (usize, usize),
pub raw: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub target_id: Option<String>,
pub anchor: Option<String>,
pub remote: Option<String>,
pub resolved_path: Option<String>,
pub title: Option<String>,
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip)]
pub idx: Option<usize>,
}
pub fn occurrences(body: &str) -> Vec<Occurrence> {
let mut out = Vec::new();
outside_code(body, wiki_re(), |start, m| {
let whole = m.get(0).expect("capture group 0 always exists");
let inner = &whole.as_str()[2..whole.as_str().len() - 2];
let label = inner.split_once('|').map(|(_, l)| l.trim().to_string()).filter(|l| !l.is_empty());
out.push(Occurrence {
span: (start + whole.start(), start + whole.end()),
raw: whole.as_str().to_string(),
target: m[1].trim().to_string(),
label,
});
});
out
}
fn md_id_re() -> &'static regex::Regex {
static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
R.get_or_init(|| {
regex::Regex::new(r"(^|[^!\]])\[([^\[\]\n]*)\]\(<?([a-z][a-z0-9_-]*:[^\s()<>/:]+/[^\s()<>]+)>?\)")
.expect("static regex is valid")
})
}
pub fn md_id_occurrences(body: &str) -> Vec<Occurrence> {
let mut out = Vec::new();
outside_code(body, md_id_re(), |start, m| {
let lead = m.get(1).map_or(0, |g| g.len());
let whole = m.get(0).expect("capture group 0 always exists");
let text = m[2].trim().to_string();
out.push(Occurrence {
span: (start + whole.start() + lead, start + whole.end()),
raw: whole.as_str()[lead..].to_string(),
target: m[3].to_string(),
label: Some(text).filter(|l| !l.is_empty()),
});
});
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct MdLink {
pub start: usize,
pub image: bool,
pub text: String,
pub target: String,
}
pub(crate) fn md_link_re() -> &'static regex::Regex {
static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
R.get_or_init(|| {
regex::Regex::new(
r#"(!?)\[([^\[\]\n]*)\]\(\s*(?:<([^<>\n]*)>|([^\s()<>]*))(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\s*\)"#,
)
.expect("static regex is valid")
})
}
pub fn md_links(body: &str) -> Vec<MdLink> {
let mut out = Vec::new();
outside_code(body, md_link_re(), |start, m| {
let target = m.get(3).or(m.get(4)).map_or("", |g| g.as_str()).to_string();
out.push(MdLink {
start: start + m.get(0).expect("capture group 0 always exists").start(),
image: !m[1].is_empty(),
text: m[2].to_string(),
target,
});
});
out
}
pub(crate) fn md_ref_def_re() -> &'static regex::Regex {
static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
R.get_or_init(|| {
regex::Regex::new(r"^ {0,3}\[([^\]^\n][^\]\n]*)\]:[ \t]*(?:<([^<>\n]*)>|([^\s<>]+))")
.expect("static regex is valid")
})
}
pub fn md_ref_defs(body: &str) -> Vec<MdLink> {
let mut out = Vec::new();
outside_code(body, md_ref_def_re(), |start, m| {
out.push(MdLink {
start: start + m.get(0).expect("capture group 0 always exists").start(),
image: false,
text: m[1].to_string(),
target: m.get(2).or(m.get(3)).map_or("", |g| g.as_str()).to_string(),
});
});
out
}
pub(crate) fn outside_code(body: &str, re: ®ex::Regex, mut each: impl FnMut(usize, ®ex::Captures)) {
let mut fence = crate::markdown::Fence::default();
let mut offset = 0;
for line in body.split_inclusive('\n') {
let start = offset;
offset += line.len();
let trimmed = line.trim_start();
if fence.step(trimmed) || fence.inside() {
continue;
}
let code = code_spans(line);
for m in re.captures_iter(line) {
let whole = m.get(0).expect("capture group 0 always exists");
if code.iter().any(|&(a, b)| whole.start() < b && whole.end() > a) {
continue;
}
each(start, &m);
}
}
}
fn code_spans(line: &str) -> Vec<(usize, usize)> {
let b = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < b.len() {
if b[i] != b'`' {
i += 1;
continue;
}
let n = b[i..].iter().take_while(|&&c| c == b'`').count();
let open = i;
i += n;
let mut j = i;
let mut closed = false;
while j < b.len() {
if b[j] == b'`' {
let m = b[j..].iter().take_while(|&&c| c == b'`').count();
if m == n {
out.push((open, j + m));
i = j + m;
closed = true;
break;
}
j += m;
} else {
j += 1;
}
}
if !closed {
break;
}
}
out
}
pub fn resolve(c: &Corpus, g: &Graph, o: &Occurrence) -> Link {
let r = RefText::parse(&o.target);
let mut l = Link {
span: o.span,
raw: o.raw.clone(),
label: o.label.clone(),
target_id: None,
anchor: r.anchor,
remote: None,
resolved_path: None,
title: None,
status: None,
reason: None,
idx: None,
};
match g.resolve(&o.target) {
Resolution::Doc { idx, anchor, .. } => {
let d = &c.docs[idx];
l.target_id = Some(d.id.to_string());
l.anchor = anchor;
l.resolved_path = Some(d.path.clone());
l.title = d.title.clone();
l.status = d.status();
l.idx = Some(idx);
}
Resolution::Ambiguous(list) => {
l.reason = Some(format!("ambiguous ({} documents)", list.len()));
}
Resolution::MissingAnchor { idx, anchor } => {
l.target_id = Some(c.docs[idx].id.to_string());
l.resolved_path = Some(c.docs[idx].path.clone());
l.reason = Some(format!("{} has no anchor #{anchor}", c.docs[idx].id));
}
Resolution::Missing => remote(&c.cfg, &o.target, &mut l),
}
l
}
fn remote(cfg: &Config, text: &str, l: &mut Link) {
l.reason = Some(match remotes::resolve(cfg, text) {
Remote::Local => "names no document".into(),
Remote::Found { name, id, title, file, .. } => {
l.target_id = Some(id);
l.remote = Some(name);
l.title = Some(title).filter(|t| !t.is_empty());
l.resolved_path = Some(file.display().to_string());
return;
}
Remote::Absent { name, .. } => {
l.remote = Some(name.clone());
format!("remote {name} is not on disk")
}
Remote::Missing { name, id } => {
l.remote = Some(name.clone());
format!("{id} is not in remote {name}")
}
Remote::MissingAnchor { name, id, anchor } => {
l.remote = Some(name);
format!("{id} has no anchor #{anchor}")
}
Remote::Ambiguous { name, ids } => {
l.remote = Some(name);
format!("ambiguous ({} documents)", ids.len())
}
});
}
pub fn of_body(c: &Corpus, g: &Graph, body: &str) -> Vec<Link> {
let mut out: Vec<Link> = occurrences(body).iter().map(|o| resolve(c, g, o)).collect();
out.extend(
md_id_occurrences(body).iter().map(|o| resolve(c, g, o)).filter(|l| l.idx.is_some() && l.remote.is_none()),
);
out.sort_by_key(|l| l.span.0);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spans_skip_code() {
let body = "See [[a:b/c|the c]] and `[[x:y]]`.\n```\n[[no:pe]]\n```\nAnd [[d:e#F]].\n";
let o = occurrences(body);
assert_eq!(o.len(), 2);
assert_eq!(&body[o[0].span.0..o[0].span.1], "[[a:b/c|the c]]");
assert_eq!(o[0].target, "a:b/c");
assert_eq!(o[0].label.as_deref(), Some("the c"));
assert_eq!(&body[o[1].span.0..o[1].span.1], "[[d:e#F]]");
}
#[test]
fn plain_links_and_images() {
let b = "See [a](x.md#Top) and , `[no](code)`.\n```\n[no](fence)\n```\n\n";
let l = md_links(b);
let t: Vec<(&str, bool, &str)> = l.iter().map(|x| (x.target.as_str(), x.image, x.text.as_str())).collect();
assert_eq!(t, [("x.md#Top", false, "a"), ("d.png", true, ""), ("a b.png", true, "alt")]);
}
#[test]
fn markdown_links_shaped_like_ids() {
let body = "See [the plan](plan:root/2026) and [x](https://a.b/c), ,\n`[c](plan:a/b)` [d](<adr:shop/x#Y>).\n";
let o = md_id_occurrences(body);
assert_eq!(o.len(), 2, "{o:?}");
assert_eq!(&body[o[0].span.0..o[0].span.1], "[the plan](plan:root/2026)");
assert_eq!(o[0].target, "plan:root/2026");
assert_eq!(o[0].label.as_deref(), Some("the plan"));
assert_eq!(o[1].target, "adr:shop/x#Y");
}
}