Skip to main content

rustbrain_core/obsidian/
wikilink.rs

1//! Obsidian WikiLink (`[[...]]`) extraction.
2//!
3//! Links inside fenced code blocks and inline code spans are ignored.
4
5use serde::{Deserialize, Serialize};
6
7/// Parsed Obsidian WikiLink representation.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct WikiLink {
10    /// Target note name / id fragment.
11    pub target_node: String,
12    /// Optional `#section` fragment.
13    pub section: Option<String>,
14    /// Optional display alias after `|`.
15    pub display_alias: Option<String>,
16}
17
18impl WikiLink {
19    /// Format as standard Obsidian markdown.
20    pub fn to_markdown(&self) -> String {
21        match (&self.section, &self.display_alias) {
22            (Some(sec), Some(alias)) => format!("[[{}#{}|{}]]", self.target_node, sec, alias),
23            (Some(sec), None) => format!("[[{}#{}]]", self.target_node, sec),
24            (None, Some(alias)) => format!("[[{}|{}]]", self.target_node, alias),
25            (None, None) => format!("[[{}]]", self.target_node),
26        }
27    }
28}
29
30/// A WikiLink with byte offsets into the source markdown (`start` inclusive, `end` exclusive).
31///
32/// Offsets cover the full `[[...]]` token and are guaranteed UTF-8 char boundaries.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct WikiLinkSpan {
35    /// Parsed link.
36    pub link: WikiLink,
37    /// Byte offset of the opening `[[`.
38    pub start: usize,
39    /// Byte offset after the closing `]]`.
40    pub end: usize,
41}
42
43/// Extract all WikiLinks from markdown, skipping fenced and inline code.
44pub fn extract_wikilinks(markdown: &str) -> Vec<WikiLink> {
45    extract_wikilink_spans(markdown)
46        .into_iter()
47        .map(|s| s.link)
48        .collect()
49}
50
51/// Extract WikiLinks with absolute byte spans (for safe in-place rewrites).
52pub fn extract_wikilink_spans(markdown: &str) -> Vec<WikiLinkSpan> {
53    let mut links = Vec::new();
54    let bytes = markdown.as_bytes();
55    let len = bytes.len();
56    let mut i = 0;
57    let mut in_fence = false;
58
59    while i < len {
60        // Fence toggle on lines starting with ```
61        if is_line_start(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
62            in_fence = !in_fence;
63            i += 3;
64            continue;
65        }
66
67        if in_fence {
68            i += 1;
69            continue;
70        }
71
72        // Inline code span: skip until next unescaped `
73        if bytes[i] == b'`' {
74            i += 1;
75            while i < len && bytes[i] != b'`' {
76                i += 1;
77            }
78            if i < len {
79                i += 1;
80            }
81            continue;
82        }
83
84        if i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[' {
85            let open = i;
86            let start = i + 2;
87            if let Some(rel_end) = markdown[start..].find("]]") {
88                let inner = &markdown[start..start + rel_end];
89                let end = start + rel_end + 2;
90                if !inner.trim().is_empty() && !inner.contains('\n') {
91                    let mut alias_parts = inner.splitn(2, '|');
92                    let target_and_sec = alias_parts.next().unwrap_or("").trim();
93                    let display_alias = alias_parts.next().map(|s| s.trim().to_string());
94
95                    let mut sec_parts = target_and_sec.splitn(2, '#');
96                    let target_node = sec_parts.next().unwrap_or("").trim().to_string();
97                    let section = sec_parts.next().map(|s| s.trim().to_string());
98
99                    if !target_node.is_empty()
100                        && markdown.is_char_boundary(open)
101                        && markdown.is_char_boundary(end)
102                    {
103                        links.push(WikiLinkSpan {
104                            link: WikiLink {
105                                target_node,
106                                section,
107                                display_alias,
108                            },
109                            start: open,
110                            end,
111                        });
112                    }
113                }
114                i = end;
115                continue;
116            }
117        }
118        i += 1;
119    }
120
121    links
122}
123
124fn is_line_start(bytes: &[u8], i: usize) -> bool {
125    i == 0 || bytes[i - 1] == b'\n'
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn extract_wikilinks_basic() {
134        let text =
135            "This relates to [[LogCompaction#Section|Log Compaction]] and [[RaftConsensus]].";
136        let links = extract_wikilinks(text);
137        assert_eq!(links.len(), 2);
138        assert_eq!(links[0].target_node, "LogCompaction");
139        assert_eq!(links[0].section.as_deref(), Some("Section"));
140        assert_eq!(links[0].display_alias.as_deref(), Some("Log Compaction"));
141        assert_eq!(links[1].target_node, "RaftConsensus");
142        assert_eq!(
143            links[0].to_markdown(),
144            "[[LogCompaction#Section|Log Compaction]]"
145        );
146    }
147
148    #[test]
149    fn ignores_code_fences_and_inline() {
150        let text = "See [[Real]] and `[[NotThis]]` and:\n```\n[[AlsoNot]]\n```\n[[Yes]]";
151        let links = extract_wikilinks(text);
152        let names: Vec<_> = links.iter().map(|l| l.target_node.as_str()).collect();
153        assert_eq!(names, vec!["Real", "Yes"]);
154    }
155
156    #[test]
157    fn spans_roundtrip_slice() {
158        let text = "A [[foo|Foo]] B";
159        let spans = extract_wikilink_spans(text);
160        assert_eq!(spans.len(), 1);
161        assert_eq!(&text[spans[0].start..spans[0].end], "[[foo|Foo]]");
162        assert_eq!(spans[0].link.to_markdown(), "[[foo|Foo]]");
163    }
164}