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