grapha-core 0.3.0

Shared graph types and extraction traits for Grapha
Documentation
use crate::graph::{Edge, EdgeKind, Graph, Node, NodeKind, NodeRole, TerminalKind};
use crate::semantic::{
    ArtifactKind, SemanticAnnotation, SemanticArtifact, SemanticDocument, SemanticRelation,
    SemanticSymbol, SemanticTarget,
};

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SymbolSelector {
    pub id: Option<String>,
    pub name: Option<String>,
    pub kind: Option<NodeKind>,
    pub module: Option<String>,
    pub file_suffix: Option<String>,
    pub annotation: Option<AnnotationSelector>,
    pub property_key: Option<String>,
}

impl SymbolSelector {
    pub fn by_kind(kind: NodeKind) -> Self {
        Self {
            kind: Some(kind),
            ..Self::default()
        }
    }

    pub fn with_annotation(mut self, annotation: AnnotationSelector) -> Self {
        self.annotation = Some(annotation);
        self
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnnotationSelector {
    EntryPoint,
    Terminal(TerminalKind),
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RelationSelector {
    pub source: Option<String>,
    pub relation_kind: Option<EdgeKind>,
    pub target_symbol: Option<String>,
    pub external_only: bool,
    pub terminal_kind: Option<TerminalKind>,
}

impl RelationSelector {
    pub fn calls() -> Self {
        Self {
            relation_kind: Some(EdgeKind::Calls),
            ..Self::default()
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ArtifactSelector {
    pub kind: Option<ArtifactKind>,
}

pub fn select_semantic_symbols<'a>(
    document: &'a SemanticDocument,
    selector: &SymbolSelector,
) -> Vec<&'a SemanticSymbol> {
    document
        .symbols
        .iter()
        .filter(|symbol| symbol_matches(symbol, selector))
        .collect()
}

pub fn select_semantic_relations<'a>(
    document: &'a SemanticDocument,
    selector: &RelationSelector,
) -> Vec<&'a SemanticRelation> {
    document
        .relations
        .iter()
        .filter(|relation| relation_matches(relation, selector))
        .collect()
}

pub fn select_semantic_artifacts<'a>(
    document: &'a SemanticDocument,
    selector: &ArtifactSelector,
) -> Vec<&'a SemanticArtifact> {
    document
        .artifacts
        .iter()
        .filter(|artifact| selector.kind.is_none_or(|kind| artifact.kind() == kind))
        .collect()
}

pub fn select_graph_nodes<'a>(graph: &'a Graph, selector: &SymbolSelector) -> Vec<&'a Node> {
    graph
        .nodes
        .iter()
        .filter(|node| node_matches(node, selector))
        .collect()
}

pub fn select_graph_edges<'a>(graph: &'a Graph, selector: &RelationSelector) -> Vec<&'a Edge> {
    let node_ids: std::collections::HashSet<&str> =
        graph.nodes.iter().map(|node| node.id.as_str()).collect();
    graph
        .edges
        .iter()
        .filter(|edge| edge_matches(edge, &node_ids, selector))
        .collect()
}

fn symbol_matches(symbol: &SemanticSymbol, selector: &SymbolSelector) -> bool {
    selector.id.as_ref().is_none_or(|id| symbol.id == *id)
        && selector
            .name
            .as_ref()
            .is_none_or(|name| symbol.name == *name)
        && selector.kind.is_none_or(|kind| symbol.kind == kind)
        && selector
            .module
            .as_ref()
            .is_none_or(|module| symbol.module.as_deref() == Some(module.as_str()))
        && selector
            .file_suffix
            .as_ref()
            .is_none_or(|suffix| symbol.file.to_string_lossy().ends_with(suffix))
        && selector
            .annotation
            .is_none_or(|annotation| symbol_annotation_matches(symbol, annotation))
        && selector
            .property_key
            .as_ref()
            .is_none_or(|key| symbol.properties.contains_key(key))
}

fn symbol_annotation_matches(symbol: &SemanticSymbol, selector: AnnotationSelector) -> bool {
    symbol
        .annotations
        .iter()
        .any(|annotation| match (annotation, selector) {
            (SemanticAnnotation::EntryPoint, AnnotationSelector::EntryPoint) => true,
            (SemanticAnnotation::Terminal { kind }, AnnotationSelector::Terminal(expected)) => {
                *kind == expected
            }
            _ => false,
        })
}

fn relation_matches(relation: &SemanticRelation, selector: &RelationSelector) -> bool {
    selector
        .source
        .as_ref()
        .is_none_or(|source| relation.source == *source)
        && selector
            .relation_kind
            .is_none_or(|kind| relation.kind == kind)
        && selector.target_symbol.as_ref().is_none_or(|target| {
            matches!(&relation.target, SemanticTarget::Symbol(symbol_id) if symbol_id == target)
        })
        && (!selector.external_only || matches!(relation.target, SemanticTarget::ExternalRef(_)))
        && selector
            .terminal_kind
            .is_none_or(|kind| relation.terminal_kind == Some(kind))
}

