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
30pub fn extract_wikilinks(markdown: &str) -> Vec<WikiLink> {
32 let mut links = Vec::new();
33 let bytes = markdown.as_bytes();
34 let len = bytes.len();
35 let mut i = 0;
36 let mut in_fence = false;
37
38 while i < len {
39 if is_line_start(bytes, i) && i + 2 < len && &bytes[i..i + 3] == b"```" {
41 in_fence = !in_fence;
42 i += 3;
43 continue;
44 }
45
46 if in_fence {
47 i += 1;
48 continue;
49 }
50
51 if bytes[i] == b'`' {
53 i += 1;
54 while i < len && bytes[i] != b'`' {
55 i += 1;
56 }
57 if i < len {
58 i += 1;
59 }
60 continue;
61 }
62
63 if i + 1 < len && bytes[i] == b'[' && bytes[i + 1] == b'[' {
64 let start = i + 2;
65 if let Some(rel_end) = markdown[start..].find("]]") {
66 let inner = &markdown[start..start + rel_end];
67 if !inner.trim().is_empty() && !inner.contains('\n') {
68 let mut alias_parts = inner.splitn(2, '|');
69 let target_and_sec = alias_parts.next().unwrap_or("").trim();
70 let display_alias = alias_parts.next().map(|s| s.trim().to_string());
71
72 let mut sec_parts = target_and_sec.splitn(2, '#');
73 let target_node = sec_parts.next().unwrap_or("").trim().to_string();
74 let section = sec_parts.next().map(|s| s.trim().to_string());
75
76 if !target_node.is_empty() {
77 links.push(WikiLink {
78 target_node,
79 section,
80 display_alias,
81 });
82 }
83 }
84 i = start + rel_end + 2;
85 continue;
86 }
87 }
88 i += 1;
89 }
90
91 links
92}
93
94fn is_line_start(bytes: &[u8], i: usize) -> bool {
95 i == 0 || bytes[i - 1] == b'\n'
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn extract_wikilinks_basic() {
104 let text =
105 "This relates to [[LogCompaction#Section|Log Compaction]] and [[RaftConsensus]].";
106 let links = extract_wikilinks(text);
107 assert_eq!(links.len(), 2);
108 assert_eq!(links[0].target_node, "LogCompaction");
109 assert_eq!(links[0].section.as_deref(), Some("Section"));
110 assert_eq!(links[0].display_alias.as_deref(), Some("Log Compaction"));
111 assert_eq!(links[1].target_node, "RaftConsensus");
112 assert_eq!(
113 links[0].to_markdown(),
114 "[[LogCompaction#Section|Log Compaction]]"
115 );
116 }
117
118 #[test]
119 fn ignores_code_fences_and_inline() {
120 let text = "See [[Real]] and `[[NotThis]]` and:\n```\n[[AlsoNot]]\n```\n[[Yes]]";
121 let links = extract_wikilinks(text);
122 let names: Vec<_> = links.iter().map(|l| l.target_node.as_str()).collect();
123 assert_eq!(names, vec!["Real", "Yes"]);
124 }
125}