rustbrain_core/
symbols.rs1use crate::id::node_id_from_rel_path;
8use std::path::Path;
9
10pub fn symbol_node_id(crate_name: &str, module_path: &str, symbol_name: &str) -> String {
15 let module = module_path.replace("::", ".").replace('/', ".");
16 format!(
17 "symbol/{}/{}/{}",
18 sanitize_seg(crate_name),
19 sanitize_seg(&module),
20 sanitize_seg(symbol_name)
21 )
22}
23
24fn sanitize_seg(s: &str) -> String {
25 s.chars()
26 .map(|c| {
27 if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == ':' || c == '-' {
28 c
29 } else {
30 '_'
31 }
32 })
33 .collect()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SymbolRef {
39 pub raw: String,
41 pub crate_name: Option<String>,
43 pub module_path: Option<String>,
45 pub symbol_name: String,
47}
48
49pub fn extract_symbol_refs(markdown: &str) -> Vec<SymbolRef> {
57 let mut refs = Vec::new();
58 let mut in_fence = false;
59 let bytes = markdown.as_bytes();
60 let mut i = 0;
61 while i < bytes.len() {
62 if is_line_start(bytes, i) && i + 2 < bytes.len() && &bytes[i..i + 3] == b"```" {
63 in_fence = !in_fence;
64 i += 3;
65 continue;
66 }
67 if in_fence {
68 i += 1;
69 continue;
70 }
71 if bytes[i] == b'`' {
72 i += 1;
73 while i < bytes.len() && bytes[i] != b'`' {
74 i += 1;
75 }
76 if i < bytes.len() {
77 i += 1;
78 }
79 continue;
80 }
81
82 if i + 7 <= bytes.len() && &bytes[i..i + 7] == b"symbol:" {
85 if !markdown.is_char_boundary(i) {
88 i += 1;
89 continue;
90 }
91 let start = i + 7;
92 if !markdown.is_char_boundary(start) {
93 i += 1;
94 continue;
95 }
96 let rest = &markdown[start..];
97 let end_rel = rest
98 .find(|c: char| {
99 c.is_whitespace()
100 || c == ']'
101 || c == ')'
102 || c == ','
103 || c == ';'
104 || c == '"'
105 || c == '\''
106 })
107 .unwrap_or(rest.len());
108 let raw_path = rest[..end_rel].trim_end_matches(['.', '!', '?', ':']);
110 if !raw_path.is_empty() {
111 if let Some(sym) = parse_symbol_path(raw_path) {
112 refs.push(sym);
113 }
114 }
115 i = start + end_rel;
116 continue;
117 }
118 i += 1;
119 }
120 refs
121}
122
123pub fn parse_symbol_path(raw: &str) -> Option<SymbolRef> {
125 let raw = raw.trim();
126 if raw.is_empty() {
127 return None;
128 }
129 let parts: Vec<&str> = raw.split("::").collect();
130 match parts.len() {
131 1 => Some(SymbolRef {
132 raw: raw.to_string(),
133 crate_name: None,
134 module_path: None,
135 symbol_name: parts[0].to_string(),
136 }),
137 2 => Some(SymbolRef {
138 raw: raw.to_string(),
139 crate_name: None,
140 module_path: Some(parts[0].to_string()),
141 symbol_name: parts[1].to_string(),
142 }),
143 n if n >= 3 => {
144 let crate_name = parts[0].to_string();
145 let symbol_name = parts[n - 1].to_string();
146 let module_path = parts[1..n - 1].join("::");
147 Some(SymbolRef {
148 raw: raw.to_string(),
149 crate_name: Some(crate_name),
150 module_path: Some(module_path),
151 symbol_name,
152 })
153 }
154 _ => None,
155 }
156}
157
158pub fn resolve_symbol_ref(
165 sym: &SymbolRef,
166 symbol_ids: &std::collections::HashSet<String>,
167) -> Option<String> {
168 if let (Some(c), Some(m)) = (&sym.crate_name, &sym.module_path) {
170 let id = symbol_node_id(c, m, &sym.symbol_name);
171 if symbol_ids.contains(&id) {
172 return Some(id);
173 }
174 }
175
176 let name = sym.symbol_name.to_lowercase();
177 let mut hits: Vec<&String> = symbol_ids
178 .iter()
179 .filter(|id| {
180 let last = id.rsplit('/').next().unwrap_or("");
181 last.eq_ignore_ascii_case(&sym.symbol_name)
182 || last.to_lowercase().ends_with(&format!("::{name}"))
183 || last.to_lowercase() == name
184 })
185 .collect();
186
187 if let Some(m) = &sym.module_path {
188 let m_norm = m.replace("::", ".");
189 hits.retain(|id| id.contains(&m_norm) || id.contains(m));
190 }
191 if let Some(c) = &sym.crate_name {
192 hits.retain(|id| id.contains(&format!("symbol/{c}/")) || id.contains(c));
193 }
194
195 hits.sort();
196 hits.dedup();
197 if hits.len() == 1 {
198 Some(hits[0].clone())
199 } else {
200 None
201 }
202}
203
204pub fn note_id_from_path(workspace_rel: &Path) -> String {
206 node_id_from_rel_path(workspace_rel)
207}
208
209fn is_line_start(bytes: &[u8], i: usize) -> bool {
210 i == 0 || bytes[i - 1] == b'\n'
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::collections::HashSet;
217
218 #[test]
219 fn extract_symbol_refs_basic() {
220 let md = "See symbol:demo::storage::Engine and symbol:compact_log.\n";
221 let refs = extract_symbol_refs(md);
222 assert_eq!(refs.len(), 2);
223 assert_eq!(refs[0].symbol_name, "Engine");
224 assert_eq!(refs[0].crate_name.as_deref(), Some("demo"));
225 assert_eq!(refs[1].symbol_name, "compact_log");
226 }
227
228 #[test]
229 fn ignores_fenced_symbol() {
230 let md = "```\nsymbol:Hidden\n```\nsymbol:Visible\n";
231 let refs = extract_symbol_refs(md);
232 assert_eq!(refs.len(), 1);
233 assert_eq!(refs[0].symbol_name, "Visible");
234 }
235
236 #[test]
237 fn unicode_arrows_do_not_panic() {
238 let md = "Flow: UI → backend. See symbol:query_json for details.\n";
240 let refs = extract_symbol_refs(md);
241 assert_eq!(refs.len(), 1);
242 assert_eq!(refs[0].symbol_name, "query_json");
243 }
244
245 #[test]
246 fn resolve_unique_suffix() {
247 let mut ids = HashSet::new();
248 ids.insert("symbol/demo/crate/StorageEngine".into());
249 ids.insert("symbol/demo/crate/compact_log".into());
250 let r = parse_symbol_path("StorageEngine").unwrap();
251 assert_eq!(
252 resolve_symbol_ref(&r, &ids).as_deref(),
253 Some("symbol/demo/crate/StorageEngine")
254 );
255 }
256}