arbor-graph 2.0.0

Graph schema and relationship tracking for Arbor
Documentation
use crate::graph::NodeId;
use std::collections::HashMap;
use std::path::PathBuf;

/// A global symbol table for resolving cross-file references.
///
/// Maps Fully Qualified Names (FQNs) to Node IDs.
/// Example FQN: "arbor::graph::SymbolTable" -> NodeId(42)
#[derive(Debug, Default, Clone)]
pub struct SymbolTable {
    /// Map of FQN to NodeId
    by_fqn: HashMap<String, NodeId>,

    /// Map of File Path to list of exported symbols (FQNs)
    /// Used to resolve wildcard imports or find all symbols in a file.
    exports_by_file: HashMap<PathBuf, Vec<String>>,
}

impl SymbolTable {
    /// Creates a new empty symbol table.
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers a symbol in the table.
    ///
    /// * `fqn` - Fully Qualified Name (e.g., "pkg.module.function")
    /// * `id` - The Node ID in the graph
    /// * `file` - The file path defining this symbol
    pub fn insert(&mut self, fqn: String, id: NodeId, file: PathBuf) {
        self.by_fqn.insert(fqn.clone(), id);
        self.exports_by_file.entry(file).or_default().push(fqn);
    }

    /// Resolves a Fully Qualified Name to a Node ID.
    pub fn resolve(&self, fqn: &str) -> Option<NodeId> {
        self.by_fqn.get(fqn).copied()
    }

    /// Returns all symbols exported by a file.
    pub fn get_file_exports(&self, file: &PathBuf) -> Option<&Vec<String>> {
        self.exports_by_file.get(file)
    }

    /// Clears the symbol table.
    pub fn clear(&mut self) {
        self.by_fqn.clear();
        self.exports_by_file.clear();
    }

    /// Resolves a symbol name with context-aware matching.
    ///
    /// Resolution order:
    /// 1. Exact FQN match
    /// 2. Suffix match (e.g., "helper" matches "pkg.Utils.helper")
    ///    - Only matches if unambiguous OR in same directory as `context_file`
    ///
    /// Returns None if:
    /// - No match found
    /// - Multiple matches exist and none are in the same directory (ambiguous)
    pub fn resolve_with_context(
        &self,
        name: &str,
        context_file: &std::path::Path,
    ) -> Option<NodeId> {
        // 1. Try exact match first
        if let Some(id) = self.by_fqn.get(name) {
            return Some(*id);
        }

        // 2. Suffix match
        let context_dir = context_file.parent();
        let mut candidates: Vec<(&String, NodeId, bool)> = Vec::new();

        for (fqn, &id) in &self.by_fqn {
            // Check if FQN ends with the name (with separator)
            if fqn.ends_with(name) {
                // Ensure it's a proper suffix (preceded by separator or start)
                let prefix_len = fqn.len() - name.len();
                if prefix_len == 0
                    || fqn.chars().nth(prefix_len - 1) == Some('.')
                    || fqn.chars().nth(prefix_len - 1) == Some(':')
                {
                    // Check if in same directory
                    let same_dir = self
                        .exports_by_file
                        .iter()
                        .find(|(_, exports)| exports.contains(fqn))
                        .map(|(file, _)| file.parent() == context_dir)
                        .unwrap_or(false);

                    candidates.push((fqn, id, same_dir));
                }
            }
        }

        match candidates.len() {
            0 => None,
            1 => Some(candidates[0].1),
            _ => {
                // Multiple candidates: only resolve if exactly one is in same directory
                let same_dir_candidates: Vec<_> =
                    candidates.iter().filter(|(_, _, same)| *same).collect();
                if same_dir_candidates.len() == 1 {
                    Some(same_dir_candidates[0].1)
                } else {
                    // Ambiguous: don't auto-link
                    None
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_insert_resolve() {
        let mut table = SymbolTable::new();
        let path = PathBuf::from("main.rs");
        let id = NodeId::new(1);

        table.insert("main::foo".to_string(), id, path.clone());

        assert_eq!(table.resolve("main::foo"), Some(id));
        assert_eq!(table.resolve("main::bar"), None);

        let exports = table.get_file_exports(&path).unwrap();
        assert_eq!(exports.len(), 1);
        assert_eq!(exports[0], "main::foo");
    }

    #[test]
    fn test_resolve_with_context_exact_match() {
        let mut table = SymbolTable::new();
        let path = PathBuf::from("src/utils.rs");
        let id = NodeId::new(1);

        table.insert("pkg.utils.helper".to_string(), id, path.clone());

        // Exact match works from any context
        let result =
            table.resolve_with_context("pkg.utils.helper", &PathBuf::from("other/file.rs"));
        assert_eq!(result, Some(id));
    }

    #[test]
    fn test_resolve_with_context_suffix_match() {
        let mut table = SymbolTable::new();
        let path = PathBuf::from("src/utils.rs");
        let id = NodeId::new(1);

        table.insert("pkg.utils.helper".to_string(), id, path.clone());

        // Suffix match works when unambiguous
        let result = table.resolve_with_context("helper", &PathBuf::from("other/file.rs"));
        assert_eq!(result, Some(id));
    }

    #[test]
    fn test_resolve_with_context_ambiguous_returns_none() {
        let mut table = SymbolTable::new();
        let id1 = NodeId::new(1);
        let id2 = NodeId::new(2);

        // Two helpers in different directories
        table.insert(
            "pkg.a.helper".to_string(),
            id1,
            PathBuf::from("src/a/mod.rs"),
        );
        table.insert(
            "pkg.b.helper".to_string(),
            id2,
            PathBuf::from("src/b/mod.rs"),
        );

        // Ambiguous: from unrelated directory, should return None
        let result = table.resolve_with_context("helper", &PathBuf::from("src/c/caller.rs"));
        assert_eq!(result, None);
    }

    #[test]
    fn test_resolve_with_context_locality_preference() {
        let mut table = SymbolTable::new();
        let id1 = NodeId::new(1);
        let id2 = NodeId::new(2);

        // Two helpers in different directories
        table.insert(
            "pkg.a.helper".to_string(),
            id1,
            PathBuf::from("src/a/mod.rs"),
        );
        table.insert(
            "pkg.b.helper".to_string(),
            id2,
            PathBuf::from("src/b/mod.rs"),
        );

        // From src/a/, should resolve to id1 (same directory)
        let result = table.resolve_with_context("helper", &PathBuf::from("src/a/caller.rs"));
        assert_eq!(result, Some(id1));

        // From src/b/, should resolve to id2 (same directory)
        let result = table.resolve_with_context("helper", &PathBuf::from("src/b/caller.rs"));
        assert_eq!(result, Some(id2));
    }
}