grapha 0.2.1

Blazingly fast code intelligence CLI and MCP server for Swift and Rust
Documentation
use std::collections::{HashMap, HashSet};
use std::path::Path;

use grapha_core::graph::{EdgeKind, Graph, Node, NodeKind};

#[derive(Debug, Clone, Default)]
pub struct SymbolLocatorIndex {
    locators_by_id: HashMap<String, String>,
}

impl SymbolLocatorIndex {
    pub fn new(graph: &Graph) -> Self {
        let node_index: HashMap<&str, &Node> = graph
            .nodes
            .iter()
            .map(|node| (node.id.as_str(), node))
            .collect();
        let parent_by_child = select_parents(graph, &node_index);
        let mut locators = HashMap::with_capacity(graph.nodes.len());

        for node in &graph.nodes {
            let locator = build_locator(
                node.id.as_str(),
                &node_index,
                &parent_by_child,
                &mut HashMap::new(),
            )
            .unwrap_or_else(|| fallback_locator(node));
            locators.insert(node.id.clone(), locator);
        }

        Self {
            locators_by_id: locators,
        }
    }

    pub fn locator_for_id(&self, id: &str) -> Option<&str> {
        self.locators_by_id.get(id).map(String::as_str)
    }

    pub fn locator_for_node(&self, node: &Node) -> String {
        self.locators_by_id
            .get(node.id.as_str())
            .cloned()
            .unwrap_or_else(|| fallback_locator(node))
    }
}

pub fn fallback_locator(node: &Node) -> String {
    let mut parts = Vec::new();
    let file = file_label(&node.file);
    if let Some(module) = node.module.as_deref()
        && !module.is_empty()
        && module != file
    {
        parts.push(module.to_string());
    }
    parts.push(file.to_string());
    parts.push(node.name.clone());
    parts.join("::")
}

pub fn locator_matches_suffix(locator: &str, query: &str) -> bool {
    locator == query || locator.ends_with(&format!("::{query}"))
}

pub fn file_label(path: &Path) -> String {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(str::to_string)
        .unwrap_or_else(|| path.to_string_lossy().to_string())
}

fn select_parents<'a>(
    graph: &'a Graph,
    node_index: &HashMap<&'a str, &'a Node>,
) -> HashMap<&'a str, &'a str> {
    let mut candidates: HashMap<&str, Vec<&str>> = HashMap::new();
    for edge in &graph.edges {
        if edge.kind != EdgeKind::Contains {
            continue;
        }
        if node_index.contains_key(edge.source.as_str())
            && node_index.contains_key(edge.target.as_str())
        {
            candidates
                .entry(edge.target.as_str())
                .or_default()
                .push(edge.source.as_str());
        }
    }

    candidates
        .into_iter()
        .filter_map(|(child_id, mut parents)| {
            parents.sort_by(|left_id, right_id| {
                let left = node_index
                    .get(left_id)
                    .copied()
                    .expect("contains source should exist");
                let right = node_index
                    .get(right_id)
                    .copied()
                    .expect("contains source should exist");
                parent_priority(left)
                    .cmp(&parent_priority(right))
                    .then_with(|| left.span.start.cmp(&right.span.start))
                    .then_with(|| left.span.end.cmp(&right.span.end))
                    .then_with(|| left.id.cmp(&right.id))
            });
            parents
                .into_iter()
                .next()
                .map(|parent_id| (child_id, parent_id))
        })
        .collect()
}

fn parent_priority(node: &Node) -> usize {
    match node.kind {
        NodeKind::View | NodeKind::Branch => 1,
        _ => 0,
    }
}

fn build_locator<'a>(
    node_id: &'a str,
    node_index: &HashMap<&'a str, &'a Node>,
    parent_by_child: &HashMap<&'a str, &'a str>,
    cache: &mut HashMap<&'a str, String>,
) -> Option<String> {
    if let Some(existing) = cache.get(node_id) {
        return Some(existing.clone());
    }

    let node = node_index.get(node_id).copied()?;
    let mut seen = HashSet::new();
    let mut owner_ids = Vec::new();
    let mut current = node_id;
    while let Some(parent_id) = parent_by_child.get(current).copied() {
        if !seen.insert(parent_id) {
            break;
        }
        owner_ids.push(parent_id);
        current = parent_id;
    }
    owner_ids.reverse();

    let mut parts = Vec::new();
    let file = file_label(&node.file);
    if let Some(module) = node.module.as_deref()
        && !module.is_empty()
        && module != file
    {
        parts.push(module.to_string());
    }
    parts.push(file);
    for owner_id in owner_ids {
        let owner = node_index.get(owner_id).copied()?;
        parts.push(owner.name.clone());
    }
    parts.push(node.name.clone());

    let locator = parts.join("::");
    cache.insert(node_id, locator.clone());
    Some(locator)
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::path::PathBuf;

    use grapha_core::graph::{Edge, Span, Visibility};

    use super::*;

    fn node(id: &str, name: &str, kind: NodeKind, file: &str) -> Node {
        Node {
            id: id.to_string(),
            kind,
            name: name.to_string(),
            file: PathBuf::from(file),
            span: Span {
                start: [1, 0],
                end: [1, 0],
            },
            visibility: Visibility::Public,
            metadata: HashMap::new(),
            role: None,
            signature: None,
            doc_comment: None,
            module: Some("ModuleExport".to_string()),
            snippet: None,
        }
    }

    fn contains(source: &str, target: &str) -> Edge {
        Edge {
            source: source.to_string(),
            target: target.to_string(),
            kind: EdgeKind::Contains,
            confidence: 1.0,
            direction: None,
            operation: None,
            condition: None,
            async_boundary: None,
            provenance: Vec::new(),
        }
    }

    #[test]
    fn builds_rust_style_locator_from_contains_chain() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![
                node("type", "Test", NodeKind::Struct, "Sources/Hello.swift"),
                node(
                    "method",
                    "hello(name:)",
                    NodeKind::Function,
                    "Sources/Hello.swift",
                ),
            ],
            edges: vec![contains("type", "method")],
        };

        let locators = SymbolLocatorIndex::new(&graph);
        assert_eq!(
            locators.locator_for_id("method"),
            Some("ModuleExport::Hello.swift::Test::hello(name:)")
        );
    }

    #[test]
    fn suffix_matching_requires_segment_boundary() {
        assert!(locator_matches_suffix(
            "ModuleExport::Hello.swift::Test::hello(name:)",
            "Hello.swift::Test::hello(name:)"
        ));
        assert!(!locator_matches_suffix(
            "ModuleExport::Hello.swift::Test::hello(name:)",
            "ello.swift::Test::hello(name:)"
        ));
    }
}