Skip to main content

specfold_core/markdown/
link_dest.rs

1//! Link destinations in a body, the way the import and `mv` rewrite them (C180): inline links
2//! and images (`[t](a.md#x)`, `[t](<a b.md>)`) and reference definitions (`[r]: a.md "title"`),
3//! outside fenced and inline code. A percent-encoded destination is matched decoded and written
4//! back encoded.
5
6use crate::export::links::{md_link_re, md_ref_def_re, outside_code};
7
8/// Byte ranges of every link destination in `body` (without `<` `>`), in order.
9pub fn spans(body: &str) -> Vec<(usize, usize)> {
10    let mut out = vec![];
11    for (re, groups) in [(md_link_re(), [3, 4]), (md_ref_def_re(), [2, 3])] {
12        outside_code(body, re, |start, m| {
13            if let Some(g) = groups.iter().find_map(|&i| m.get(i)) {
14                out.push((start + g.start(), start + g.end()));
15            }
16        });
17    }
18    out.sort_unstable();
19    out
20}
21
22/// `a%20b.md` → `a b.md`; text that does not decode to UTF-8 stays as written.
23pub fn decode(p: &str) -> String {
24    let b = p.as_bytes();
25    let mut out = Vec::with_capacity(b.len());
26    let mut i = 0;
27    while i < b.len() {
28        let hex = |c: u8| (c as char).to_digit(16);
29        match (b[i], b.get(i + 1).copied().and_then(hex), b.get(i + 2).copied().and_then(hex)) {
30            (b'%', Some(h), Some(l)) => {
31                out.push((h * 16 + l) as u8);
32                i += 3;
33            }
34            (c, _, _) => {
35                out.push(c);
36                i += 1;
37            }
38        }
39    }
40    String::from_utf8(out).unwrap_or_else(|_| p.to_string())
41}
42
43/// Percent-encodes everything but unreserved characters and `/`.
44pub fn encode(p: &str) -> String {
45    p.bytes()
46        .map(|c| match c {
47            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => (c as char).to_string(),
48            _ => format!("%{c:02X}"),
49        })
50        .collect()
51}
52
53/// Rewrites each destination whose path (decoded, `#fragment` and `?query` apart) `new_path`
54/// maps to a new path; the fragment is kept, and an encoded destination stays encoded. Returns
55/// the body and how many destinations changed.
56pub fn rewrite(body: &str, mut new_path: impl FnMut(&str) -> Option<String>) -> (String, usize) {
57    let mut out = String::with_capacity(body.len());
58    let (mut at, mut n) = (0, 0);
59    for (a, b) in spans(body) {
60        let dest = &body[a..b];
61        let cut = dest.find(['#', '?']).unwrap_or(dest.len());
62        let (path, tail) = dest.split_at(cut);
63        if path.is_empty() {
64            continue;
65        }
66        let Some(new) = new_path(&decode(path)) else { continue };
67        let new = if path.contains('%') { encode(&new) } else { new };
68        out.push_str(&body[at..a]);
69        out.push_str(&new);
70        out.push_str(tail);
71        at = b;
72        n += 1;
73    }
74    out.push_str(&body[at..]);
75    (out, n)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn every_link_form_is_rewritten_outside_code() {
84        let body = "[a](old.md#x) [b](<old.md>) [c](%EC%98%9B.md)\n\n[r]: old.md \"t\"\n`[d](old.md)`\n```\n[e](old.md)\n```\n";
85        let (out, n) = rewrite(body, |p| matches!(p, "old.md" | "옛.md").then(|| "new dir/새.md".to_string()));
86        assert_eq!(n, 4);
87        assert_eq!(
88            out,
89            "[a](new dir/새.md#x) [b](<new dir/새.md>) [c](new%20dir/%EC%83%88.md)\n\n[r]: new dir/새.md \"t\"\n`[d](old.md)`\n```\n[e](old.md)\n```\n"
90        );
91        assert_eq!(decode("%ZZ%41"), "%ZZA");
92    }
93}