fn node_matches(node: &Node, selector: &SymbolSelector) -> bool {
    selector.id.as_ref().is_none_or(|id| node.id == *id)
        && selector.name.as_ref().is_none_or(|name| node.name == *name)
        && selector.kind.is_none_or(|kind| node.kind == kind)
        && selector
            .module
            .as_ref()
            .is_none_or(|module| node.module.as_deref() == Some(module.as_str()))
        && selector
            .file_suffix
            .as_ref()
            .is_none_or(|suffix| node.file.to_string_lossy().ends_with(suffix))
        && selector
            .annotation
            .is_none_or(|annotation| node_role_matches(node.role.as_ref(), annotation))
        && selector
            .property_key
            .as_ref()
            .is_none_or(|key| node.metadata.contains_key(key))
}

fn node_role_matches(role: Option<&NodeRole>, selector: AnnotationSelector) -> bool {
    match (role, selector) {
        (Some(NodeRole::EntryPoint), AnnotationSelector::EntryPoint) => true,
        (Some(NodeRole::Terminal { kind }), AnnotationSelector::Terminal(expected)) => {
            *kind == expected
        }
        _ => false,
    }
}

fn edge_matches(
    edge: &Edge,
    node_ids: &std::collections::HashSet<&str>,
    selector: &RelationSelector,
) -> bool {
    selector
        .source
        .as_ref()
        .is_none_or(|source| edge.source == *source)
        && selector.relation_kind.is_none_or(|kind| edge.kind == kind)
        && selector
            .target_symbol
            .as_ref()
            .is_none_or(|target| edge.target == *target)
        && (!selector.external_only || !node_ids.contains(edge.target.as_str()))
}

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

    use crate::graph::{Edge, FlowDirection, Graph, Node, Span, Visibility};
    use crate::semantic::{SemanticDocument, SemanticRelation, SemanticSymbol, SemanticTarget};

    use super::*;

    fn test_symbol() -> SemanticSymbol {
        SemanticSymbol {
            id: "body".to_string(),
            kind: NodeKind::View,
            name: "Text".to_string(),
            file: PathBuf::from("ContentView.swift"),
            span: Span {
                start: [1, 0],
                end: [2, 0],
            },
            visibility: Visibility::Public,
            properties: HashMap::from([(
                "swiftui.invalidation_source".to_string(),
                "true".to_string(),
            )]),
            annotations: vec![SemanticAnnotation::EntryPoint],
            signature: None,
            doc_comment: None,
            module: Some("Demo".to_string()),
            snippet: None,
            synthetic_kind: Some("swiftui_view".to_string()),
        }
    }

    #[test]
    fn selects_semantic_symbols_and_relations() {
        let mut document = SemanticDocument::new();
        document.symbols.push(test_symbol());
        document.relations.push(SemanticRelation {
            source: "body".to_string(),
            target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
            kind: EdgeKind::Calls,
            confidence: 1.0,
            direction: Some(FlowDirection::Read),
            operation: Some("HTTP".to_string()),
            condition: None,
            async_boundary: None,
            provenance: Vec::new(),
            terminal_kind: Some(TerminalKind::Network),
        });

        let symbols = select_semantic_symbols(
            &document,
            &SymbolSelector::by_kind(NodeKind::View)
                .with_annotation(AnnotationSelector::EntryPoint),
        );
        assert_eq!(symbols.len(), 1);

        let relations = select_semantic_relations(
            &document,
            &RelationSelector {
                external_only: true,
                terminal_kind: Some(TerminalKind::Network),
                ..RelationSelector::calls()
            },
        );
        assert_eq!(relations.len(), 1);
    }

    #[test]
    fn selects_only_external_graph_edges_when_requested() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![
                Node {
                    id: "caller".to_string(),
                    kind: NodeKind::Function,
                    name: "load".to_string(),
                    file: PathBuf::from("main.rs"),
                    span: Span {
                        start: [1, 0],
                        end: [2, 0],
                    },
                    visibility: Visibility::Public,
                    metadata: HashMap::new(),
                    role: None,
                    signature: None,
                    doc_comment: None,
                    module: None,
                    snippet: None,
                },
                Node {
                    id: "callee".to_string(),
                    kind: NodeKind::Function,
                    name: "helper".to_string(),
                    file: PathBuf::from("main.rs"),
                    span: Span {
                        start: [3, 0],
                        end: [4, 0],
                    },
                    visibility: Visibility::Private,
                    metadata: HashMap::new(),
                    role: None,
                    signature: None,
                    doc_comment: None,
                    module: None,
                    snippet: None,
                },
            ],
            edges: vec![
                Edge {
                    source: "caller".to_string(),
                    target: "callee".to_string(),
                    kind: EdgeKind::Calls,
                    confidence: 1.0,
                    direction: None,
                    operation: None,
                    condition: None,
                    async_boundary: None,
                    provenance: Vec::new(),
                },
                Edge {
                    source: "caller".to_string(),
                    target: "reqwest::get".to_string(),
                    kind: EdgeKind::Calls,
                    confidence: 1.0,
                    direction: Some(FlowDirection::Read),
                    operation: Some("HTTP".to_string()),
                    condition: None,
                    async_boundary: None,
                    provenance: Vec::new(),
                },
            ],
        };

        let edges = select_graph_edges(
            &graph,
            &RelationSelector {
                external_only: true,
                ..RelationSelector::calls()
            },
        );

        assert_eq!(edges.len(), 1);
        assert_eq!(edges[0].target, "reqwest::get");
    }
}