Skip to main content

okf_core/
links.rs

1//! Markdown link extraction, classification, and path-valued fields.
2//!
3//! OKF relationships are expressed as ordinary markdown links, so this module
4//! provides a small, dependency-free scanner for inline `[text](dest)` links
5//! plus the link-classification rules (absolute bundle-relative vs.
6//! relative vs. external). It ignores links inside fenced code blocks and
7//! inline code spans, which are content rather than relationships.
8//!
9//! The same path grammar extends to *frontmatter* fields (`resource`,
10//! `sources[].resource`, `computation`, `executor.resource`, and
11//! `attester.resource`), which are resolved by
12//! [`field_path_candidates`] rather than by [`Link::resolve`].
13//!
14//! It also still parses the v0.1 body `# Citations` list
15//! ([`extract_citations`]), which v0.2 supersedes with `sources` but
16//! which consumers MAY keep reading for legacy documents.
17
18use crate::concept_id::ConceptId;
19
20/// How a link target is interpreted.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum LinkKind {
23    /// Begins with `/`: resolved relative to the bundle root (recommended).
24    Absolute,
25    /// A relative path such as `./other.md`.
26    Relative,
27    /// An external URI (`https://…`, `mailto:…`, …).
28    External,
29    /// A pure in-document anchor (`#section`).
30    Anchor,
31    /// Anything else (e.g. an empty target).
32    Other,
33}
34
35/// A markdown link found in a concept body.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct Link {
38    /// The link text (between `[` and `]`).
39    pub text: String,
40    /// The raw destination (between `(` and `)`), with any title removed.
41    pub target: String,
42    /// The classification of [`Link::target`].
43    pub kind: LinkKind,
44}
45
46impl Link {
47    /// Classifies a raw target string.
48    #[must_use]
49    pub fn classify(target: &str) -> LinkKind {
50        let t = target.trim();
51        if t.is_empty() {
52            LinkKind::Other
53        } else if t.starts_with('#') {
54            LinkKind::Anchor
55        } else if is_external(t) {
56            LinkKind::External
57        } else if t.starts_with('/') {
58            LinkKind::Absolute
59        } else {
60            LinkKind::Relative
61        }
62    }
63
64    /// Resolves an internal link to the concept id it points at, given the id
65    /// of the concept the link appears in.
66    ///
67    /// Returns `None` for external links, anchors, links to directories
68    /// (targets ending in `/`), or targets that cannot form a valid concept id.
69    /// The result is *not* guaranteed to exist in the bundle: broken links are
70    /// permitted by the spec.
71    ///
72    /// Where a target is percent-encoded this returns the literal reading; use
73    /// [`Link::resolve_all`] to also consider the decoded one.
74    #[must_use]
75    pub fn resolve(&self, source: &ConceptId) -> Option<ConceptId> {
76        self.resolve_all(source).into_iter().next()
77    }
78
79    /// Every concept id this link may denote, most likely first.
80    ///
81    /// A markdown destination is a URL, so a concept whose filename contains a
82    /// space is normally linked as `/tables/my%20notes.md`. Decoding is offered
83    /// as a second candidate rather than applied outright, so that a file
84    /// genuinely named `my%20notes.md` still resolves by its literal spelling.
85    /// Callers should prefer the first candidate that exists in the bundle.
86    #[must_use]
87    pub fn resolve_all(&self, source: &ConceptId) -> Vec<ConceptId> {
88        let mut out = Vec::new();
89        let mut push = |target: &str| {
90            let id = match self.kind {
91                LinkKind::Absolute => resolve_absolute_path(target),
92                LinkKind::Relative => resolve_relative_path(target, source),
93                _ => None,
94            };
95            if let Some(id) = id
96                && !out.contains(&id)
97            {
98                out.push(id);
99            }
100        };
101        // Strip an anchor before decoding. Otherwise a filename containing an
102        // encoded `%23` would turn into `#` and be mistaken for the anchor
103        // delimiter on the second candidate.
104        let target = strip_anchor(&self.target);
105        push(target);
106        if let Some(decoded) = percent_decode(target) {
107            push(&decoded);
108        }
109        out
110    }
111}
112
113/// Percent-decodes a link destination, or `None` if there is nothing to decode
114/// or the result is not valid UTF-8.
115fn percent_decode(s: &str) -> Option<String> {
116    if !s.contains('%') {
117        return None;
118    }
119    let bytes = s.as_bytes();
120    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
121    let mut decoded_any = false;
122    let mut i = 0;
123    while i < bytes.len() {
124        let escape = (bytes[i] == b'%' && i + 3 <= bytes.len())
125            .then(|| &bytes[i + 1..i + 3])
126            .filter(|hex| hex.iter().all(u8::is_ascii_hexdigit));
127        if let Some(hex) = escape {
128            let hex = std::str::from_utf8(hex).ok()?;
129            out.push(u8::from_str_radix(hex, 16).ok()?);
130            decoded_any = true;
131            i += 3;
132        } else {
133            out.push(bytes[i]);
134            i += 1;
135        }
136    }
137    if !decoded_any {
138        return None;
139    }
140    String::from_utf8(out).ok()
141}
142
143/// A numbered entry under a legacy v0.1 `# Citations` heading.
144///
145/// v0.2 supersedes the body citations list with the `sources` frontmatter field
146/// and footnote attribution; consumers MAY still parse this form for
147/// v0.1 documents.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct Citation {
150    /// The citation number (the `n` in `[n]`).
151    pub number: u32,
152    /// The link text, if the entry is a markdown link.
153    pub text: Option<String>,
154    /// The cited URL/target, if present.
155    pub target: Option<String>,
156    /// The full raw text of the entry after the `[n]` marker.
157    pub raw: String,
158}
159
160/// Whether a target names something outside the bundle.
161///
162/// Any RFC-3986 scheme prefix counts, not just `http`. The spec calls `resource`
163/// "a URI that uniquely identifies the underlying asset", and producers do use
164/// non-http schemes for warehouse assets (`bigquery:project.dataset.table`);
165/// treating those as relative paths would have a consumer looking for a file
166/// that was never meant to exist.
167fn is_external(t: &str) -> bool {
168    t.starts_with("//") /* protocol-relative URL */ || has_uri_scheme(t)
169}
170
171/// Matches `scheme:` where scheme is `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`.
172fn has_uri_scheme(t: &str) -> bool {
173    let Some((scheme, _)) = t.split_once(':') else {
174        return false;
175    };
176    let mut chars = scheme.chars();
177    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
178        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
179}
180
181fn strip_anchor(target: &str) -> &str {
182    target.find('#').map_or(target, |i| &target[..i])
183}
184
185fn resolve_absolute_path(t: &str) -> Option<ConceptId> {
186    if t.ends_with('/') {
187        return None; // directory link
188    }
189    // Normalize `.`/`..` segments relative to the bundle root, consistent with
190    // relative-link resolution.
191    normalize_segments(t, &[])
192        .and_then(strip_md)
193        .and_then(|segs| ConceptId::new(segs).ok())
194}
195
196fn resolve_relative_path(t: &str, source: &ConceptId) -> Option<ConceptId> {
197    if t.is_empty() || t.ends_with('/') {
198        return None;
199    }
200    // Start from the source concept's directory.
201    let base = source
202        .parent()
203        .map(|p| p.segments().to_vec())
204        .unwrap_or_default();
205    normalize_segments(t, &base)
206        .and_then(strip_md)
207        .and_then(|segs| ConceptId::new(segs).ok())
208}
209
210/// Resolves `.`/`..`/empty components in a `/`-separated path against `base`.
211///
212/// A `..` at the bundle root is invalid rather than being allowed to disappear:
213/// silently popping an empty vector would make `../x.md` from a root concept
214/// point at `x.md` inside the bundle.
215fn normalize_segments(path: &str, base: &[String]) -> Option<Vec<String>> {
216    let mut segs = base.to_vec();
217    for comp in path.split('/') {
218        match comp {
219            "" | "." => {}
220            ".." => {
221                segs.pop()?;
222            }
223            other => segs.push(other.to_string()),
224        }
225    }
226    Some(segs)
227}
228
229/// Drops a trailing `.md` from the last segment, or `None` if there are none.
230fn strip_md(mut segs: Vec<String>) -> Option<Vec<String>> {
231    let last = segs.last_mut()?;
232    if let Some(s) = last.strip_suffix(".md") {
233        *last = s.to_string();
234    }
235    Some(segs)
236}
237
238/// Normalizes a **path-valued frontmatter field** into the
239/// bundle-relative paths it might name, most likely first.
240///
241/// `resource`, `sources[].resource`, `computation`, `executor.resource`, and
242/// `attester.resource` all accept an absolute URL, a bundle-relative path
243/// beginning with `/`, or a relative path. URLs (and anchors) yield an empty
244/// vector, since there is nothing in the bundle to resolve.
245///
246/// A relative path yields **two** candidates, because the spec uses both
247/// readings: one reading treats `../computations/revenue.md` relative to the concept,
248/// while the `references/` convention is written from the bundle root
249/// (`executor.resource: references/skills/run-on-bq.md` on a concept that lives
250/// in `computations/`). Callers should take the first candidate that exists.
251///
252/// Unlike [`Link::resolve`], the returned paths keep their file extension:
253/// these fields routinely name non-markdown files such as
254/// `references/attesters/revenue.py`.
255#[must_use]
256pub fn field_path_candidates(raw: &str, from: &ConceptId) -> Vec<String> {
257    let target = raw.trim();
258    match Link::classify(target) {
259        LinkKind::Absolute => normalize_segments(strip_anchor(target), &[])
260            .map(|segments| segments.join("/"))
261            .into_iter()
262            .collect(),
263        LinkKind::Relative => {
264            let base = from
265                .parent()
266                .map(|p| p.segments().to_vec())
267                .unwrap_or_default();
268            let stripped = strip_anchor(target);
269            let mut out = Vec::new();
270            if let Some(path) = normalize_segments(stripped, &base) {
271                out.push(path.join("/"));
272            }
273            if let Some(path) = normalize_segments(stripped, &[]) {
274                let from_root = path.join("/");
275                if !out.contains(&from_root) {
276                    out.push(from_root);
277                }
278            }
279            out.retain(|p| !p.is_empty());
280            out
281        }
282        _ => Vec::new(),
283    }
284}
285
286/// The concept id a bundle-relative markdown path denotes, or `None` if the
287/// path is not a `.md` file or is not a valid id.
288#[must_use]
289pub fn concept_id_for_path(path: &str) -> Option<ConceptId> {
290    let stem = path.strip_suffix(".md")?;
291    ConceptId::parse(stem).ok()
292}
293
294/// Extracts all inline markdown links from a body, skipping fenced code blocks
295/// and inline code spans.
296#[must_use]
297pub fn extract_links(body: &str) -> Vec<Link> {
298    let mut links = Vec::new();
299    for (_, line) in code_free_lines(body) {
300        scan_line_links(&line, &mut links);
301    }
302    links
303}
304
305/// Returns the body's lines, as `(1-based line number, text)`, with fenced
306/// code blocks removed and inline code spans blanked out.
307///
308/// Shared with [`footnotes`](crate::footnotes), which needs the same
309/// "prose only" view of the body to find attribution markers.
310pub(crate) fn code_free_lines(body: &str) -> Vec<(usize, String)> {
311    let mut out = Vec::new();
312    let mut fence: Option<char> = None;
313    for (i, line) in body.lines().enumerate() {
314        let trimmed = line.trim_start();
315        if let Some(f) = fence {
316            // Inside a fence; look for the closing marker.
317            if trimmed.starts_with(&f.to_string().repeat(3)) {
318                fence = None;
319            }
320            continue;
321        }
322        if trimmed.starts_with("```") {
323            fence = Some('`');
324            continue;
325        }
326        if trimmed.starts_with("~~~") {
327            fence = Some('~');
328            continue;
329        }
330        out.push((i + 1, blank_inline_code(line)));
331    }
332    out
333}
334
335/// Replaces inline code spans (backtick-delimited) with spaces so links inside
336/// them are not extracted.
337fn blank_inline_code(line: &str) -> String {
338    let mut out = String::with_capacity(line.len());
339    let mut in_code = false;
340    for c in line.chars() {
341        if c == '`' {
342            in_code = !in_code;
343            out.push(' ');
344        } else if in_code {
345            out.push(' ');
346        } else {
347            out.push(c);
348        }
349    }
350    out
351}
352
353/// Scans a single (code-free) line for `[text](dest)` links.
354fn scan_line_links(line: &str, out: &mut Vec<Link>) {
355    let chars: Vec<char> = line.chars().collect();
356    let mut i = 0;
357    while i < chars.len() {
358        if chars[i] == '['
359            && !is_escaped(&chars, i)
360            && let Some((text, dest, next)) = parse_inline_link(&chars, i)
361        {
362            let target = clean_destination(&dest);
363            out.push(Link {
364                text,
365                kind: Link::classify(&target),
366                target,
367            });
368            i = next;
369            continue;
370        }
371        i += 1;
372    }
373}
374
375/// Whether the character at `index` is preceded by an odd number of
376/// backslashes, and is therefore escaped in Markdown.
377const fn is_escaped(chars: &[char], index: usize) -> bool {
378    let mut backslashes = 0;
379    let mut i = index;
380    while i > 0 && chars[i - 1] == '\\' {
381        backslashes += 1;
382        i -= 1;
383    }
384    backslashes % 2 == 1
385}
386
387/// Attempts to parse `[text](dest)` starting at `start` (the `[`). Returns the
388/// text, destination, and index just past the closing `)`.
389fn parse_inline_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
390    // Match the link text up to a balanced `]`.
391    let mut i = start + 1;
392    let mut depth = 1;
393    let text_start = i;
394    while i < chars.len() {
395        match chars[i] {
396            '\\' => i += 1, // skip escaped char
397            '[' => depth += 1,
398            ']' => {
399                depth -= 1;
400                if depth == 0 {
401                    break;
402                }
403            }
404            _ => {}
405        }
406        i += 1;
407    }
408    if depth != 0 || i >= chars.len() {
409        return None;
410    }
411    let text: String = chars[text_start..i].iter().collect();
412    // Next non-space char must be '('.
413    let mut j = i + 1;
414    if j >= chars.len() || chars[j] != '(' {
415        return None;
416    }
417    j += 1;
418    let dest_start = j;
419    let mut paren = 1;
420    while j < chars.len() {
421        match chars[j] {
422            '\\' => j += 1,
423            '(' => paren += 1,
424            ')' => {
425                paren -= 1;
426                if paren == 0 {
427                    break;
428                }
429            }
430            _ => {}
431        }
432        j += 1;
433    }
434    if paren != 0 || j >= chars.len() {
435        return None;
436    }
437    let dest: String = chars[dest_start..j].iter().collect();
438    Some((text, dest, j + 1))
439}
440
441/// Normalizes a raw link destination.
442///
443/// Unwraps the `CommonMark` `<...>` form, which is how a destination is allowed
444/// to contain spaces, and otherwise removes an optional title suffix. A
445/// bracketed destination is taken literally, since a space inside it is part of
446/// the path rather than a separator before a title.
447fn clean_destination(dest: &str) -> String {
448    let d = dest.trim();
449    if let Some(rest) = d.strip_prefix('<')
450        && let Some(end) = rest.find('>')
451    {
452        return rest[..end].to_string();
453    }
454    strip_title(d)
455}
456
457/// Removes an optional `"title"` (or `'title'`) suffix from a link destination.
458fn strip_title(dest: &str) -> String {
459    let d = dest.trim();
460    if let Some(idx) = d.find([' ', '\t']) {
461        let (url, rest) = d.split_at(idx);
462        let rest = rest.trim_start();
463        if rest.starts_with('"') || rest.starts_with('\'') {
464            return url.to_string();
465        }
466    }
467    d.to_string()
468}
469
470/// Extracts numbered citation entries from the `# Citations` section.
471#[must_use]
472pub fn extract_citations(body: &str) -> Vec<Citation> {
473    let mut out = Vec::new();
474    let mut in_section = false;
475    for line in body.lines() {
476        let trimmed = line.trim();
477        if let Some(heading) = trimmed.strip_prefix('#') {
478            let title = heading.trim_start_matches('#').trim();
479            if in_section {
480                // A new heading ends the citations section.
481                break;
482            }
483            in_section = title.eq_ignore_ascii_case("citations");
484            continue;
485        }
486        if !in_section || trimmed.is_empty() {
487            continue;
488        }
489        if let Some(cit) = parse_citation_line(trimmed) {
490            out.push(cit);
491        }
492    }
493    out
494}
495
496/// Parses a single `[n] …` citation line.
497fn parse_citation_line(line: &str) -> Option<Citation> {
498    let rest = line.strip_prefix('[')?;
499    let close = rest.find(']')?;
500    let number: u32 = rest[..close].trim().parse().ok()?;
501    let after = rest[close + 1..].trim().to_string();
502
503    // If the remainder is itself a markdown link, capture its text and target.
504    let mut text = None;
505    let mut target = None;
506    let chars: Vec<char> = after.chars().collect();
507    if let Some(open) = chars.iter().position(|&c| c == '[')
508        && let Some((t, dest, _)) = parse_inline_link(&chars, open)
509    {
510        text = Some(t);
511        target = Some(clean_destination(&dest));
512    }
513    Some(Citation {
514        number,
515        text,
516        target,
517        raw: after,
518    })
519}