Skip to main content

mdbook_repo_links/
links.rs

1//! Finding the link targets in Markdown, and replacing the ones a resolver
2//! has an answer for. Code is left alone: fenced blocks and inline code
3//! spans show links as text, and rewriting them would change what they show.
4
5/// `content` with every link target `resolve` answers for replaced by its
6/// answer: inline links and images, `[text](target)`, and reference
7/// definitions, `[label]: target`.
8pub fn rewrite_links(content: &str, resolve: &dyn Fn(&str) -> Option<String>) -> String {
9    let mut out = String::with_capacity(content.len());
10    let mut fence: Option<&str> = None;
11    for line in content.split_inclusive('\n') {
12        let trimmed = line.trim_start();
13        if let Some(open) = fence {
14            if trimmed.starts_with(open) {
15                fence = None;
16            }
17            out.push_str(line);
18            continue;
19        }
20        if let Some(marker) = ["```", "~~~"].into_iter().find(|m| trimmed.starts_with(m)) {
21            fence = Some(marker);
22            out.push_str(line);
23            continue;
24        }
25        out.push_str(&rewrite_line(line, resolve));
26    }
27    out
28}
29
30fn rewrite_line(line: &str, resolve: &dyn Fn(&str) -> Option<String>) -> String {
31    if let Some(rewritten) = rewrite_reference_definition(line, resolve) {
32        return rewritten;
33    }
34    let mut out = String::with_capacity(line.len());
35    let mut in_code = false;
36    let mut rest = line;
37    while let Some(at) = rest.find(['`', ']']) {
38        let (before, from) = rest.split_at(at);
39        out.push_str(before);
40        if let Some(after_tick) = from.strip_prefix('`') {
41            in_code = !in_code;
42            out.push('`');
43            rest = after_tick;
44            continue;
45        }
46        let after = match from.strip_prefix("](") {
47            Some(after) if !in_code => after,
48            _ => {
49                out.push(']');
50                rest = &from[1..];
51                continue;
52            }
53        };
54        out.push_str("](");
55        let length = target_length(after);
56        let target = &after[..length];
57        match resolve(target.trim_matches(['<', '>'])) {
58            Some(replacement) => out.push_str(&replacement),
59            None => out.push_str(target),
60        }
61        rest = &after[length..];
62    }
63    out.push_str(rest);
64    out
65}
66
67/// How much of `text`, which follows `](`, is the target: up to the space
68/// before a title or the closing parenthesis, or all of an `<…>` target.
69fn target_length(text: &str) -> usize {
70    if text.starts_with('<') {
71        return text.find('>').map_or(text.len(), |end| end + 1);
72    }
73    text.find([')', ' ', '\t', '\n', '\r'])
74        .unwrap_or(text.len())
75}
76
77/// `[label]: target` at the start of a line, the target rewritten.
78fn rewrite_reference_definition(
79    line: &str,
80    resolve: &dyn Fn(&str) -> Option<String>,
81) -> Option<String> {
82    let indent = line.len() - line.trim_start().len();
83    let body = &line[indent..];
84    let label_end = body.strip_prefix('[')?.find("]:")? + 1;
85    let after_colon = &body[label_end + 2..];
86    let spaces = after_colon.len() - after_colon.trim_start().len();
87    let rest = &after_colon[spaces..];
88    let length = target_length(rest);
89    let replacement = resolve(rest[..length].trim_matches(['<', '>']))?;
90    Some(format!(
91        "{}{}{replacement}{}",
92        &line[..indent + label_end + 2],
93        &after_colon[..spaces],
94        &rest[length..]
95    ))
96}
97
98#[cfg(test)]
99mod tests {
100    fn to_github(target: &str) -> Option<String> {
101        target
102            .ends_with(".rs")
103            .then(|| format!("https://example.test/{target}"))
104    }
105
106    fn rewrite(content: &str) -> String {
107        super::rewrite_links(content, &to_github)
108    }
109
110    #[test]
111    fn a_link_the_resolver_answers_for_is_replaced() {
112        assert_eq!(
113            rewrite("see [the zip reader](src/zip.rs) and [design](DESIGN.md)"),
114            "see [the zip reader](https://example.test/src/zip.rs) and [design](DESIGN.md)"
115        );
116    }
117
118    #[test]
119    fn a_title_and_an_image_keep_their_shape() {
120        assert_eq!(
121            rewrite("![x](a.rs \"title\") [y](<b.rs>)"),
122            "![x](https://example.test/a.rs \"title\") [y](https://example.test/b.rs)"
123        );
124    }
125
126    #[test]
127    fn code_shows_links_as_text_and_is_left_alone() {
128        let fenced = "```md\n[x](a.rs)\n```\n[x](a.rs)\n";
129        assert_eq!(
130            rewrite(fenced),
131            "```md\n[x](a.rs)\n```\n[x](https://example.test/a.rs)\n"
132        );
133        assert_eq!(
134            rewrite("`[x](a.rs)` [x](a.rs)"),
135            "`[x](a.rs)` [x](https://example.test/a.rs)"
136        );
137    }
138
139    #[test]
140    fn a_reference_definition_is_rewritten() {
141        assert_eq!(
142            rewrite("[zip]: src/zip.rs \"the reader\"\n"),
143            "[zip]: https://example.test/src/zip.rs \"the reader\"\n"
144        );
145    }
146
147    #[test]
148    fn brackets_that_are_not_links_are_untouched() {
149        let text = "a [box] then (paren) and ]( alone";
150        assert_eq!(rewrite(text), text);
151    }
152}