Skip to main content

specfold_core/export/
links.rs

1//! Every `[[reference]]` in a body with its byte span and what it resolves to, using the same
2//! resolution as rule L1 (ids, aliases, paths, anchors and remotes).
3
4use 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/// One reference as written: `span` is the byte range of `[[…]]` in the body.
13#[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/// A reference and where it leads. `target_id` is set for a document here or in a remote;
22/// `reason` is set when it does not resolve.
23#[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    /// The local document index, for exporters inside specfold.
38    #[serde(skip)]
39    pub idx: Option<usize>,
40}
41
42/// The references in `body`, skipping fenced code blocks and inline code like rule L1 does.
43pub 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
59/// `[text](plan:root/…)`: a Markdown link whose target has the shape of a document id. Such a
60/// link is not a reference: rule L1 warns and points at `[[id]]`, and the built-in export
61/// rewrites the ones that resolve.
62fn 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
70/// The Markdown links in `body` whose target looks like `<type>:<owner>/<rest>`, outside code.
71pub 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/// A plain Markdown link or image: `[text](target)` or `![alt](target "title")`.
88#[derive(Debug, Clone, PartialEq)]
89pub struct MdLink {
90    /// Byte offset in the body.
91    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
107/// Every inline Markdown link and image in `body`, outside code.
108pub 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
122/// `[label]: target "title"`: a reference-style link definition (not a footnote `[^1]:`).
123pub(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
131/// Every reference-style link definition in `body`, outside code (C180).
132pub 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
145/// Calls `each(line_start, captures)` for every match of `re` outside fences and inline code.
146pub(crate) fn outside_code(body: &str, re: &regex::Regex, mut each: impl FnMut(usize, &regex::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
167/// Byte ranges of inline code on one line.
168fn 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
203/// Resolves one reference.
204pub 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
271/// Every reference in `body`, resolved.
272/// Markdown links to a document id count as references here when they resolve, so an export
273/// rewrites them; one that resolves to nothing is left as written.
274pub 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 ![](d.png \"t\"), `[no](code)`.\n```\n[no](fence)\n```\n![alt](<a b.png>)\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), ![i](img:a/b),\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}