use std::sync::OnceLock;
use regex::Regex;
use crate::js;
fn vault_ref_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(!?)\\?\[\\?\[([^\]\[]+)\]\]").expect("static regex"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VaultRefKind {
Wikilink,
Transclusion,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultRef {
pub kind: VaultRefKind,
pub target: String,
pub alias: Option<String>,
pub fragment: Option<String>,
pub raw: String,
}
pub fn extract_vault_refs(text: &str) -> Vec<VaultRef> {
let mut refs = Vec::new();
for m in vault_ref_re().captures_iter(text) {
let bang = m.get(1).is_some_and(|b| b.as_str() == "!");
let Some(inner) = m.get(2) else { continue };
let mut target = inner.as_str();
let mut alias = None;
let mut fragment = None;
if let Some(pipe) = target.find('|') {
alias = Some(js::trim(&target[pipe + 1..]).to_string());
target = &target[..pipe];
}
if let Some(hash) = target.find('#') {
fragment = Some(js::trim(&target[hash + 1..]).to_string());
target = &target[..hash];
}
refs.push(VaultRef {
kind: if bang {
VaultRefKind::Transclusion
} else {
VaultRefKind::Wikilink
},
target: js::trim(target).to_string(),
alias,
fragment,
raw: m.get(0).expect("whole match").as_str().to_string(),
});
}
refs
}
#[cfg(test)]
mod tests {
use super::*;
fn r(
kind: VaultRefKind,
target: &str,
alias: Option<&str>,
fragment: Option<&str>,
raw: &str,
) -> VaultRef {
VaultRef {
kind,
target: target.into(),
alias: alias.map(Into::into),
fragment: fragment.map(Into::into),
raw: raw.into(),
}
}
fn wl(target: &str, alias: Option<&str>, fragment: Option<&str>, raw: &str) -> VaultRef {
r(VaultRefKind::Wikilink, target, alias, fragment, raw)
}
#[test]
fn plain_wikilink_target_is_the_inner_text() {
assert_eq!(
extract_vault_refs("see [[D-0002]] for the decision"),
vec![wl("D-0002", None, None, "[[D-0002]]")]
);
}
#[test]
fn leading_bang_marks_a_transclusion() {
assert_eq!(
extract_vault_refs("![[Diagram]]"),
vec![r(
VaultRefKind::Transclusion,
"Diagram",
None,
None,
"![[Diagram]]"
)]
);
}
#[test]
fn pipe_splits_the_display_alias_off_the_target() {
assert_eq!(
extract_vault_refs("[[Note|see this]]"),
vec![wl("Note", Some("see this"), None, "[[Note|see this]]")]
);
}
#[test]
fn hash_splits_the_fragment_off_the_target() {
assert_eq!(
extract_vault_refs("[[Note#Section]]"),
vec![wl("Note", None, Some("Section"), "[[Note#Section]]")]
);
}
#[test]
fn block_fragment_keeps_the_leading_caret() {
assert_eq!(
extract_vault_refs("[[Note#^block-id]]"),
vec![wl("Note", None, Some("^block-id"), "[[Note#^block-id]]")]
);
}
#[test]
fn multiple_refs_are_recognized_in_order() {
let kinds: Vec<_> = extract_vault_refs("[[A]] and then ![[B]]")
.into_iter()
.map(|r| (r.kind, r.target))
.collect();
assert_eq!(
kinds,
vec![
(VaultRefKind::Wikilink, "A".to_string()),
(VaultRefKind::Transclusion, "B".to_string())
]
);
}
#[test]
fn alias_and_fragment_together() {
assert_eq!(
extract_vault_refs("[[Note#Section|alias]] ![[Note#^id|alias]]"),
vec![
wl(
"Note",
Some("alias"),
Some("Section"),
"[[Note#Section|alias]]"
),
r(
VaultRefKind::Transclusion,
"Note",
Some("alias"),
Some("^id"),
"![[Note#^id|alias]]"
),
]
);
}
#[test]
fn same_document_link_has_an_empty_target() {
assert_eq!(
extract_vault_refs("[[#^id]]"),
vec![wl("", None, Some("^id"), "[[#^id]]")]
);
}
#[test]
fn one_backslash_before_each_bracket_is_recognized() {
assert_eq!(
extract_vault_refs(r"\[\[Note]] and !\[\[Embed]]"),
vec![
wl("Note", None, None, r"\[\[Note]]"),
r(
VaultRefKind::Transclusion,
"Embed",
None,
None,
r"!\[\[Embed]]"
),
]
);
}
#[test]
fn alias_hash_is_part_of_the_alias() {
assert_eq!(
extract_vault_refs("[[Note|alias#frag]]"),
vec![wl("Note", Some("alias#frag"), None, "[[Note|alias#frag]]")]
);
}
#[test]
fn inner_text_cannot_contain_an_open_bracket() {
assert_eq!(
extract_vault_refs("[[a[[b]]"),
vec![wl("b", None, None, "[[b]]")]
);
assert_eq!(
extract_vault_refs("[[[a]]"),
vec![wl("a", None, None, "[[a]]")]
);
assert_eq!(extract_vault_refs("[[Note [draft]]]"), Vec::new());
}
#[test]
fn two_backslashes_are_not_an_escape() {
assert_eq!(
extract_vault_refs(r"\\[[a]]"),
vec![wl("a", None, None, r"\[[a]]")]
);
assert_eq!(extract_vault_refs(r"[\\[a]]"), Vec::new());
}
#[test]
fn text_with_no_refs_yields_empty() {
assert_eq!(extract_vault_refs("nothing to see here"), Vec::new());
}
#[test]
fn large_hostile_inputs_yield_no_refs() {
let inputs = [
"[[a".repeat(70_000),
"\\".repeat(200_000),
"\\".repeat(100_000) + "[[a",
"[[".to_string() + &"a".repeat(200_000),
"[".repeat(200_000),
];
for text in inputs {
assert_eq!(extract_vault_refs(&text), Vec::new());
}
}
}