mdbook_repo_links/
links.rs1pub 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
67fn 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
77fn 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(" [y](<b.rs>)"),
122 " [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}