Skip to main content

lean_ctx_doc/
links.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Links inside page bodies: `[Title](/slug#frag)` to another page and
4//! `[Title](emspace:tasks/<id>)`-style references to other objects.
5//! Code fences and inline code spans are never touched.
6
7use crate::sections::Fence;
8
9/// What a link points at.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Target<'a> {
12    /// A page of the same space, by slug, with an optional `#fragment`.
13    Page {
14        slug: &'a str,
15        fragment: Option<&'a str>,
16    },
17    /// A Hive node by id (`hive:n_…`), with an optional `#heading-slug`.
18    Node {
19        id: &'a str,
20        fragment: Option<&'a str>,
21    },
22    /// An object addressed `<scheme>:<kind>/<id>`, e.g. `emspace:tasks/01a1…`.
23    Object {
24        scheme: &'a str,
25        kind: &'a str,
26        id: &'a str,
27    },
28    /// Anything else: a URL, a relative path, an anchor.
29    Other(&'a str),
30}
31
32/// Classifies a link target.
33pub fn classify(target: &str) -> Target<'_> {
34    if let Some(path) = target.strip_prefix('/') {
35        if path.starts_with('/') || path.is_empty() {
36            return Target::Other(target);
37        }
38        let (slug, fragment) = match path.split_once('#') {
39            Some((s, f)) => (s, Some(f)),
40            None => (path, None),
41        };
42        return Target::Page {
43            slug: slug.trim_end_matches('/'),
44            fragment,
45        };
46    }
47    if let Some(node) = target.strip_prefix("hive:") {
48        let (id, fragment) = match node.split_once('#') {
49            Some((i, f)) => (i, Some(f)),
50            None => (node, None),
51        };
52        if id.starts_with("n_")
53            && id.len() > 2
54            && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
55        {
56            return Target::Node { id, fragment };
57        }
58    }
59    if let Some((scheme, rest)) = target.split_once(':') {
60        let plain = |s: &str| {
61            !s.is_empty()
62                && s.bytes()
63                    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
64        };
65        if let Some((kind, id)) = rest.split_once('/') {
66            if plain(scheme) && plain(kind) && plain(id) && !rest.starts_with("//") {
67                return Target::Object { scheme, kind, id };
68            }
69        }
70    }
71    Target::Other(target)
72}
73
74/// What to do with one link.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Edit {
77    /// Point it at this target instead.
78    Target(String),
79    /// Drop the link and keep its text.
80    TextOnly,
81}
82
83/// Every link target in `body`, in order.
84pub fn targets(body: &str) -> Vec<&str> {
85    let mut out = Vec::new();
86    scan(body, |target| {
87        out.push(target);
88        None
89    });
90    out
91}
92
93/// `body` with each link target replaced by `f(target)` where it returns one.
94pub fn rewrite(body: &str, mut f: impl FnMut(&str) -> Option<String>) -> String {
95    scan(body, |t| f(t).map(Edit::Target))
96}
97
98/// C0 2.2 write rule: `/slug[#frag]` naming a live page becomes
99/// `hive:<id>[#frag]`. Returns the body and the targets no page has, which
100/// stay as written. Other targets are untouched.
101pub fn to_storage(body: &str, id_of: impl Fn(&str) -> Option<String>) -> (String, Vec<String>) {
102    let mut dangling = Vec::new();
103    let out = scan(body, |t| {
104        let Target::Page { slug, fragment } = classify(t) else {
105            return None;
106        };
107        let id = id_of(slug);
108        if id.is_none() {
109            dangling.push(t.to_owned());
110        }
111        id.map(|id| Edit::Target(with_fragment(&format!("hive:{id}"), fragment)))
112    });
113    (out, dangling)
114}
115
116/// C0 2.2 export rule: `hive:<id>[#frag]` becomes the target page's
117/// `<slug>.md` relative to `from_slug`'s; a link to no live page keeps its
118/// text only.
119pub fn to_export(body: &str, from_slug: &str, slug_of: impl Fn(&str) -> Option<String>) -> String {
120    scan(body, |t| match classify(t) {
121        Target::Node { id, fragment } => Some(match slug_of(id) {
122            Some(slug) => Edit::Target(with_fragment(&relative_md(from_slug, &slug), fragment)),
123            None => Edit::TextOnly,
124        }),
125        _ => None,
126    })
127}
128
129/// `hive:<id>[#frag]` back to `/slug[#frag]`, for agents reading a page.
130pub fn to_slugs(body: &str, slug_of: impl Fn(&str) -> Option<String>) -> String {
131    scan(body, |t| match classify(t) {
132        Target::Node { id, fragment } => {
133            slug_of(id).map(|slug| Edit::Target(with_fragment(&format!("/{slug}"), fragment)))
134        }
135        _ => None,
136    })
137}
138
139fn with_fragment(base: &str, fragment: Option<&str>) -> String {
140    match fragment {
141        Some(f) => format!("{base}#{f}"),
142        None => base.to_owned(),
143    }
144}
145
146/// The path of `to`'s `<slug>.md` relative to the directory of `from`'s.
147pub fn relative_md(from: &str, to: &str) -> String {
148    let from_dirs: Vec<&str> = from.split('/').collect();
149    let from_dirs = &from_dirs[..from_dirs.len().saturating_sub(1)];
150    let to_parts: Vec<&str> = to.split('/').collect();
151    let common = from_dirs
152        .iter()
153        .zip(&to_parts)
154        .take_while(|(a, b)| a == b)
155        .count();
156    let mut out: Vec<&str> = vec![".."; from_dirs.len() - common];
157    out.extend(&to_parts[common..]);
158    format!("{}.md", out.join("/"))
159}
160
161/// The heading slugs of a page's markdown in reading order, `# ` title
162/// included: GitHub style, a repeat gets `-1`, `-2`, … (C0 2.2).
163pub fn heading_slugs(markdown: &str) -> Vec<String> {
164    headings(markdown).into_iter().map(|h| h.slug).collect()
165}
166
167/// One ATX heading of a page's markdown.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Heading {
170    /// Byte offset of the heading line.
171    pub start: usize,
172    /// 1 for `#`, 2 for `##`, ….
173    pub level: usize,
174    pub text: String,
175    /// Its slug, `-1`, `-2`, … for repeats (C0 2.2).
176    pub slug: String,
177}
178
179/// The headings outside code fences, in reading order.
180pub fn headings(markdown: &str) -> Vec<Heading> {
181    let mut fence = Fence::default();
182    let mut seen = std::collections::BTreeMap::<String, usize>::new();
183    let mut out = Vec::new();
184    let mut pos = 0;
185    for line in markdown.split_inclusive('\n') {
186        let start = pos;
187        pos += line.len();
188        if fence.feed(line) {
189            continue;
190        }
191        let hashes = line.bytes().take_while(|b| *b == b'#').count();
192        if !(1..=6).contains(&hashes) || line.as_bytes().get(hashes) != Some(&b' ') {
193            continue;
194        }
195        let text = line[hashes + 1..].trim().to_owned();
196        let base = heading_slug(&text);
197        let n = seen.entry(base.clone()).or_insert(0);
198        let slug = if *n == 0 { base } else { format!("{base}-{n}") };
199        *n += 1;
200        out.push(Heading {
201            start,
202            level: hashes,
203            text,
204            slug,
205        });
206    }
207    out
208}
209
210/// The stretch from the heading with slug `want` to the next heading of the
211/// same or a higher level.
212pub fn section_of<'a>(markdown: &'a str, want: &str) -> Option<&'a str> {
213    let hs = headings(markdown);
214    let i = hs.iter().position(|h| h.slug == want)?;
215    let end = hs[i + 1..]
216        .iter()
217        .find(|h| h.level <= hs[i].level)
218        .map_or(markdown.len(), |h| h.start);
219    Some(&markdown[hs[i].start..end])
220}
221
222/// One heading's slug: lowercase, each space to `-`, nothing outside `[a-z0-9-]`.
223pub fn heading_slug(heading: &str) -> String {
224    heading
225        .trim()
226        .to_lowercase()
227        .chars()
228        .filter_map(|c| match c {
229            ' ' => Some('-'),
230            'a'..='z' | '0'..='9' | '-' => Some(c),
231            _ => None,
232        })
233        .collect()
234}
235
236/// Walks the links outside code, letting `f` edit them, and returns the
237/// rewritten body.
238fn scan<'a>(body: &'a str, mut f: impl FnMut(&'a str) -> Option<Edit>) -> String {
239    let mut out = String::with_capacity(body.len());
240    let mut fence = Fence::default();
241    for line in body.split_inclusive('\n') {
242        if fence.feed(line) {
243            out.push_str(line);
244        } else {
245            scan_line(line, &mut out, &mut f);
246        }
247    }
248    out
249}
250
251/// The `)` that closes a link target starting at `start`, parentheses
252/// balanced; `None` when a space or the line end comes first.
253fn target_end(b: &[u8], start: usize) -> Option<usize> {
254    let mut depth = 0usize;
255    for (j, c) in b[start..].iter().enumerate() {
256        match c {
257            b'(' => depth += 1,
258            b')' if depth == 0 => return Some(start + j),
259            b')' => depth -= 1,
260            b' ' | b'\n' => return None,
261            _ => {}
262        }
263    }
264    None
265}
266
267fn scan_line<'a>(line: &'a str, out: &mut String, f: &mut impl FnMut(&'a str) -> Option<Edit>) {
268    let b = line.as_bytes();
269    let mut i = 0;
270    let mut copied = 0;
271    let mut opens: Vec<usize> = Vec::new();
272    while i < b.len() {
273        if b[i] == b'[' {
274            opens.push(i);
275        }
276        if b[i] == b'`' {
277            // Skip an inline code span: the same run of backticks closes it.
278            let run = b[i..].iter().take_while(|c| **c == b'`').count();
279            let ticks = &line[i..i + run];
280            match line[i + run..].find(ticks) {
281                Some(end) => i += run + end + run,
282                None => i += run,
283            }
284            continue;
285        }
286        if b[i] == b']' && b.get(i + 1) == Some(&b'(') {
287            let start = i + 2;
288            let end = target_end(b, start);
289            let open = opens.pop();
290            if let Some(end) = end {
291                match f(&line[start..end]) {
292                    Some(Edit::Target(new)) => {
293                        out.push_str(&line[copied..start]);
294                        out.push_str(&new);
295                        copied = end;
296                    }
297                    // An image (`![..](..)`) keeps its markup.
298                    Some(Edit::TextOnly) => match open {
299                        Some(o) if o >= copied && (o == 0 || b[o - 1] != b'!') => {
300                            out.push_str(&line[copied..o]);
301                            out.push_str(&line[o + 1..i]);
302                            copied = end + 1;
303                        }
304                        _ => {}
305                    },
306                    None => {}
307                }
308                i = end + 1;
309                continue;
310            }
311        }
312        i += 1;
313    }
314    out.push_str(&line[copied..]);
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn classifies_pages_objects_and_others() {
323        assert_eq!(
324            classify("/design/schema#tables"),
325            Target::Page {
326                slug: "design/schema",
327                fragment: Some("tables")
328            }
329        );
330        assert_eq!(
331            classify("/index"),
332            Target::Page {
333                slug: "index",
334                fragment: None
335            }
336        );
337        assert_eq!(
338            classify("emspace:tasks/01a1"),
339            Target::Object {
340                scheme: "emspace",
341                kind: "tasks",
342                id: "01a1"
343            }
344        );
345        assert_eq!(
346            classify("hive:n_01abc#retry-budget"),
347            Target::Node {
348                id: "n_01abc",
349                fragment: Some("retry-budget")
350            }
351        );
352        assert_eq!(classify("hive:x"), Target::Other("hive:x"));
353        assert_eq!(classify("https://x.y/z"), Target::Other("https://x.y/z"));
354        assert_eq!(classify("//cdn/x"), Target::Other("//cdn/x"));
355    }
356
357    #[test]
358    fn finds_targets_outside_code_only() {
359        let body = "see [A](/a) and `[B](/b)`\n```\n[C](/c)\n```\n[D](/d#x) [E](emspace:tasks/9)\n";
360        assert_eq!(targets(body), ["/a", "/d#x", "emspace:tasks/9"]);
361    }
362
363    #[test]
364    fn rewrites_selected_targets_losslessly() {
365        let body = "[A](/a) [U](https://u) [T](emspace:tasks/9)\n";
366        let out = rewrite(body, |t| match classify(t) {
367            Target::Object { id, .. } => Some(format!("hive:task/{id}")),
368            _ => None,
369        });
370        assert_eq!(out, "[A](/a) [U](https://u) [T](hive:task/9)\n");
371        assert_eq!(rewrite(body, |_| None), body);
372    }
373
374    // C0 2.2 vectors (hive-v2 vectors/doc_links + heading_slug).
375    fn pages() -> Vec<(&'static str, &'static str)> {
376        vec![
377            ("n_aaaaaaaaaaaaaaaaaaaaaadpga", "knowledge/wiki"),
378            ("n_aaaaaaaaaaaaaaaaaaaaaadpgb", "knowledge/knowledge-items"),
379            (
380                "n_aaaaaaaaaaaaaaaaaaaaaadpgc",
381                "architecture/place-event-store",
382            ),
383        ]
384    }
385
386    #[test]
387    fn write_vectors() {
388        let id_of = |s: &str| {
389            pages()
390                .into_iter()
391                .find(|p| p.1 == s)
392                .map(|p| p.0.to_owned())
393        };
394        let cases = [
395            (
396                "[types](/knowledge/knowledge-items#types-and-bodies)",
397                "[types](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#types-and-bodies)",
398                vec![],
399            ),
400            (
401                "[log](/architecture/place-event-store)",
402                "[log](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgc)",
403                vec![],
404            ),
405            (
406                "[x](/no/such-page)",
407                "[x](/no/such-page)",
408                vec!["/no/such-page".to_owned()],
409            ),
410            (
411                "[web](https://leanctx.com/)",
412                "[web](https://leanctx.com/)",
413                vec![],
414            ),
415        ];
416        for (input, out, dangling) in cases {
417            assert_eq!(
418                to_storage(input, id_of),
419                (out.to_owned(), dangling),
420                "{input}"
421            );
422        }
423    }
424
425    #[test]
426    fn export_vectors() {
427        let slug_of = |id: &str| {
428            pages()
429                .into_iter()
430                .find(|p| p.0 == id)
431                .map(|p| p.1.to_owned())
432        };
433        let cases = [
434            (
435                "[types](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#types-and-bodies)",
436                "[types](knowledge-items.md#types-and-bodies)",
437            ),
438            (
439                "[log](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgc)",
440                "[log](../architecture/place-event-store.md)",
441            ),
442            ("[gone](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgd)", "gone"),
443        ];
444        for (input, out) in cases {
445            assert_eq!(to_export(input, "knowledge/wiki", slug_of), out, "{input}");
446        }
447        assert_eq!(
448            to_slugs(
449                "[t](hive:n_aaaaaaaaaaaaaaaaaaaaaadpgb#x) ![i](hive:n_zz)",
450                slug_of
451            ),
452            "[t](/knowledge/knowledge-items#x) ![i](hive:n_zz)"
453        );
454        assert_eq!(
455            to_export("![i](hive:n_zz) and [g](hive:n_zz).", "a", slug_of),
456            "![i](hive:n_zz) and g."
457        );
458    }
459
460    #[test]
461    fn heading_slug_vectors() {
462        let md = "# Wiki\n## What It Does\n## C0 2.2: Docs!\n## `base_event` (CAS)\n## Rules And Edge Cases\n\
463                  ## Two  Spaces\n## Über Pages\n```\n# not a heading\n```\n## Rules And Edge Cases\n## Rules And Edge Cases\n";
464        assert_eq!(
465            heading_slugs(md),
466            [
467                "wiki",
468                "what-it-does",
469                "c0-22-docs",
470                "baseevent-cas",
471                "rules-and-edge-cases",
472                "two--spaces",
473                "ber-pages",
474                "rules-and-edge-cases-1",
475                "rules-and-edge-cases-2",
476            ]
477        );
478    }
479
480    #[test]
481    fn balanced_parentheses_stay_in_the_target() {
482        assert_eq!(
483            targets("[w](https://e.org/a_(b))\n"),
484            ["https://e.org/a_(b)"]
485        );
486    }
487}