rustbrain_core/obsidian/
wikilink.rs1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct WikiLink {
10 pub target_node: String,
12 pub section: Option<String>,
14 pub display_alias: Option<String>,
16}
17
18impl WikiLink {
19 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct WikiLinkSpan {
35 pub link: WikiLink,
37 pub start: usize,
39 pub end: usize,
41}
42
43pub fn extract_wikilinks(markdown: &str) -> Vec<WikiLink> {
45 extract_wikilink_spans(markdown)
46 .into_iter()
47 .map(|s| s.link)
48 .collect()
49}
50
51pub 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 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 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}