wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

sable-vault/src/link.rs@337ba67f65eaa17b44e371af7c0f0c761d6aa914

raw
Date Commit Message Author Files + -
2026-02-23 01:55 initial mvp wayverd 139 17808 0
...

1use std::{ops::Range, sync::LazyLock};
2
3use regex::Regex;
4
5static LINK_REGEX: LazyLock<Regex> =
6    LazyLock::new(|| Regex::new(r"(?:\[(?P<text>.*?)\])\((?P<link>.*?)\)").unwrap());
7static WIKI_REGEX: LazyLock<Regex> =
8    LazyLock::new(|| Regex::new(r"\[\[(?P<link>.+?)(\|(?P<text>.+))?\]\]").unwrap());
9
10/// The kinds a link can be.
11///
12/// This allows differentiating between Markdown's inline link (for both internal and external links)
13/// and Obsidian's Wikilinks.
14#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
15pub enum LinkKind {
16    /// A Markdown link that points to a 'internal' Vault file.
17    Internal,
18    /// A Markdown link that points to a 'external' website outside the Vault.
19    External,
20    /// A Obsidian Wikilink.
21    ///
22    /// Can only be [`LinkKind::Internal`].
23    Wiki,
24}
25
26/// The details of a link inside a Vault note.
27#[derive(Debug, Clone, Hash, PartialEq, Eq)]
28pub struct Link {
29    /// The kind of link this is.
30    pub kind: LinkKind,
31    /// The 'target' of the link.
32    ///
33    /// For Markdown links this is the URL,
34    /// and for Obsidian Wikilinks this is the note title.
35    pub href: String,
36    /// The display text of the link.
37    ///
38    /// For Markdown links this is the name,
39    /// and for Obsidian Wikilinks this is the `|` name.
40    pub title: Option<String>,
41    /// The byte position of the *whole* link.
42    pub pos: Range<usize>,
43}
44
45impl Link {
46    /// Extract any links from a Vault note's content.
47    pub fn collect(s: &str) -> Vec<Self> {
48        let mut tags = Vec::new();
49
50        for captures in LINK_REGEX.captures_iter(s) {
51            let Some(full) = captures.get(0) else {
52                continue;
53            };
54            let Some(text) = captures.get(1) else {
55                continue;
56            };
57            let Some(url) = captures.get(2) else {
58                continue;
59            };
60
61            tags.push(Self {
62                kind: LinkKind::External,
63                href: url.as_str().to_string(),
64                title: Some(text.as_str().to_string()),
65                pos: full.range(),
66            });
67        }
68
69        for captures in WIKI_REGEX.captures_iter(s) {
70            let Some(full) = captures.get(0) else {
71                continue;
72            };
73            let Some(capture) = captures.get(1) else {
74                continue;
75            };
76            let title = captures.get(3).map(|c| c.as_str().to_string());
77
78            tags.push(Self {
79                kind: LinkKind::Wiki,
80                href: capture.as_str().to_string(),
81                title,
82                pos: full.range(),
83            });
84        }
85
86        tags
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn test_external() {
96        let text = "[test](https://example.com)";
97
98        let expected = &[Link {
99            kind: LinkKind::External,
100            href: "https://example.com".to_string(),
101            title: Some("test".to_string()),
102            pos: 0..27,
103        }];
104        let got = Link::collect(text);
105
106        assert_eq!(expected, &got[..]);
107    }
108
109    #[test]
110    fn test_wiki_no_title() {
111        let text = "[[test]]";
112
113        let expected = &[Link {
114            kind: LinkKind::Wiki,
115            href: "test".to_string(),
116            title: None,
117            pos: 0..8,
118        }];
119        let got = Link::collect(text);
120
121        assert_eq!(expected, &got[..]);
122    }
123
124    #[test]
125    fn test_wiki_with_title() {
126        let text = "[[test|title]]";
127
128        let expected = &[Link {
129            kind: LinkKind::Wiki,
130            href: "test".to_string(),
131            title: Some("title".to_string()),
132            pos: 0..14,
133        }];
134        let got = Link::collect(text);
135
136        assert_eq!(expected, &got[..]);
137    }
138
139    #[test]
140    fn test_fail_invalid_1() {
141        let text = "[[test]";
142
143        let got = Link::collect(text);
144
145        assert!(got.is_empty());
146    }
147
148    #[test]
149    fn test_fail_invalid_3() {
150        let text = "[test]]";
151
152        let got = Link::collect(text);
153
154        assert!(got.is_empty());
155    }
156
157    #[test]
158    fn test_fail_invalid_4() {
159        let text = "[[test|title]";
160
161        let got = Link::collect(text);
162
163        assert!(got.is_empty());
164    }
165
166    #[test]
167    fn test_fail_invalid_2() {
168        let text = "[test|title]]";
169
170        let got = Link::collect(text);
171
172        assert!(got.is_empty());
173    }
174}
175