codebase-graph 1.1.5

Native codebaseGraph CLI and MCP server for local code knowledge graphs.
use super::state::{SemNode, SemanticState};
use std::collections::{BTreeSet, HashMap, HashSet};

const DECLARATION_TABLES: &[&str] = &[
    "Symbol",
    "Class",
    "Function",
    "Method",
    "Parameter",
    "ReturnType",
    "TypeAnnotation",
    "TypeAlias",
    "Variable",
    "Constant",
    "ClassAttribute",
    "InstanceAttribute",
    "Property",
    "Decorator",
    "Assignment",
    "APIEndpoint",
    "Component",
    "Route",
    "Query",
    "SecretRef",
    "Dependency",
    "Module",
];

#[derive(Clone)]
pub(super) struct SemSymbol {
    pub(super) name: String,
    pub(super) qualified_name: String,
    pub(super) node_id: String,
    pub(super) table: String,
    pub(super) language: String,
    pub(super) scope_id: String,
    pub(super) visibility: String,
}

pub(super) fn build_semantic_symbols(state: &SemanticState) -> Vec<SemSymbol> {
    let exported_targets: HashSet<String> = state
        .edges
        .values()
        .filter(|edge| edge.edge_type == "Exports")
        .map(|edge| edge.target_id.clone())
        .collect();
    let mut symbols = Vec::new();
    for node_id in &state.node_order {
        let Some(node) = state.nodes.get(node_id) else {
            continue;
        };
        if !DECLARATION_TABLES.contains(&node.table.as_str()) {
            continue;
        }
        let name = node.label.trim();
        if name.is_empty() {
            continue;
        }
        let mut visibility = semantic_visibility(node);
        if exported_targets.contains(&node.id) {
            visibility = "exported".to_string();
        }
        symbols.push(SemSymbol {
            name: name.to_string(),
            qualified_name: if node.qualified_name.is_empty() {
                name.to_string()
            } else {
                node.qualified_name.clone()
            },
            node_id: node.id.clone(),
            table: node.table.clone(),
            language: node.language.clone(),
            scope_id: node.scope_id.clone(),
            visibility,
        });
    }
    symbols.sort_by(|left, right| {
        (
            left.qualified_name.as_str(),
            left.table.as_str(),
            left.node_id.as_str(),
        )
            .cmp(&(
                right.qualified_name.as_str(),
                right.table.as_str(),
                right.node_id.as_str(),
            ))
    });
    symbols
}

pub(super) fn index_symbols_by_name(symbols: &[SemSymbol]) -> HashMap<String, Vec<SemSymbol>> {
    let mut by_name: HashMap<String, Vec<SemSymbol>> = HashMap::with_capacity(symbols.len() * 2);
    for symbol in symbols {
        for key in semantic_symbol_keys(&symbol.name, &symbol.qualified_name) {
            by_name.entry(key).or_default().push(symbol.clone());
        }
    }
    by_name
}

pub(super) fn semantic_symbol_keys(name: &str, qualified_name: &str) -> Vec<String> {
    let mut keys: BTreeSet<String> = candidate_semantic_symbol_keys(name).into_iter().collect();
    keys.extend(candidate_semantic_symbol_keys(qualified_name));
    keys.into_iter().collect()
}

pub(super) fn candidate_semantic_symbol_keys(label: &str) -> Vec<String> {
    let text = label.trim();
    if text.is_empty() {
        return Vec::new();
    }
    let mut parts = BTreeSet::new();
    parts.insert(text.to_string());
    for delimiter in [".", "::", "->"] {
        if text.contains(delimiter) {
            if let Some((_, right)) = text.rsplit_once(delimiter) {
                parts.insert(right.to_string());
            }
        }
    }
    if text.contains('/') {
        if let Some((_, right)) = text.rsplit_once('/') {
            parts.insert(right.to_string());
        }
    }
    parts
        .into_iter()
        .filter_map(|part| {
            let normalized = part.trim().to_lowercase().replace('_', "");
            if normalized.is_empty() {
                None
            } else {
                Some(normalized)
            }
        })
        .collect()
}

pub(super) fn semantic_visibility(node: &SemNode) -> String {
    if node.table == "Dependency" {
        "external".to_string()
    } else if node.label.starts_with('_') {
        "private".to_string()
    } else if node.label.chars().next().is_some_and(char::is_uppercase)
        || matches!(
            node.table.as_str(),
            "Module" | "Class" | "Function" | "Method" | "TypeAlias"
        )
    {
        "public".to_string()
    } else {
        "local".to_string()
    }
}