Skip to main content

graphyn_core/
query.rs

1use std::collections::{BTreeSet, HashSet, VecDeque};
2
3use petgraph::visit::EdgeRef;
4use petgraph::Direction;
5
6use crate::error::GraphynError;
7use crate::graph::GraphynGraph;
8use crate::index::find_symbol_id;
9use crate::ir::SymbolId;
10
11const DEFAULT_DEPTH: usize = 3;
12const MAX_DEPTH: usize = 10;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct QueryEdge {
16    pub from: SymbolId,
17    pub to: SymbolId,
18    pub file: String,
19    pub line: u32,
20    pub alias: Option<String>,
21    pub properties_accessed: Vec<String>,
22    pub context: String,
23    pub hop: usize,
24}
25
26/// Split edges into those that reach the symbol under its own name and those
27/// that rename it.
28///
29/// The distinction drives the "HIGH RISK" labelling in both the CLI and the MCP
30/// server: a caller that imports `UserPayload as ResponseModel` will not turn up
31/// in a text search for the original name, which is exactly the case Graphyn
32/// exists to surface.
33///
34/// An `alias` equal to the symbol's own name is not a rename. Adapters record
35/// the local name on type-reference edges whether or not it differs, so testing
36/// `alias.is_some()` alone flagged ordinary same-name references as high risk
37/// and buried the genuine renames among them.
38pub fn partition_by_alias<'e>(
39    graph: &GraphynGraph,
40    edges: &'e [QueryEdge],
41) -> (Vec<&'e QueryEdge>, Vec<&'e QueryEdge>) {
42    let mut direct = Vec::new();
43    let mut aliased = Vec::new();
44
45    for edge in edges {
46        if is_renamed(graph, edge) {
47            aliased.push(edge);
48        } else {
49            direct.push(edge);
50        }
51    }
52
53    (direct, aliased)
54}
55
56/// True when `edge` refers to its target under a name other than the target's.
57pub fn is_renamed(graph: &GraphynGraph, edge: &QueryEdge) -> bool {
58    let Some(alias) = edge.alias.as_deref() else {
59        return false;
60    };
61    match graph.symbols.get(&edge.to) {
62        // A qualified reference such as `models.UserPayload` names the symbol
63        // directly; only the final segment is compared.
64        Some(symbol) => alias.rsplit(['.', ':']).next().unwrap_or(alias) != symbol.name,
65        // Unknown target: an alias is the only name we have, so treat it as one.
66        None => true,
67    }
68}
69
70pub fn blast_radius(
71    graph: &GraphynGraph,
72    symbol: &str,
73    file: Option<&str>,
74    depth: Option<usize>,
75) -> Result<Vec<QueryEdge>, GraphynError> {
76    let effective_depth = depth.unwrap_or(DEFAULT_DEPTH);
77    if effective_depth > MAX_DEPTH {
78        return Err(GraphynError::InvalidDepth {
79            depth: effective_depth,
80            max: MAX_DEPTH,
81        });
82    }
83
84    let root = find_symbol_id(graph, symbol, file)?;
85    traverse(graph, &root, effective_depth, Direction::Incoming)
86}
87
88pub fn dependencies(
89    graph: &GraphynGraph,
90    symbol: &str,
91    file: Option<&str>,
92    depth: Option<usize>,
93) -> Result<Vec<QueryEdge>, GraphynError> {
94    let effective_depth = depth.unwrap_or(DEFAULT_DEPTH);
95    if effective_depth > MAX_DEPTH {
96        return Err(GraphynError::InvalidDepth {
97            depth: effective_depth,
98            max: MAX_DEPTH,
99        });
100    }
101
102    let root = find_symbol_id(graph, symbol, file)?;
103    traverse(graph, &root, effective_depth, Direction::Outgoing)
104}
105
106pub fn symbol_usages(
107    graph: &GraphynGraph,
108    symbol: &str,
109    file: Option<&str>,
110    include_aliases: bool,
111) -> Result<Vec<QueryEdge>, GraphynError> {
112    let root = find_symbol_id(graph, symbol, file)?;
113    let mut results = traverse(graph, &root, 1, Direction::Incoming)?;
114
115    if include_aliases {
116        if let Some(aliases) = graph.alias_chains.get(&root) {
117            let alias_set: HashSet<String> = aliases.iter().map(|a| a.alias_name.clone()).collect();
118            for edge in &mut results {
119                if edge.alias.is_none() && edge.context.contains(" as ") {
120                    let alias = edge
121                        .context
122                        .split(" as ")
123                        .nth(1)
124                        .and_then(|v| v.split_whitespace().next())
125                        .map(|s| s.trim_matches(|c: char| c == ',' || c == ';').to_string());
126                    if let Some(found) = alias {
127                        if alias_set.contains(&found) {
128                            edge.alias = Some(found);
129                        }
130                    }
131                }
132            }
133        }
134    } else {
135        results.retain(|edge| edge.alias.is_none());
136    }
137
138    dedupe_edges(results)
139}
140
141fn traverse(
142    graph: &GraphynGraph,
143    root: &SymbolId,
144    max_depth: usize,
145    direction: Direction,
146) -> Result<Vec<QueryEdge>, GraphynError> {
147    let Some(root_node) = graph.node_index.get(root).map(|v| *v) else {
148        return Err(GraphynError::SymbolNotFound(root.clone()));
149    };
150
151    let mut queue = VecDeque::new();
152    let mut visited = HashSet::new();
153    let mut results = Vec::new();
154
155    queue.push_back((root_node, 0usize));
156    visited.insert(root_node);
157
158    while let Some((node, depth)) = queue.pop_front() {
159        if depth >= max_depth {
160            continue;
161        }
162
163        for edge in graph.graph.edges_directed(node, direction) {
164            let neighbor = if direction == Direction::Incoming {
165                edge.source()
166            } else {
167                edge.target()
168            };
169
170            let from_id = graph
171                .graph
172                .node_weight(edge.source())
173                .cloned()
174                .ok_or_else(|| GraphynError::GraphCorrupt("Missing source node".to_string()))?;
175            let to_id = graph
176                .graph
177                .node_weight(edge.target())
178                .cloned()
179                .ok_or_else(|| GraphynError::GraphCorrupt("Missing target node".to_string()))?;
180            let meta = edge.weight();
181
182            results.push(QueryEdge {
183                from: from_id,
184                to: to_id,
185                file: meta.file.clone(),
186                line: meta.line,
187                alias: meta.alias.clone(),
188                properties_accessed: meta.properties_accessed.clone(),
189                context: meta.context.clone(),
190                hop: depth + 1,
191            });
192
193            if visited.insert(neighbor) {
194                queue.push_back((neighbor, depth + 1));
195            }
196        }
197    }
198
199    dedupe_edges(results)
200}
201
202fn dedupe_edges(mut edges: Vec<QueryEdge>) -> Result<Vec<QueryEdge>, GraphynError> {
203    let mut seen = BTreeSet::new();
204    edges.retain(|edge| {
205        seen.insert((
206            edge.from.clone(),
207            edge.to.clone(),
208            edge.file.clone(),
209            edge.line,
210            edge.alias.clone(),
211        ))
212    });
213
214    edges.sort_by(|a, b| {
215        a.hop
216            .cmp(&b.hop)
217            .then(a.file.cmp(&b.file))
218            .then(a.line.cmp(&b.line))
219            .then(a.from.cmp(&b.from))
220            .then(a.to.cmp(&b.to))
221    });
222
223    Ok(edges)
224}