1use std::ops::Range;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LinkKind {
17 Wiki,
19 Markdown,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct InlineLink<'a> {
26 pub kind: LinkKind,
27 pub label: &'a str,
30 pub target: &'a str,
32 pub range: Range<usize>,
34}
35
36pub fn scan_inline_links(text: &str) -> InlineLinks<'_> {
44 InlineLinks { text, i: 0 }
45}
46
47pub struct InlineLinks<'a> {
49 text: &'a str,
50 i: usize,
51}
52
53impl<'a> Iterator for InlineLinks<'a> {
54 type Item = InlineLink<'a>;
55
56 fn next(&mut self) -> Option<Self::Item> {
57 let bytes = self.text.as_bytes();
58 while self.i < bytes.len() {
59 let i = self.i;
60 if i + 1 < bytes.len()
63 && bytes[i] == b'['
64 && bytes[i + 1] == b'['
65 && let Some(end) = find_closing_double_bracket(bytes, i + 2)
66 {
67 let target = self.text[i + 2..end].trim();
68 if !target.is_empty() {
69 self.i = end + 2;
70 return Some(InlineLink {
71 kind: LinkKind::Wiki,
72 label: target,
73 target,
74 range: i..end + 2,
75 });
76 }
77 }
78 if bytes[i] == b'['
80 && let Some(label_end) = find_byte(bytes, b']', i + 1)
81 && label_end + 1 < bytes.len()
82 && bytes[label_end + 1] == b'('
83 && let Some(target_end) = find_byte(bytes, b')', label_end + 2)
84 {
85 let target = self.text[label_end + 2..target_end].trim();
86 if !target.is_empty() {
87 self.i = target_end + 1;
88 return Some(InlineLink {
89 kind: LinkKind::Markdown,
90 label: &self.text[i + 1..label_end],
91 target,
92 range: i..target_end + 1,
93 });
94 }
95 }
96 self.i += 1;
97 }
98 None
99 }
100}
101
102pub fn rewrite_inline_links(
110 text: &str,
111 mut rewrite: impl FnMut(LinkKind, &str) -> Option<String>,
112) -> String {
113 let mut out = String::with_capacity(text.len());
114 let mut cursor = 0;
115 for link in scan_inline_links(text) {
116 out.push_str(&text[cursor..link.range.start]);
117 match rewrite(link.kind, link.target) {
118 Some(new_target) => {
119 out.push('[');
120 out.push_str(link.label);
121 out.push_str("](");
122 out.push_str(&new_target);
123 out.push(')');
124 }
125 None => out.push_str(&text[link.range.clone()]),
126 }
127 cursor = link.range.end;
128 }
129 out.push_str(&text[cursor..]);
130 out
131}
132
133fn find_closing_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
134 let mut i = start;
135 while i + 1 < bytes.len() {
136 if bytes[i] == b']' && bytes[i + 1] == b']' {
137 return Some(i);
138 }
139 i += 1;
140 }
141 None
142}
143
144fn find_byte(bytes: &[u8], target: u8, start: usize) -> Option<usize> {
145 (start..bytes.len()).find(|&i| bytes[i] == target)
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn scan(text: &str) -> Vec<InlineLink<'_>> {
153 scan_inline_links(text).collect()
154 }
155
156 #[test]
157 fn scans_wiki_kind_label_target_range() {
158 let links = scan("see [[My Page]] here");
159 assert_eq!(links.len(), 1);
160 let link = &links[0];
161 assert_eq!(link.kind, LinkKind::Wiki);
162 assert_eq!(link.label, "My Page");
163 assert_eq!(link.target, "My Page");
164 assert_eq!(link.range, 4..15);
166 assert_eq!(&"see [[My Page]] here"[link.range.clone()], "[[My Page]]");
167 }
168
169 #[test]
170 fn scans_markdown_kind_label_target_range() {
171 let links = scan("a [label](page:abc) b");
172 assert_eq!(links.len(), 1);
173 let link = &links[0];
174 assert_eq!(link.kind, LinkKind::Markdown);
175 assert_eq!(link.label, "label");
176 assert_eq!(link.target, "page:abc");
177 assert_eq!(link.range, 2..19);
178 assert_eq!(
179 &"a [label](page:abc) b"[link.range.clone()],
180 "[label](page:abc)"
181 );
182 }
183
184 #[test]
185 fn scans_mixed_in_source_order() {
186 let links = scan("see [[wiki]] and [md](target) done");
187 assert_eq!(links.len(), 2);
188 assert_eq!(links[0].kind, LinkKind::Wiki);
189 assert_eq!(links[0].target, "wiki");
190 assert_eq!(links[1].kind, LinkKind::Markdown);
191 assert_eq!(links[1].label, "md");
192 assert_eq!(links[1].target, "target");
193 }
194
195 #[test]
196 fn target_is_trimmed_but_range_covers_whitespace() {
197 let links = scan("[label]( url )");
198 assert_eq!(links.len(), 1);
199 assert_eq!(links[0].target, "url");
200 assert_eq!(links[0].range, 0..16);
201 }
202
203 #[test]
204 fn empty_targets_are_skipped() {
205 assert!(scan("[]()").is_empty());
206 assert!(scan("[[]]").is_empty());
207 assert!(scan("[label]( )").is_empty());
208 assert!(scan("[[ ]]").is_empty());
209 }
210
211 #[test]
212 fn wiki_takes_precedence_over_markdown() {
213 let links = scan("[[x](y)]]");
215 assert_eq!(links.len(), 1);
216 assert_eq!(links[0].kind, LinkKind::Wiki);
217 assert_eq!(links[0].target, "x](y)");
218 }
219
220 #[test]
221 fn unclosed_wiki_falls_through_to_markdown() {
222 let links = scan("[[text](url)");
224 assert_eq!(links.len(), 1);
225 assert_eq!(links[0].kind, LinkKind::Markdown);
226 assert_eq!(links[0].label, "[text");
227 assert_eq!(links[0].target, "url");
228 }
229
230 #[test]
231 fn unicode_content_in_labels_and_targets() {
232 let links = scan("[名前](ページ) [[日本語]]");
233 assert_eq!(links.len(), 2);
234 assert_eq!(links[0].target, "ページ");
235 assert_eq!(links[1].target, "日本語");
236 }
237
238 #[test]
239 fn rewrite_replaces_with_callback_target() {
240 let out = rewrite_inline_links("see [a](b) and [[c]]", |kind, target| match kind {
241 LinkKind::Markdown => Some(format!("md:{target}")),
242 LinkKind::Wiki => Some(format!("wiki:{target}")),
243 });
244 assert_eq!(out, "see [a](md:b) and [c](wiki:c)");
245 }
246
247 #[test]
248 fn rewrite_none_leaves_span_verbatim() {
249 let out = rewrite_inline_links("[a]( keep ) and [[ keep ]]", |_, _| None);
251 assert_eq!(out, "[a]( keep ) and [[ keep ]]");
252 }
253
254 #[test]
255 fn rewrite_callback_sees_kind_and_trimmed_target() {
256 let mut seen = Vec::new();
257 let _ = rewrite_inline_links("[a]( b ) [[ c ]]", |kind, target| {
258 seen.push((kind, target.to_string()));
259 None
260 });
261 assert_eq!(
262 seen,
263 vec![
264 (LinkKind::Markdown, "b".to_string()),
265 (LinkKind::Wiki, "c".to_string()),
266 ]
267 );
268 }
269
270 #[test]
271 fn rewrite_is_identity_on_plain_text() {
272 let out = rewrite_inline_links("no links here at all", |_, _| Some("x".to_string()));
273 assert_eq!(out, "no links here at all");
274 }
275}