Skip to main content

rustbrain_core/
symbols.rs

1//! Symbol reference parsing and stable symbol node IDs.
2//!
3//! Notes may reference code with `symbol:…` anchors. This module parses those
4//! refs, builds canonical `symbol/…` node ids, and resolves them against the set
5//! of indexed symbol nodes.
6
7use crate::id::node_id_from_rel_path;
8use std::path::Path;
9
10/// Build the canonical node id for a code symbol.
11///
12/// Format: `symbol/{crate}/{module_path_with_dots}/{symbol_name}`
13/// where `symbol_name` may be `Type::method` for impl methods.
14pub 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/// A reference from a Markdown note to a code symbol.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SymbolRef {
39    /// Raw text as written (e.g. `symbol:crate::foo::Bar` or `Bar`).
40    pub raw: String,
41    /// Optional crate qualifier.
42    pub crate_name: Option<String>,
43    /// Optional module path.
44    pub module_path: Option<String>,
45    /// Symbol name (may include `Type::method`).
46    pub symbol_name: String,
47}
48
49/// Extract `symbol:…` references from Markdown body (outside code fences).
50///
51/// Supported forms:
52/// - `symbol:Name`
53/// - `symbol:module::Name`
54/// - `symbol:crate::module::Name`
55/// - WikiLinks `[[symbol:…]]` (target already extracted by wikilink parser — also scanned raw)
56pub 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        // Match "symbol:" case-sensitively as specified in architecture notes.
83        if i + 7 <= bytes.len() && &markdown[i..i + 7] == "symbol:" {
84            let start = i + 7;
85            let rest = &markdown[start..];
86            let end_rel = rest
87                .find(|c: char| {
88                    c.is_whitespace()
89                        || c == ']'
90                        || c == ')'
91                        || c == ','
92                        || c == ';'
93                        || c == '"'
94                        || c == '\''
95                })
96                .unwrap_or(rest.len());
97            let raw_path = rest[..end_rel].trim_end_matches(['.', '!', '?', ':']);
98            if !raw_path.is_empty() {
99                if let Some(sym) = parse_symbol_path(raw_path) {
100                    refs.push(sym);
101                }
102            }
103            i = start + end_rel;
104            continue;
105        }
106        i += 1;
107    }
108    refs
109}
110
111/// Parse `crate::module::Name` or `Name` into a [`SymbolRef`].
112pub fn parse_symbol_path(raw: &str) -> Option<SymbolRef> {
113    let raw = raw.trim();
114    if raw.is_empty() {
115        return None;
116    }
117    let parts: Vec<&str> = raw.split("::").collect();
118    match parts.len() {
119        1 => Some(SymbolRef {
120            raw: raw.to_string(),
121            crate_name: None,
122            module_path: None,
123            symbol_name: parts[0].to_string(),
124        }),
125        2 => Some(SymbolRef {
126            raw: raw.to_string(),
127            crate_name: None,
128            module_path: Some(parts[0].to_string()),
129            symbol_name: parts[1].to_string(),
130        }),
131        n if n >= 3 => {
132            let crate_name = parts[0].to_string();
133            let symbol_name = parts[n - 1].to_string();
134            let module_path = parts[1..n - 1].join("::");
135            Some(SymbolRef {
136                raw: raw.to_string(),
137                crate_name: Some(crate_name),
138                module_path: Some(module_path),
139                symbol_name,
140            })
141        }
142        _ => None,
143    }
144}
145
146/// Resolve a symbol ref against known symbol node ids.
147///
148/// Matching priority:
149/// 1. Exact full id if caller built one
150/// 2. Exact `symbol_name` suffix unique match
151/// 3. Unique `module::name` suffix
152pub fn resolve_symbol_ref(
153    sym: &SymbolRef,
154    symbol_ids: &std::collections::HashSet<String>,
155) -> Option<String> {
156    // Direct constructed id attempts
157    if let (Some(c), Some(m)) = (&sym.crate_name, &sym.module_path) {
158        let id = symbol_node_id(c, m, &sym.symbol_name);
159        if symbol_ids.contains(&id) {
160            return Some(id);
161        }
162    }
163
164    let name = sym.symbol_name.to_lowercase();
165    let mut hits: Vec<&String> = symbol_ids
166        .iter()
167        .filter(|id| {
168            let last = id.rsplit('/').next().unwrap_or("");
169            last.eq_ignore_ascii_case(&sym.symbol_name)
170                || last.to_lowercase().ends_with(&format!("::{name}"))
171                || last.to_lowercase() == name
172        })
173        .collect();
174
175    if let Some(m) = &sym.module_path {
176        let m_norm = m.replace("::", ".");
177        hits.retain(|id| id.contains(&m_norm) || id.contains(m));
178    }
179    if let Some(c) = &sym.crate_name {
180        hits.retain(|id| id.contains(&format!("symbol/{c}/")) || id.contains(c));
181    }
182
183    hits.sort();
184    hits.dedup();
185    if hits.len() == 1 {
186        Some(hits[0].clone())
187    } else {
188        None
189    }
190}
191
192/// Helper for docs path ids (re-export convenience).
193pub fn note_id_from_path(workspace_rel: &Path) -> String {
194    node_id_from_rel_path(workspace_rel)
195}
196
197fn is_line_start(bytes: &[u8], i: usize) -> bool {
198    i == 0 || bytes[i - 1] == b'\n'
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::collections::HashSet;
205
206    #[test]
207    fn extract_symbol_refs_basic() {
208        let md = "See symbol:demo::storage::Engine and symbol:compact_log.\n";
209        let refs = extract_symbol_refs(md);
210        assert_eq!(refs.len(), 2);
211        assert_eq!(refs[0].symbol_name, "Engine");
212        assert_eq!(refs[0].crate_name.as_deref(), Some("demo"));
213        assert_eq!(refs[1].symbol_name, "compact_log");
214    }
215
216    #[test]
217    fn ignores_fenced_symbol() {
218        let md = "```\nsymbol:Hidden\n```\nsymbol:Visible\n";
219        let refs = extract_symbol_refs(md);
220        assert_eq!(refs.len(), 1);
221        assert_eq!(refs[0].symbol_name, "Visible");
222    }
223
224    #[test]
225    fn resolve_unique_suffix() {
226        let mut ids = HashSet::new();
227        ids.insert("symbol/demo/crate/StorageEngine".into());
228        ids.insert("symbol/demo/crate/compact_log".into());
229        let r = parse_symbol_path("StorageEngine").unwrap();
230        assert_eq!(
231            resolve_symbol_ref(&r, &ids).as_deref(),
232            Some("symbol/demo/crate/StorageEngine")
233        );
234    }
235}