Skip to main content

grapha_core/
selector.rs

1use crate::graph::{Edge, EdgeKind, Graph, Node, NodeKind, NodeRole, TerminalKind};
2use crate::semantic::{
3    ArtifactKind, SemanticAnnotation, SemanticArtifact, SemanticDocument, SemanticRelation,
4    SemanticSymbol, SemanticTarget,
5};
6
7#[derive(Debug, Clone, PartialEq, Eq, Default)]
8pub struct SymbolSelector {
9    pub id: Option<String>,
10    pub name: Option<String>,
11    pub kind: Option<NodeKind>,
12    pub module: Option<String>,
13    pub file_suffix: Option<String>,
14    pub annotation: Option<AnnotationSelector>,
15    pub property_key: Option<String>,
16}
17
18impl SymbolSelector {
19    pub fn by_kind(kind: NodeKind) -> Self {
20        Self {
21            kind: Some(kind),
22            ..Self::default()
23        }
24    }
25
26    pub fn with_annotation(mut self, annotation: AnnotationSelector) -> Self {
27        self.annotation = Some(annotation);
28        self
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AnnotationSelector {
34    EntryPoint,
35    Terminal(TerminalKind),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct RelationSelector {
40    pub source: Option<String>,
41    pub relation_kind: Option<EdgeKind>,
42    pub target_symbol: Option<String>,
43    pub external_only: bool,
44    pub terminal_kind: Option<TerminalKind>,
45}
46
47impl RelationSelector {
48    pub fn calls() -> Self {
49        Self {
50            relation_kind: Some(EdgeKind::Calls),
51            ..Self::default()
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub struct ArtifactSelector {
58    pub kind: Option<ArtifactKind>,
59}
60
61pub fn select_semantic_symbols<'a>(
62    document: &'a SemanticDocument,
63    selector: &SymbolSelector,
64) -> Vec<&'a SemanticSymbol> {
65    document
66        .symbols
67        .iter()
68        .filter(|symbol| symbol_matches(symbol, selector))
69        .collect()
70}
71
72pub fn select_semantic_relations<'a>(
73    document: &'a SemanticDocument,
74    selector: &RelationSelector,
75) -> Vec<&'a SemanticRelation> {
76    document
77        .relations
78        .iter()
79        .filter(|relation| relation_matches(relation, selector))
80        .collect()
81}
82
83pub fn select_semantic_artifacts<'a>(
84    document: &'a SemanticDocument,
85    selector: &ArtifactSelector,
86) -> Vec<&'a SemanticArtifact> {
87    document
88        .artifacts
89        .iter()
90        .filter(|artifact| selector.kind.is_none_or(|kind| artifact.kind() == kind))
91        .collect()
92}
93
94pub fn select_graph_nodes<'a>(graph: &'a Graph, selector: &SymbolSelector) -> Vec<&'a Node> {
95    graph
96        .nodes
97        .iter()
98        .filter(|node| node_matches(node, selector))
99        .collect()
100}
101
102pub fn select_graph_edges<'a>(graph: &'a Graph, selector: &RelationSelector) -> Vec<&'a Edge> {
103    let node_ids: std::collections::HashSet<&str> =
104        graph.nodes.iter().map(|node| node.id.as_str()).collect();
105    graph
106        .edges
107        .iter()
108        .filter(|edge| edge_matches(edge, &node_ids, selector))
109        .collect()
110}
111
112fn symbol_matches(symbol: &SemanticSymbol, selector: &SymbolSelector) -> bool {
113    selector.id.as_ref().is_none_or(|id| symbol.id == *id)
114        && selector
115            .name
116            .as_ref()
117            .is_none_or(|name| symbol.name == *name)
118        && selector.kind.is_none_or(|kind| symbol.kind == kind)
119        && selector
120            .module
121            .as_ref()
122            .is_none_or(|module| symbol.module.as_deref() == Some(module.as_str()))
123        && selector
124            .file_suffix
125            .as_ref()
126            .is_none_or(|suffix| symbol.file.to_string_lossy().ends_with(suffix))
127        && selector
128            .annotation
129            .is_none_or(|annotation| symbol_annotation_matches(symbol, annotation))
130        && selector
131            .property_key
132            .as_ref()
133            .is_none_or(|key| symbol.properties.contains_key(key))
134}
135
136fn symbol_annotation_matches(symbol: &SemanticSymbol, selector: AnnotationSelector) -> bool {
137    symbol
138        .annotations
139        .iter()
140        .any(|annotation| match (annotation, selector) {
141            (SemanticAnnotation::EntryPoint, AnnotationSelector::EntryPoint) => true,
142            (SemanticAnnotation::Terminal { kind }, AnnotationSelector::Terminal(expected)) => {
143                *kind == expected
144            }
145            _ => false,
146        })
147}
148
149fn relation_matches(relation: &SemanticRelation, selector: &RelationSelector) -> bool {
150    selector
151        .source
152        .as_ref()
153        .is_none_or(|source| relation.source == *source)
154        && selector
155            .relation_kind
156            .is_none_or(|kind| relation.kind == kind)
157        && selector.target_symbol.as_ref().is_none_or(|target| {
158            matches!(&relation.target, SemanticTarget::Symbol(symbol_id) if symbol_id == target)
159        })
160        && (!selector.external_only || matches!(relation.target, SemanticTarget::ExternalRef(_)))
161        && selector
162            .terminal_kind
163            .is_none_or(|kind| relation.terminal_kind == Some(kind))
164}
165
166fn node_matches(node: &Node, selector: &SymbolSelector) -> bool {
167    selector.id.as_ref().is_none_or(|id| node.id == *id)
168        && selector.name.as_ref().is_none_or(|name| node.name == *name)
169        && selector.kind.is_none_or(|kind| node.kind == kind)
170        && selector
171            .module
172            .as_ref()
173            .is_none_or(|module| node.module.as_deref() == Some(module.as_str()))
174        && selector
175            .file_suffix
176            .as_ref()
177            .is_none_or(|suffix| node.file.to_string_lossy().ends_with(suffix))
178        && selector
179            .annotation
180            .is_none_or(|annotation| node_role_matches(node.role.as_ref(), annotation))
181        && selector
182            .property_key
183            .as_ref()
184            .is_none_or(|key| node.metadata.contains_key(key))
185}
186
187fn node_role_matches(role: Option<&NodeRole>, selector: AnnotationSelector) -> bool {
188    match (role, selector) {
189        (Some(NodeRole::EntryPoint), AnnotationSelector::EntryPoint) => true,
190        (Some(NodeRole::Terminal { kind }), AnnotationSelector::Terminal(expected)) => {
191            *kind == expected
192        }
193        _ => false,
194    }
195}
196
197fn edge_matches(
198    edge: &Edge,
199    node_ids: &std::collections::HashSet<&str>,
200    selector: &RelationSelector,
201) -> bool {
202    selector
203        .source
204        .as_ref()
205        .is_none_or(|source| edge.source == *source)
206        && selector.relation_kind.is_none_or(|kind| edge.kind == kind)
207        && selector
208            .target_symbol
209            .as_ref()
210            .is_none_or(|target| edge.target == *target)
211        && (!selector.external_only || !node_ids.contains(edge.target.as_str()))
212}
213
214#[cfg(test)]
215mod tests {
216    use std::collections::HashMap;
217    use std::path::PathBuf;
218
219    use crate::graph::{Edge, FlowDirection, Graph, Node, Span, Visibility};
220    use crate::semantic::{SemanticDocument, SemanticRelation, SemanticSymbol, SemanticTarget};
221
222    use super::*;
223
224    fn test_symbol() -> SemanticSymbol {
225        SemanticSymbol {
226            id: "body".to_string(),
227            kind: NodeKind::View,
228            name: "Text".to_string(),
229            file: PathBuf::from("ContentView.swift"),
230            span: Span {
231                start: [1, 0],
232                end: [2, 0],
233            },
234            visibility: Visibility::Public,
235            properties: HashMap::from([(
236                "swiftui.invalidation_source".to_string(),
237                "true".to_string(),
238            )]),
239            annotations: vec![SemanticAnnotation::EntryPoint],
240            signature: None,
241            doc_comment: None,
242            module: Some("Demo".to_string()),
243            snippet: None,
244            synthetic_kind: Some("swiftui_view".to_string()),
245        }
246    }
247
248    #[test]
249    fn selects_semantic_symbols_and_relations() {
250        let mut document = SemanticDocument::new();
251        document.symbols.push(test_symbol());
252        document.relations.push(SemanticRelation {
253            source: "body".to_string(),
254            target: SemanticTarget::ExternalRef("reqwest::get".to_string()),
255            kind: EdgeKind::Calls,
256            confidence: 1.0,
257            direction: Some(FlowDirection::Read),
258            operation: Some("HTTP".to_string()),
259            condition: None,
260            async_boundary: None,
261            provenance: Vec::new(),
262            terminal_kind: Some(TerminalKind::Network),
263        });
264
265        let symbols = select_semantic_symbols(
266            &document,
267            &SymbolSelector::by_kind(NodeKind::View)
268                .with_annotation(AnnotationSelector::EntryPoint),
269        );
270        assert_eq!(symbols.len(), 1);
271
272        let relations = select_semantic_relations(
273            &document,
274            &RelationSelector {
275                external_only: true,
276                terminal_kind: Some(TerminalKind::Network),
277                ..RelationSelector::calls()
278            },
279        );
280        assert_eq!(relations.len(), 1);
281    }
282
283    #[test]
284    fn selects_only_external_graph_edges_when_requested() {
285        let graph = Graph {
286            version: "0.1.0".to_string(),
287            nodes: vec![
288                Node {
289                    id: "caller".to_string(),
290                    kind: NodeKind::Function,
291                    name: "load".to_string(),
292                    file: PathBuf::from("main.rs"),
293                    span: Span {
294                        start: [1, 0],
295                        end: [2, 0],
296                    },
297                    visibility: Visibility::Public,
298                    metadata: HashMap::new(),
299                    role: None,
300                    signature: None,
301                    doc_comment: None,
302                    module: None,
303                    snippet: None,
304                },
305                Node {
306                    id: "callee".to_string(),
307                    kind: NodeKind::Function,
308                    name: "helper".to_string(),
309                    file: PathBuf::from("main.rs"),
310                    span: Span {
311                        start: [3, 0],
312                        end: [4, 0],
313                    },
314                    visibility: Visibility::Private,
315                    metadata: HashMap::new(),
316                    role: None,
317                    signature: None,
318                    doc_comment: None,
319                    module: None,
320                    snippet: None,
321                },
322            ],
323            edges: vec![
324                Edge {
325                    source: "caller".to_string(),
326                    target: "callee".to_string(),
327                    kind: EdgeKind::Calls,
328                    confidence: 1.0,
329                    direction: None,
330                    operation: None,
331                    condition: None,
332                    async_boundary: None,
333                    provenance: Vec::new(),
334                },
335                Edge {
336                    source: "caller".to_string(),
337                    target: "reqwest::get".to_string(),
338                    kind: EdgeKind::Calls,
339                    confidence: 1.0,
340                    direction: Some(FlowDirection::Read),
341                    operation: Some("HTTP".to_string()),
342                    condition: None,
343                    async_boundary: None,
344                    provenance: Vec::new(),
345                },
346            ],
347        };
348
349        let edges = select_graph_edges(
350            &graph,
351            &RelationSelector {
352                external_only: true,
353                ..RelationSelector::calls()
354            },
355        );
356
357        assert_eq!(edges.len(), 1);
358        assert_eq!(edges[0].target, "reqwest::get");
359    }
360}