1#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RefSyntax {
14 WikilinkStem,
16 WikilinkPath,
18 WikilinkStemEmbed,
20 WikilinkPathEmbed,
22 WikilinkAliased { display: String },
24 WikilinkAliasedEmbed { display: String },
26 MarkdownLink { label: String },
28 MarkdownImage { alt: String },
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RawRef {
35 pub text: String,
39 pub syntax: RefSyntax,
41 pub byte_from: usize,
43 pub byte_to: usize,
45}
46
47pub fn extract_md_references(source: &str) -> Vec<RawRef> {
57 let bytes = source.as_bytes();
58 let len = bytes.len();
59 let mut refs = Vec::new();
60 let mut i = 0;
61
62 let mut fence_char: Option<u8> = None;
64 let mut line_start = 0;
66
67 while i < len {
68 if bytes[i] == b'\n' {
70 i += 1;
71 line_start = i;
72 continue;
73 }
74
75 if i == line_start {
77 let mut j = i;
79 while j < len && (bytes[j] == b' ' || bytes[j] == b'\t') && j - i < 4 {
80 j += 1;
81 }
82 let fence_cand = if j + 2 < len && bytes[j] == b'`' && bytes[j+1] == b'`' && bytes[j+2] == b'`' {
84 Some(b'`')
85 } else if j + 2 < len && bytes[j] == b'~' && bytes[j+1] == b'~' && bytes[j+2] == b'~' {
86 Some(b'~')
87 } else {
88 None
89 };
90 if let Some(fc) = fence_cand {
91 if let Some(cur_fc) = fence_char {
92 if cur_fc == fc {
93 let mut k = j + 3;
95 while k < len && bytes[k] == fc { k += 1; }
96 while k < len && bytes[k] == b' ' { k += 1; }
98 if k >= len || bytes[k] == b'\n' {
99 fence_char = None;
100 while i < len && bytes[i] != b'\n' {
106 i += 1;
107 }
108 continue;
109 }
110 }
111 } else {
112 fence_char = Some(fc);
113 }
114 }
115 }
116
117 if fence_char.is_some() {
119 while i < len && bytes[i] != b'\n' {
120 i += 1;
121 }
122 continue;
123 }
124
125 if bytes[i] == b'`' {
127 let mut n = 0;
129 while i + n < len && bytes[i + n] == b'`' { n += 1; }
130 let start = i;
131 i += n;
132 while i < len {
134 if bytes[i] == b'`' {
135 let mut m = 0;
136 while i + m < len && bytes[i + m] == b'`' { m += 1; }
137 if m == n {
138 i += m;
139 break;
140 }
141 i += m;
142 } else {
143 i += 1;
144 }
145 }
146 let _ = start;
147 continue;
148 }
149
150 if bytes[i] == b'\\' {
155 i += 1; if i < len {
157 #[allow(clippy::string_slice)]
159 if let Some(ch) = source[i..].chars().next() {
160 i += ch.len_utf8();
161 }
162 }
163 continue;
164 }
165
166 let is_embed_wikilink = i + 4 < len
168 && bytes[i] == b'!'
169 && bytes[i+1] == b'['
170 && bytes[i+2] == b'[';
171 let is_wikilink = !is_embed_wikilink
172 && i + 3 < len
173 && bytes[i] == b'['
174 && bytes[i+1] == b'[';
175
176 if is_embed_wikilink || is_wikilink {
177 let token_start = i;
178 let inner_start = if is_embed_wikilink { i + 3 } else { i + 2 };
179 if let Some(close) = find_double_bracket(bytes, inner_start) {
181 #[allow(clippy::string_slice)]
184 let inner = &source[inner_start..close];
185 let token_end = close + 2;
186 let (path_part, pipe_part) = match inner.find('|') {
188 Some(p) => (&inner[..p], Some(&inner[p+1..])),
189 None => (inner, None),
190 };
191 if !path_part.trim().is_empty() {
193 let text = path_part.trim().to_string();
194 let has_slash = text.contains('/');
195 let syntax = match (is_embed_wikilink, pipe_part) {
196 (false, None) => {
197 if has_slash { RefSyntax::WikilinkPath } else { RefSyntax::WikilinkStem }
198 }
199 (false, Some(alias)) => RefSyntax::WikilinkAliased { display: alias.to_string() },
200 (true, None) => {
201 if has_slash { RefSyntax::WikilinkPathEmbed } else { RefSyntax::WikilinkStemEmbed }
202 }
203 (true, Some(pot)) => RefSyntax::WikilinkAliasedEmbed { display: pot.to_string() },
204 };
205 refs.push(RawRef { text, syntax, byte_from: token_start, byte_to: token_end });
206 }
207 i = token_end;
208 continue;
209 }
210 }
211
212 if i + 3 < len && bytes[i] == b'!' && bytes[i+1] == b'[' {
214 if let Some((alt, path, end)) = parse_md_link(source, bytes, i + 1) {
215 let token_start = i;
216 refs.push(RawRef {
217 text: path,
218 syntax: RefSyntax::MarkdownImage { alt },
219 byte_from: token_start,
220 byte_to: end,
221 });
222 i = end;
223 continue;
224 }
225 }
226
227 if bytes[i] == b'[' {
229 if i + 1 < len && bytes[i+1] != b'[' {
231 if let Some((label, path, end)) = parse_md_link(source, bytes, i) {
232 refs.push(RawRef {
233 text: path,
234 syntax: RefSyntax::MarkdownLink { label },
235 byte_from: i,
236 byte_to: end,
237 });
238 i = end;
239 continue;
240 }
241 }
242 }
243
244 i += 1;
245 }
246
247 refs
248}
249
250fn find_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
253 let mut j = start;
254 while j + 1 < bytes.len() {
255 if bytes[j] == b']' && bytes[j+1] == b']' {
256 return Some(j);
257 }
258 if bytes[j] == b'\n' {
260 return None;
261 }
262 j += 1;
263 }
264 None
265}
266
267fn parse_md_link(source: &str, bytes: &[u8], bracket_pos: usize) -> Option<(String, String, usize)> {
271 let len = bytes.len();
272 let mut depth = 0usize;
274 let mut j = bracket_pos;
275 while j < len {
276 match bytes[j] {
277 b'[' => { depth += 1; j += 1; }
278 b']' => {
279 depth -= 1;
280 if depth == 0 { break; }
281 j += 1;
282 }
283 b'\n' => return None,
284 _ => { j += 1; }
285 }
286 }
287 if j >= len || bytes[j] != b']' { return None; }
288 let label_start = bracket_pos + 1;
289 let label_end = j;
290 #[allow(clippy::string_slice)]
291 let label = source[label_start..label_end].to_string();
292
293 let paren_open = j + 1;
295 if paren_open >= len || bytes[paren_open] != b'(' { return None; }
296
297 let mut depth = 0usize;
299 let mut k = paren_open;
300 while k < len {
301 match bytes[k] {
302 b'(' => { depth += 1; k += 1; }
303 b')' => {
304 depth -= 1;
305 if depth == 0 { break; }
306 k += 1;
307 }
308 b'\n' => return None,
309 _ => { k += 1; }
310 }
311 }
312 if k >= len || bytes[k] != b')' { return None; }
313 let path_start = paren_open + 1;
314 let path_end = k;
315 #[allow(clippy::string_slice)]
316 let path_raw = source[path_start..path_end].trim().to_string();
317 let path = strip_link_title(&path_raw);
319 let token_end = k + 1;
320
321 Some((label, path, token_end))
322}
323
324fn strip_link_title(raw: &str) -> String {
328 let raw = raw.trim();
329 if let Some(ws) = raw.rfind(|c: char| c.is_ascii_whitespace()) {
331 let (path_part, maybe_title) = raw.split_at(ws);
332 let maybe_title = maybe_title.trim();
333 let is_title = (maybe_title.starts_with('"') && maybe_title.ends_with('"'))
334 || (maybe_title.starts_with('\'') && maybe_title.ends_with('\''))
335 || (maybe_title.starts_with('(') && maybe_title.ends_with(')'));
336 if is_title {
337 return path_part.trim().to_string();
338 }
339 }
340 raw.to_string()
341}
342
343#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn wikilink_stem() {
351 let src = "See [[note]] for details.";
352 let refs = extract_md_references(src);
353 assert_eq!(refs.len(), 1);
354 assert_eq!(refs[0].text, "note");
355 assert_eq!(refs[0].syntax, RefSyntax::WikilinkStem);
356 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[note]]");
357 }
358
359 #[test]
360 fn wikilink_path() {
361 let src = "See [[a/b]] here.";
362 let refs = extract_md_references(src);
363 assert_eq!(refs.len(), 1);
364 assert_eq!(refs[0].text, "a/b");
365 assert_eq!(refs[0].syntax, RefSyntax::WikilinkPath);
366 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a/b]]");
367 }
368
369 #[test]
370 fn wikilink_stem_embed() {
371 let src = "![[x]] is an embed.";
372 let refs = extract_md_references(src);
373 assert_eq!(refs.len(), 1);
374 assert_eq!(refs[0].text, "x");
375 assert_eq!(refs[0].syntax, RefSyntax::WikilinkStemEmbed);
376 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[x]]");
377 }
378
379 #[test]
380 fn wikilink_path_embed() {
381 let src = "![[a/b]] embedded.";
382 let refs = extract_md_references(src);
383 assert_eq!(refs.len(), 1);
384 assert_eq!(refs[0].text, "a/b");
385 assert_eq!(refs[0].syntax, RefSyntax::WikilinkPathEmbed);
386 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[a/b]]");
387 }
388
389 #[test]
390 fn wikilink_aliased() {
391 let src = "See [[stem|Display]] here.";
392 let refs = extract_md_references(src);
393 assert_eq!(refs.len(), 1);
394 assert_eq!(refs[0].text, "stem");
395 assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliased { display } if display == "Display"));
396 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[stem|Display]]");
397 }
398
399 #[test]
400 fn wikilink_aliased_embed() {
401 let src = "![[stem|500]] wide embed.";
402 let refs = extract_md_references(src);
403 assert_eq!(refs.len(), 1);
404 assert_eq!(refs[0].text, "stem");
405 assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "500"));
406 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[stem|500]]");
407 }
408
409 #[test]
410 fn markdown_link() {
411 let src = "Click [here](page.md) now.";
412 let refs = extract_md_references(src);
413 assert_eq!(refs.len(), 1);
414 assert_eq!(refs[0].text, "page.md");
415 assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { label } if label == "here"));
416 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[here](page.md)");
417 }
418
419 #[test]
420 fn markdown_image() {
421 let src = " here.";
422 let refs = extract_md_references(src);
423 assert_eq!(refs.len(), 1);
424 assert_eq!(refs[0].text, "img.png");
425 assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownImage { alt } if alt == "alt text"));
426 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "");
427 }
428
429 #[test]
430 fn external_link_included() {
431 let src = "[foo](https://example.com)";
432 let refs = extract_md_references(src);
433 assert_eq!(refs.len(), 1);
434 assert_eq!(refs[0].text, "https://example.com");
435 assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { .. }));
436 }
437
438 #[test]
439 fn skip_fenced_code_block() {
440 let src = "```\n[[note]]\n```\nAfter.";
441 let refs = extract_md_references(src);
442 assert_eq!(refs.len(), 0, "wikilink inside fenced block should be skipped");
443 }
444
445 #[test]
446 fn ref_after_fence_is_found() {
447 let src = "```\n[[skip]]\n```\n[[find]]";
451 let refs = extract_md_references(src);
452 assert_eq!(refs.len(), 1, "exactly the ref after the fence is found, got: {:?}", refs);
453 assert_eq!(refs[0].text, "find");
454 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[find]]");
455 }
456
457 #[test]
458 fn backslash_escaped_refs_are_skipped() {
459 let src = "Escaped \\[[note]] and \\[t](p.md) but [[real]] counts.";
461 let refs = extract_md_references(src);
462 assert_eq!(refs.len(), 1, "only the unescaped ref should be found, got: {:?}", refs);
463 assert_eq!(refs[0].text, "real");
464 }
465
466 #[test]
467 fn skip_inline_code_span() {
468 let src = "In `` [[note]] `` code.";
469 let refs = extract_md_references(src);
470 assert_eq!(refs.len(), 0, "wikilink inside inline code should be skipped");
471 }
472
473 #[test]
474 fn multiple_refs_byte_offsets() {
475 let src = "[[a]] and [[b]]";
476 let refs = extract_md_references(src);
477 assert_eq!(refs.len(), 2);
478 assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a]]");
479 assert_eq!(&src[refs[1].byte_from..refs[1].byte_to], "[[b]]");
480 }
481
482 #[test]
483 fn aliased_embed_preserves_alias() {
484 let src = "![[image.png|600]]";
486 let refs = extract_md_references(src);
487 assert_eq!(refs.len(), 1);
488 assert_eq!(refs[0].text, "image.png");
489 assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "600"));
490 }
491}