Skip to main content

markdown_contract/dialect/
wikilinks.rs

1//! `[[wikilink]]` and `![[transclusion]]` recognition over already-flattened text,
2//! mirroring the TS `dialect/wikilinks.ts`. CommonMark leaves an undefined
3//! `[[reference]]` as literal text, so targets survive projection intact, in the
4//! `target#fragment|alias` form.
5
6use std::sync::OnceLock;
7
8use regex::Regex;
9
10use crate::js;
11
12/// One optional `\` before each opening bracket, as remark-stringify writes them. The inner
13/// text excludes `[` and `]`, so the pass is linear in the input.
14fn 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/// Whether a reference is a link or a transclusion.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VaultRefKind {
22    /// `[[target]]`
23    Wikilink,
24    /// `![[target]]`
25    Transclusion,
26}
27
28/// A recognized `[[wikilink]]` or `![[transclusion]]` extracted from flattened text.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct VaultRef {
31    /// Link or transclusion.
32    pub kind: VaultRefKind,
33    /// The link target: the part before any `#fragment` or `|alias`.
34    pub target: String,
35    /// The display alias after the first `|`, if present.
36    pub alias: Option<String>,
37    /// The heading or `^block` fragment after the first `#` before any `|`, if present.
38    pub fragment: Option<String>,
39    /// The full matched token, verbatim.
40    pub raw: String,
41}
42
43/// Every `[[wikilink]]` and `![[transclusion]]` in already-flattened text, in document
44/// order. The alias is everything after the first `|`; the fragment is everything after
45/// the first `#` in what is left, so `[[Note|alias#Part]]` has the alias `alias#Part`
46/// and no fragment. The text between the brackets cannot contain `[`.
47///
48/// ```
49/// use markdown_contract::{VaultRefKind, extract_vault_refs};
50///
51/// let refs = extract_vault_refs("see [[Note#^id|the note]] and ![[Diagram]]");
52/// assert_eq!(refs[0].target, "Note");
53/// assert_eq!(refs[0].fragment.as_deref(), Some("^id"));
54/// assert_eq!(refs[0].alias.as_deref(), Some("the note"));
55/// assert_eq!(refs[1].kind, VaultRefKind::Transclusion);
56/// ```
57pub 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    // Ported from the TS wikilinks.test.ts: each case is an input and its exact output.
113    #[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    // The `regex` crate matches in linear time, so these only check that each input
257    // returns no refs.
258    #[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}