markdown_contract/dialect/
wikilinks.rs1use std::sync::OnceLock;
7
8use regex::Regex;
9
10use crate::js;
11
12fn vault_ref_re() -> &'static Regex {
15 static RE: OnceLock<Regex> = OnceLock::new();
16 RE.get_or_init(|| Regex::new(r"(!?)\\?\[\\?\[([^\]\[]+)\]\]").expect("static regex"))
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VaultRefKind {
22 Wikilink,
24 Transclusion,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct VaultRef {
31 pub kind: VaultRefKind,
33 pub target: String,
35 pub alias: Option<String>,
37 pub fragment: Option<String>,
39 pub raw: String,
41}
42
43pub fn extract_vault_refs(text: &str) -> Vec<VaultRef> {
58 let mut refs = Vec::new();
59 for m in vault_ref_re().captures_iter(text) {
60 let bang = m.get(1).is_some_and(|b| b.as_str() == "!");
61 let Some(inner) = m.get(2) else { continue };
62 let mut target = inner.as_str();
63 let mut alias = None;
64 let mut fragment = None;
65 if let Some(pipe) = target.find('|') {
66 alias = Some(js::trim(&target[pipe + 1..]).to_string());
67 target = &target[..pipe];
68 }
69 if let Some(hash) = target.find('#') {
70 fragment = Some(js::trim(&target[hash + 1..]).to_string());
71 target = &target[..hash];
72 }
73 refs.push(VaultRef {
74 kind: if bang {
75 VaultRefKind::Transclusion
76 } else {
77 VaultRefKind::Wikilink
78 },
79 target: js::trim(target).to_string(),
80 alias,
81 fragment,
82 raw: m.get(0).expect("whole match").as_str().to_string(),
83 });
84 }
85 refs
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 fn r(
93 kind: VaultRefKind,
94 target: &str,
95 alias: Option<&str>,
96 fragment: Option<&str>,
97 raw: &str,
98 ) -> VaultRef {
99 VaultRef {
100 kind,
101 target: target.into(),
102 alias: alias.map(Into::into),
103 fragment: fragment.map(Into::into),
104 raw: raw.into(),
105 }
106 }
107
108 fn wl(target: &str, alias: Option<&str>, fragment: Option<&str>, raw: &str) -> VaultRef {
109 r(VaultRefKind::Wikilink, target, alias, fragment, raw)
110 }
111
112 #[test]
114 fn plain_wikilink_target_is_the_inner_text() {
115 assert_eq!(
116 extract_vault_refs("see [[D-0002]] for the decision"),
117 vec![wl("D-0002", None, None, "[[D-0002]]")]
118 );
119 }
120
121 #[test]
122 fn leading_bang_marks_a_transclusion() {
123 assert_eq!(
124 extract_vault_refs("![[Diagram]]"),
125 vec![r(
126 VaultRefKind::Transclusion,
127 "Diagram",
128 None,
129 None,
130 "![[Diagram]]"
131 )]
132 );
133 }
134
135 #[test]
136 fn pipe_splits_the_display_alias_off_the_target() {
137 assert_eq!(
138 extract_vault_refs("[[Note|see this]]"),
139 vec![wl("Note", Some("see this"), None, "[[Note|see this]]")]
140 );
141 }
142
143 #[test]
144 fn hash_splits_the_fragment_off_the_target() {
145 assert_eq!(
146 extract_vault_refs("[[Note#Section]]"),
147 vec![wl("Note", None, Some("Section"), "[[Note#Section]]")]
148 );
149 }
150
151 #[test]
152 fn block_fragment_keeps_the_leading_caret() {
153 assert_eq!(
154 extract_vault_refs("[[Note#^block-id]]"),
155 vec![wl("Note", None, Some("^block-id"), "[[Note#^block-id]]")]
156 );
157 }
158
159 #[test]
160 fn multiple_refs_are_recognized_in_order() {
161 let kinds: Vec<_> = extract_vault_refs("[[A]] and then ![[B]]")
162 .into_iter()
163 .map(|r| (r.kind, r.target))
164 .collect();
165 assert_eq!(
166 kinds,
167 vec![
168 (VaultRefKind::Wikilink, "A".to_string()),
169 (VaultRefKind::Transclusion, "B".to_string())
170 ]
171 );
172 }
173
174 #[test]
175 fn alias_and_fragment_together() {
176 assert_eq!(
177 extract_vault_refs("[[Note#Section|alias]] ![[Note#^id|alias]]"),
178 vec![
179 wl(
180 "Note",
181 Some("alias"),
182 Some("Section"),
183 "[[Note#Section|alias]]"
184 ),
185 r(
186 VaultRefKind::Transclusion,
187 "Note",
188 Some("alias"),
189 Some("^id"),
190 "![[Note#^id|alias]]"
191 ),
192 ]
193 );
194 }
195
196 #[test]
197 fn same_document_link_has_an_empty_target() {
198 assert_eq!(
199 extract_vault_refs("[[#^id]]"),
200 vec![wl("", None, Some("^id"), "[[#^id]]")]
201 );
202 }
203
204 #[test]
205 fn one_backslash_before_each_bracket_is_recognized() {
206 assert_eq!(
207 extract_vault_refs(r"\[\[Note]] and !\[\[Embed]]"),
208 vec![
209 wl("Note", None, None, r"\[\[Note]]"),
210 r(
211 VaultRefKind::Transclusion,
212 "Embed",
213 None,
214 None,
215 r"!\[\[Embed]]"
216 ),
217 ]
218 );
219 }
220
221 #[test]
222 fn alias_hash_is_part_of_the_alias() {
223 assert_eq!(
224 extract_vault_refs("[[Note|alias#frag]]"),
225 vec![wl("Note", Some("alias#frag"), None, "[[Note|alias#frag]]")]
226 );
227 }
228
229 #[test]
230 fn inner_text_cannot_contain_an_open_bracket() {
231 assert_eq!(
232 extract_vault_refs("[[a[[b]]"),
233 vec![wl("b", None, None, "[[b]]")]
234 );
235 assert_eq!(
236 extract_vault_refs("[[[a]]"),
237 vec![wl("a", None, None, "[[a]]")]
238 );
239 assert_eq!(extract_vault_refs("[[Note [draft]]]"), Vec::new());
240 }
241
242 #[test]
243 fn two_backslashes_are_not_an_escape() {
244 assert_eq!(
245 extract_vault_refs(r"\\[[a]]"),
246 vec![wl("a", None, None, r"\[[a]]")]
247 );
248 assert_eq!(extract_vault_refs(r"[\\[a]]"), Vec::new());
249 }
250
251 #[test]
252 fn text_with_no_refs_yields_empty() {
253 assert_eq!(extract_vault_refs("nothing to see here"), Vec::new());
254 }
255
256 #[test]
259 fn large_hostile_inputs_yield_no_refs() {
260 let inputs = [
261 "[[a".repeat(70_000),
262 "\\".repeat(200_000),
263 "\\".repeat(100_000) + "[[a",
264 "[[".to_string() + &"a".repeat(200_000),
265 "[".repeat(200_000),
266 ];
267 for text in inputs {
268 assert_eq!(extract_vault_refs(&text), Vec::new());
269 }
270 }
271}