1use crate::config::Config;
5use crate::corpus::Corpus;
6use crate::graph::{Graph, Resolution};
7use crate::markdown::body::wiki_re;
8use crate::model::RefText;
9use crate::remotes::{self, Remote};
10use serde::Serialize;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Occurrence {
15 pub span: (usize, usize),
16 pub raw: String,
17 pub target: String,
18 pub label: Option<String>,
19}
20
21#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
24pub struct Link {
25 pub span: (usize, usize),
26 pub raw: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub label: Option<String>,
29 pub target_id: Option<String>,
30 pub anchor: Option<String>,
31 pub remote: Option<String>,
32 pub resolved_path: Option<String>,
33 pub title: Option<String>,
34 pub status: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub reason: Option<String>,
37 #[serde(skip)]
39 pub idx: Option<usize>,
40}
41
42pub fn occurrences(body: &str) -> Vec<Occurrence> {
44 let mut out = Vec::new();
45 outside_code(body, wiki_re(), |start, m| {
46 let whole = m.get(0).expect("capture group 0 always exists");
47 let inner = &whole.as_str()[2..whole.as_str().len() - 2];
48 let label = inner.split_once('|').map(|(_, l)| l.trim().to_string()).filter(|l| !l.is_empty());
49 out.push(Occurrence {
50 span: (start + whole.start(), start + whole.end()),
51 raw: whole.as_str().to_string(),
52 target: m[1].trim().to_string(),
53 label,
54 });
55 });
56 out
57}
58
59fn md_id_re() -> &'static regex::Regex {
63 static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
64 R.get_or_init(|| {
65 regex::Regex::new(r"(^|[^!\]])\[([^\[\]\n]*)\]\(<?([a-z][a-z0-9_-]*:[^\s()<>/:]+/[^\s()<>]+)>?\)")
66 .expect("static regex is valid")
67 })
68}
69
70pub fn md_id_occurrences(body: &str) -> Vec<Occurrence> {
72 let mut out = Vec::new();
73 outside_code(body, md_id_re(), |start, m| {
74 let lead = m.get(1).map_or(0, |g| g.len());
75 let whole = m.get(0).expect("capture group 0 always exists");
76 let text = m[2].trim().to_string();
77 out.push(Occurrence {
78 span: (start + whole.start() + lead, start + whole.end()),
79 raw: whole.as_str()[lead..].to_string(),
80 target: m[3].to_string(),
81 label: Some(text).filter(|l| !l.is_empty()),
82 });
83 });
84 out
85}
86
87#[derive(Debug, Clone, PartialEq)]
89pub struct MdLink {
90 pub start: usize,
92 pub image: bool,
93 pub text: String,
94 pub target: String,
95}
96
97pub(crate) fn md_link_re() -> &'static regex::Regex {
98 static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
99 R.get_or_init(|| {
100 regex::Regex::new(
101 r#"(!?)\[([^\[\]\n]*)\]\(\s*(?:<([^<>\n]*)>|([^\s()<>]*))(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\s*\)"#,
102 )
103 .expect("static regex is valid")
104 })
105}
106
107pub fn md_links(body: &str) -> Vec<MdLink> {
109 let mut out = Vec::new();
110 outside_code(body, md_link_re(), |start, m| {
111 let target = m.get(3).or(m.get(4)).map_or("", |g| g.as_str()).to_string();
112 out.push(MdLink {
113 start: start + m.get(0).expect("capture group 0 always exists").start(),
114 image: !m[1].is_empty(),
115 text: m[2].to_string(),
116 target,
117 });
118 });
119 out
120}
121
122pub(crate) fn md_ref_def_re() -> &'static regex::Regex {
124 static R: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
125 R.get_or_init(|| {
126 regex::Regex::new(r"^ {0,3}\[([^\]^\n][^\]\n]*)\]:[ \t]*(?:<([^<>\n]*)>|([^\s<>]+))")
127 .expect("static regex is valid")
128 })
129}
130
131pub fn md_ref_defs(body: &str) -> Vec<MdLink> {
133 let mut out = Vec::new();
134 outside_code(body, md_ref_def_re(), |start, m| {
135 out.push(MdLink {
136 start: start + m.get(0).expect("capture group 0 always exists").start(),
137 image: false,
138 text: m[1].to_string(),
139 target: m.get(2).or(m.get(3)).map_or("", |g| g.as_str()).to_string(),
140 });
141 });
142 out
143}
144
145pub(crate) fn outside_code(body: &str, re: ®ex::Regex, mut each: impl FnMut(usize, ®ex::Captures)) {
147 let mut fence = crate::markdown::Fence::default();
148 let mut offset = 0;
149 for line in body.split_inclusive('\n') {
150 let start = offset;
151 offset += line.len();
152 let trimmed = line.trim_start();
153 if fence.step(trimmed) || fence.inside() {
154 continue;
155 }
156 let code = code_spans(line);
157 for m in re.captures_iter(line) {
158 let whole = m.get(0).expect("capture group 0 always exists");
159 if code.iter().any(|&(a, b)| whole.start() < b && whole.end() > a) {
160 continue;
161 }
162 each(start, &m);
163 }
164 }
165}
166
167fn code_spans(line: &str) -> Vec<(usize, usize)> {
169 let b = line.as_bytes();
170 let mut out = Vec::new();
171 let mut i = 0;
172 while i < b.len() {
173 if b[i] != b'`' {
174 i += 1;
175 continue;
176 }
177 let n = b[i..].iter().take_while(|&&c| c == b'`').count();
178 let open = i;
179 i += n;
180 let mut j = i;
181 let mut closed = false;
182 while j < b.len() {
183 if b[j] == b'`' {
184 let m = b[j..].iter().take_while(|&&c| c == b'`').count();
185 if m == n {
186 out.push((open, j + m));
187 i = j + m;
188 closed = true;
189 break;
190 }
191 j += m;
192 } else {
193 j += 1;
194 }
195 }
196 if !closed {
197 break;
198 }
199 }
200 out
201}
202
203pub fn resolve(c: &Corpus, g: &Graph, o: &Occurrence) -> Link {
205 let r = RefText::parse(&o.target);
206 let mut l = Link {
207 span: o.span,
208 raw: o.raw.clone(),
209 label: o.label.clone(),
210 target_id: None,
211 anchor: r.anchor,
212 remote: None,
213 resolved_path: None,
214 title: None,
215 status: None,
216 reason: None,
217 idx: None,
218 };
219 match g.resolve(&o.target) {
220 Resolution::Doc { idx, anchor, .. } => {
221 let d = &c.docs[idx];
222 l.target_id = Some(d.id.to_string());
223 l.anchor = anchor;
224 l.resolved_path = Some(d.path.clone());
225 l.title = d.title.clone();
226 l.status = d.status();
227 l.idx = Some(idx);
228 }
229 Resolution::Ambiguous(list) => {
230 l.reason = Some(format!("ambiguous ({} documents)", list.len()));
231 }
232 Resolution::MissingAnchor { idx, anchor } => {
233 l.target_id = Some(c.docs[idx].id.to_string());
234 l.resolved_path = Some(c.docs[idx].path.clone());
235 l.reason = Some(format!("{} has no anchor #{anchor}", c.docs[idx].id));
236 }
237 Resolution::Missing => remote(&c.cfg, &o.target, &mut l),
238 }
239 l
240}
241
242fn remote(cfg: &Config, text: &str, l: &mut Link) {
243 l.reason = Some(match remotes::resolve(cfg, text) {
244 Remote::Local => "names no document".into(),
245 Remote::Found { name, id, title, file, .. } => {
246 l.target_id = Some(id);
247 l.remote = Some(name);
248 l.title = Some(title).filter(|t| !t.is_empty());
249 l.resolved_path = Some(file.display().to_string());
250 return;
251 }
252 Remote::Absent { name, .. } => {
253 l.remote = Some(name.clone());
254 format!("remote {name} is not on disk")
255 }
256 Remote::Missing { name, id } => {
257 l.remote = Some(name.clone());
258 format!("{id} is not in remote {name}")
259 }
260 Remote::MissingAnchor { name, id, anchor } => {
261 l.remote = Some(name);
262 format!("{id} has no anchor #{anchor}")
263 }
264 Remote::Ambiguous { name, ids } => {
265 l.remote = Some(name);
266 format!("ambiguous ({} documents)", ids.len())
267 }
268 });
269}
270
271pub fn of_body(c: &Corpus, g: &Graph, body: &str) -> Vec<Link> {
275 let mut out: Vec<Link> = occurrences(body).iter().map(|o| resolve(c, g, o)).collect();
276 out.extend(
277 md_id_occurrences(body).iter().map(|o| resolve(c, g, o)).filter(|l| l.idx.is_some() && l.remote.is_none()),
278 );
279 out.sort_by_key(|l| l.span.0);
280 out
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn spans_skip_code() {
289 let body = "See [[a:b/c|the c]] and `[[x:y]]`.\n```\n[[no:pe]]\n```\nAnd [[d:e#F]].\n";
290 let o = occurrences(body);
291 assert_eq!(o.len(), 2);
292 assert_eq!(&body[o[0].span.0..o[0].span.1], "[[a:b/c|the c]]");
293 assert_eq!(o[0].target, "a:b/c");
294 assert_eq!(o[0].label.as_deref(), Some("the c"));
295 assert_eq!(&body[o[1].span.0..o[1].span.1], "[[d:e#F]]");
296 }
297
298 #[test]
299 fn plain_links_and_images() {
300 let b = "See [a](x.md#Top) and , `[no](code)`.\n```\n[no](fence)\n```\n\n";
301 let l = md_links(b);
302 let t: Vec<(&str, bool, &str)> = l.iter().map(|x| (x.target.as_str(), x.image, x.text.as_str())).collect();
303 assert_eq!(t, [("x.md#Top", false, "a"), ("d.png", true, ""), ("a b.png", true, "alt")]);
304 }
305
306 #[test]
307 fn markdown_links_shaped_like_ids() {
308 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";
309 let o = md_id_occurrences(body);
310 assert_eq!(o.len(), 2, "{o:?}");
311 assert_eq!(&body[o[0].span.0..o[0].span.1], "[the plan](plan:root/2026)");
312 assert_eq!(o[0].target, "plan:root/2026");
313 assert_eq!(o[0].label.as_deref(), Some("the plan"));
314 assert_eq!(o[1].target, "adr:shop/x#Y");
315 }
316}