#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefSyntax {
WikilinkStem,
WikilinkPath,
WikilinkStemEmbed,
WikilinkPathEmbed,
WikilinkAliased { display: String },
WikilinkAliasedEmbed { display: String },
MarkdownLink { label: String },
MarkdownImage { alt: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RawRef {
pub text: String,
pub syntax: RefSyntax,
pub byte_from: usize,
pub byte_to: usize,
}
pub fn extract_md_references(source: &str) -> Vec<RawRef> {
let bytes = source.as_bytes();
let len = bytes.len();
let mut refs = Vec::new();
let mut i = 0;
let mut fence_char: Option<u8> = None;
let mut line_start = 0;
while i < len {
if bytes[i] == b'\n' {
i += 1;
line_start = i;
continue;
}
if i == line_start {
let mut j = i;
while j < len && (bytes[j] == b' ' || bytes[j] == b'\t') && j - i < 4 {
j += 1;
}
let fence_cand = if j + 2 < len && bytes[j] == b'`' && bytes[j+1] == b'`' && bytes[j+2] == b'`' {
Some(b'`')
} else if j + 2 < len && bytes[j] == b'~' && bytes[j+1] == b'~' && bytes[j+2] == b'~' {
Some(b'~')
} else {
None
};
if let Some(fc) = fence_cand {
if let Some(cur_fc) = fence_char {
if cur_fc == fc {
let mut k = j + 3;
while k < len && bytes[k] == fc { k += 1; }
while k < len && bytes[k] == b' ' { k += 1; }
if k >= len || bytes[k] == b'\n' {
fence_char = None;
while i < len && bytes[i] != b'\n' {
i += 1;
}
continue;
}
}
} else {
fence_char = Some(fc);
}
}
}
if fence_char.is_some() {
while i < len && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if bytes[i] == b'`' {
let mut n = 0;
while i + n < len && bytes[i + n] == b'`' { n += 1; }
let start = i;
i += n;
while i < len {
if bytes[i] == b'`' {
let mut m = 0;
while i + m < len && bytes[i + m] == b'`' { m += 1; }
if m == n {
i += m;
break;
}
i += m;
} else {
i += 1;
}
}
let _ = start;
continue;
}
if bytes[i] == b'\\' {
i += 1; if i < len {
#[allow(clippy::string_slice)]
if let Some(ch) = source[i..].chars().next() {
i += ch.len_utf8();
}
}
continue;
}
let is_embed_wikilink = i + 4 < len
&& bytes[i] == b'!'
&& bytes[i+1] == b'['
&& bytes[i+2] == b'[';
let is_wikilink = !is_embed_wikilink
&& i + 3 < len
&& bytes[i] == b'['
&& bytes[i+1] == b'[';
if is_embed_wikilink || is_wikilink {
let token_start = i;
let inner_start = if is_embed_wikilink { i + 3 } else { i + 2 };
if let Some(close) = find_double_bracket(bytes, inner_start) {
#[allow(clippy::string_slice)]
let inner = &source[inner_start..close];
let token_end = close + 2;
let (path_part, pipe_part) = match inner.find('|') {
Some(p) => (&inner[..p], Some(&inner[p+1..])),
None => (inner, None),
};
if !path_part.trim().is_empty() {
let text = path_part.trim().to_string();
let has_slash = text.contains('/');
let syntax = match (is_embed_wikilink, pipe_part) {
(false, None) => {
if has_slash { RefSyntax::WikilinkPath } else { RefSyntax::WikilinkStem }
}
(false, Some(alias)) => RefSyntax::WikilinkAliased { display: alias.to_string() },
(true, None) => {
if has_slash { RefSyntax::WikilinkPathEmbed } else { RefSyntax::WikilinkStemEmbed }
}
(true, Some(pot)) => RefSyntax::WikilinkAliasedEmbed { display: pot.to_string() },
};
refs.push(RawRef { text, syntax, byte_from: token_start, byte_to: token_end });
}
i = token_end;
continue;
}
}
if i + 3 < len && bytes[i] == b'!' && bytes[i+1] == b'[' {
if let Some((alt, path, end)) = parse_md_link(source, bytes, i + 1) {
let token_start = i;
refs.push(RawRef {
text: path,
syntax: RefSyntax::MarkdownImage { alt },
byte_from: token_start,
byte_to: end,
});
i = end;
continue;
}
}
if bytes[i] == b'[' {
if i + 1 < len && bytes[i+1] != b'[' {
if let Some((label, path, end)) = parse_md_link(source, bytes, i) {
refs.push(RawRef {
text: path,
syntax: RefSyntax::MarkdownLink { label },
byte_from: i,
byte_to: end,
});
i = end;
continue;
}
}
}
i += 1;
}
refs
}
fn find_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
let mut j = start;
while j + 1 < bytes.len() {
if bytes[j] == b']' && bytes[j+1] == b']' {
return Some(j);
}
if bytes[j] == b'\n' {
return None;
}
j += 1;
}
None
}
fn parse_md_link(source: &str, bytes: &[u8], bracket_pos: usize) -> Option<(String, String, usize)> {
let len = bytes.len();
let mut depth = 0usize;
let mut j = bracket_pos;
while j < len {
match bytes[j] {
b'[' => { depth += 1; j += 1; }
b']' => {
depth -= 1;
if depth == 0 { break; }
j += 1;
}
b'\n' => return None,
_ => { j += 1; }
}
}
if j >= len || bytes[j] != b']' { return None; }
let label_start = bracket_pos + 1;
let label_end = j;
#[allow(clippy::string_slice)]
let label = source[label_start..label_end].to_string();
let paren_open = j + 1;
if paren_open >= len || bytes[paren_open] != b'(' { return None; }
let mut depth = 0usize;
let mut k = paren_open;
while k < len {
match bytes[k] {
b'(' => { depth += 1; k += 1; }
b')' => {
depth -= 1;
if depth == 0 { break; }
k += 1;
}
b'\n' => return None,
_ => { k += 1; }
}
}
if k >= len || bytes[k] != b')' { return None; }
let path_start = paren_open + 1;
let path_end = k;
#[allow(clippy::string_slice)]
let path_raw = source[path_start..path_end].trim().to_string();
let path = strip_link_title(&path_raw);
let token_end = k + 1;
Some((label, path, token_end))
}
fn strip_link_title(raw: &str) -> String {
let raw = raw.trim();
if let Some(ws) = raw.rfind(|c: char| c.is_ascii_whitespace()) {
let (path_part, maybe_title) = raw.split_at(ws);
let maybe_title = maybe_title.trim();
let is_title = (maybe_title.starts_with('"') && maybe_title.ends_with('"'))
|| (maybe_title.starts_with('\'') && maybe_title.ends_with('\''))
|| (maybe_title.starts_with('(') && maybe_title.ends_with(')'));
if is_title {
return path_part.trim().to_string();
}
}
raw.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wikilink_stem() {
let src = "See [[note]] for details.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "note");
assert_eq!(refs[0].syntax, RefSyntax::WikilinkStem);
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[note]]");
}
#[test]
fn wikilink_path() {
let src = "See [[a/b]] here.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "a/b");
assert_eq!(refs[0].syntax, RefSyntax::WikilinkPath);
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a/b]]");
}
#[test]
fn wikilink_stem_embed() {
let src = "![[x]] is an embed.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "x");
assert_eq!(refs[0].syntax, RefSyntax::WikilinkStemEmbed);
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[x]]");
}
#[test]
fn wikilink_path_embed() {
let src = "![[a/b]] embedded.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "a/b");
assert_eq!(refs[0].syntax, RefSyntax::WikilinkPathEmbed);
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[a/b]]");
}
#[test]
fn wikilink_aliased() {
let src = "See [[stem|Display]] here.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "stem");
assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliased { display } if display == "Display"));
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[stem|Display]]");
}
#[test]
fn wikilink_aliased_embed() {
let src = "![[stem|500]] wide embed.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "stem");
assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "500"));
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[stem|500]]");
}
#[test]
fn markdown_link() {
let src = "Click [here](page.md) now.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "page.md");
assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { label } if label == "here"));
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[here](page.md)");
}
#[test]
fn markdown_image() {
let src = " here.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "img.png");
assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownImage { alt } if alt == "alt text"));
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "");
}
#[test]
fn external_link_included() {
let src = "[foo](https://example.com)";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "https://example.com");
assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { .. }));
}
#[test]
fn skip_fenced_code_block() {
let src = "```\n[[note]]\n```\nAfter.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 0, "wikilink inside fenced block should be skipped");
}
#[test]
fn ref_after_fence_is_found() {
let src = "```\n[[skip]]\n```\n[[find]]";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1, "exactly the ref after the fence is found, got: {:?}", refs);
assert_eq!(refs[0].text, "find");
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[find]]");
}
#[test]
fn backslash_escaped_refs_are_skipped() {
let src = "Escaped \\[[note]] and \\[t](p.md) but [[real]] counts.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1, "only the unescaped ref should be found, got: {:?}", refs);
assert_eq!(refs[0].text, "real");
}
#[test]
fn skip_inline_code_span() {
let src = "In `` [[note]] `` code.";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 0, "wikilink inside inline code should be skipped");
}
#[test]
fn multiple_refs_byte_offsets() {
let src = "[[a]] and [[b]]";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 2);
assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a]]");
assert_eq!(&src[refs[1].byte_from..refs[1].byte_to], "[[b]]");
}
#[test]
fn aliased_embed_preserves_alias() {
let src = "![[image.png|600]]";
let refs = extract_md_references(src);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].text, "image.png");
assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "600"));
}
}