Skip to main content

arbor_graph/
symbol_table.rs

1use crate::graph::NodeId;
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5/// A global symbol table for resolving cross-file references.
6///
7/// Maps Fully Qualified Names (FQNs) to Node IDs.
8/// Example FQN: "arbor::graph::SymbolTable" -> NodeId(42)
9#[derive(Debug, Default, Clone)]
10pub struct SymbolTable {
11    /// Map of FQN to NodeId
12    by_fqn: HashMap<String, NodeId>,
13
14    /// Map of File Path to list of exported symbols (FQNs)
15    /// Used to resolve wildcard imports or find all symbols in a file.
16    exports_by_file: HashMap<PathBuf, Vec<String>>,
17}
18
19impl SymbolTable {
20    /// Creates a new empty symbol table.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Registers a symbol in the table.
26    ///
27    /// * `fqn` - Fully Qualified Name (e.g., "pkg.module.function")
28    /// * `id` - The Node ID in the graph
29    /// * `file` - The file path defining this symbol
30    pub fn insert(&mut self, fqn: String, id: NodeId, file: PathBuf) {
31        self.by_fqn.insert(fqn.clone(), id);
32        self.exports_by_file.entry(file).or_default().push(fqn);
33    }
34
35    /// Resolves a Fully Qualified Name to a Node ID.
36    pub fn resolve(&self, fqn: &str) -> Option<NodeId> {
37        self.by_fqn.get(fqn).copied()
38    }
39
40    /// Returns all symbols exported by a file.
41    pub fn get_file_exports(&self, file: &PathBuf) -> Option<&Vec<String>> {
42        self.exports_by_file.get(file)
43    }
44
45    /// Clears the symbol table.
46    pub fn clear(&mut self) {
47        self.by_fqn.clear();
48        self.exports_by_file.clear();
49    }
50
51    /// Resolves a symbol name with context-aware matching.
52    ///
53    /// Resolution order:
54    /// 1. Exact FQN match
55    /// 2. Suffix match (e.g., "helper" matches "pkg.Utils.helper")
56    ///    - Only matches if unambiguous OR in same directory as `context_file`
57    ///
58    /// Returns None if:
59    /// - No match found
60    /// - Multiple matches exist and none are in the same directory (ambiguous)
61    pub fn resolve_with_context(
62        &self,
63        name: &str,
64        context_file: &std::path::Path,
65    ) -> Option<NodeId> {
66        // 1. Try exact match first
67        if let Some(id) = self.by_fqn.get(name) {
68            return Some(*id);
69        }
70
71        // 2. Suffix match
72        let context_dir = context_file.parent();
73        let mut candidates: Vec<(&String, NodeId, bool)> = Vec::new();
74
75        for (fqn, &id) in &self.by_fqn {
76            // Check if FQN ends with the name (with separator)
77            if fqn.ends_with(name) {
78                // Ensure it's a proper suffix (preceded by separator or start)
79                let prefix_len = fqn.len() - name.len();
80                if prefix_len == 0
81                    || fqn.chars().nth(prefix_len - 1) == Some('.')
82                    || fqn.chars().nth(prefix_len - 1) == Some(':')
83                {
84                    // Check if in same directory
85                    let same_dir = self
86                        .exports_by_file
87                        .iter()
88                        .find(|(_, exports)| exports.contains(fqn))
89                        .map(|(file, _)| file.parent() == context_dir)
90                        .unwrap_or(false);
91
92                    candidates.push((fqn, id, same_dir));
93                }
94            }
95        }
96
97        match candidates.len() {
98            0 => None,
99            1 => Some(candidates[0].1),
100            _ => {
101                // Multiple candidates: only resolve if exactly one is in same directory
102                let same_dir_candidates: Vec<_> =
103                    candidates.iter().filter(|(_, _, same)| *same).collect();
104                if same_dir_candidates.len() == 1 {
105                    Some(same_dir_candidates[0].1)
106                } else {
107                    // Ambiguous: don't auto-link
108                    None
109                }
110            }
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_insert_resolve() {
121        let mut table = SymbolTable::new();
122        let path = PathBuf::from("main.rs");
123        let id = NodeId::new(1);
124
125        table.insert("main::foo".to_string(), id, path.clone());
126
127        assert_eq!(table.resolve("main::foo"), Some(id));
128        assert_eq!(table.resolve("main::bar"), None);
129
130        let exports = table.get_file_exports(&path).unwrap();
131        assert_eq!(exports.len(), 1);
132        assert_eq!(exports[0], "main::foo");
133    }
134
135    #[test]
136    fn test_resolve_with_context_exact_match() {
137        let mut table = SymbolTable::new();
138        let path = PathBuf::from("src/utils.rs");
139        let id = NodeId::new(1);
140
141        table.insert("pkg.utils.helper".to_string(), id, path.clone());
142
143        // Exact match works from any context
144        let result =
145            table.resolve_with_context("pkg.utils.helper", &PathBuf::from("other/file.rs"));
146        assert_eq!(result, Some(id));
147    }
148
149    #[test]
150    fn test_resolve_with_context_suffix_match() {
151        let mut table = SymbolTable::new();
152        let path = PathBuf::from("src/utils.rs");
153        let id = NodeId::new(1);
154
155        table.insert("pkg.utils.helper".to_string(), id, path.clone());
156
157        // Suffix match works when unambiguous
158        let result = table.resolve_with_context("helper", &PathBuf::from("other/file.rs"));
159        assert_eq!(result, Some(id));
160    }
161
162    #[test]
163    fn test_resolve_with_context_ambiguous_returns_none() {
164        let mut table = SymbolTable::new();
165        let id1 = NodeId::new(1);
166        let id2 = NodeId::new(2);
167
168        // Two helpers in different directories
169        table.insert(
170            "pkg.a.helper".to_string(),
171            id1,
172            PathBuf::from("src/a/mod.rs"),
173        );
174        table.insert(
175            "pkg.b.helper".to_string(),
176            id2,
177            PathBuf::from("src/b/mod.rs"),
178        );
179
180        // Ambiguous: from unrelated directory, should return None
181        let result = table.resolve_with_context("helper", &PathBuf::from("src/c/caller.rs"));
182        assert_eq!(result, None);
183    }
184
185    #[test]
186    fn test_resolve_with_context_locality_preference() {
187        let mut table = SymbolTable::new();
188        let id1 = NodeId::new(1);
189        let id2 = NodeId::new(2);
190
191        // Two helpers in different directories
192        table.insert(
193            "pkg.a.helper".to_string(),
194            id1,
195            PathBuf::from("src/a/mod.rs"),
196        );
197        table.insert(
198            "pkg.b.helper".to_string(),
199            id2,
200            PathBuf::from("src/b/mod.rs"),
201        );
202
203        // From src/a/, should resolve to id1 (same directory)
204        let result = table.resolve_with_context("helper", &PathBuf::from("src/a/caller.rs"));
205        assert_eq!(result, Some(id1));
206
207        // From src/b/, should resolve to id2 (same directory)
208        let result = table.resolve_with_context("helper", &PathBuf::from("src/b/caller.rs"));
209        assert_eq!(result, Some(id2));
210    }
211}