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}