1use std::sync::Arc;
28
29use sqry_core::graph::unified::concurrent::GraphSnapshot;
30use sqry_core::graph::unified::edge::kind::EdgeKind;
31use sqry_core::graph::unified::node::id::NodeId;
32use sqry_core::query::CircularType;
33
34use crate::QueryDb;
35use crate::dependency::record_file_dep;
36use crate::query::DerivedQuery;
37
38use super::SccQuery;
39
40pub type CyclesValue = std::sync::Arc<Vec<Vec<sqry_core::graph::unified::node::id::NodeId>>>;
51
52pub type IsInCycleValue = bool;
54
55#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
60pub struct CycleBounds {
61 pub min_depth: usize,
64 pub max_depth: Option<usize>,
66 pub max_results: usize,
68 pub should_include_self_loops: bool,
70}
71
72impl Default for CycleBounds {
73 fn default() -> Self {
74 Self {
75 min_depth: 2,
76 max_depth: None,
77 max_results: 100,
78 should_include_self_loops: false,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
85pub struct CyclesKey {
86 pub circular_type: CircularType,
88 pub bounds: CycleBounds,
90}
91
92#[derive(Debug, Clone, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
94pub struct IsInCycleKey {
95 pub node_id: NodeId,
97 pub circular_type: CircularType,
99 pub bounds: CycleBounds,
101}
102
103pub struct CyclesQuery;
120
121impl DerivedQuery for CyclesQuery {
122 type Key = CyclesKey;
123 type Value = Arc<Vec<Vec<NodeId>>>;
124 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::CYCLES;
125 const TRACKS_EDGE_REVISION: bool = true;
126
127 fn execute(key: &CyclesKey, db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<Vec<NodeId>>> {
128 for (fid, _) in snapshot.file_segments().iter() {
129 record_file_dep(fid);
130 }
131
132 let edge_probe = edge_probe_for(key.circular_type);
133 let scc = db.get::<SccQuery>(&edge_probe);
134
135 let mut cycles: Vec<Vec<NodeId>> = Vec::new();
136 for component in &scc.components {
137 if cycles.len() >= key.bounds.max_results {
138 break;
139 }
140 let size = component.len();
141 let is_self_loop =
142 size == 1 && node_has_self_loop(snapshot, component[0], key.circular_type);
143 if !should_include(size, is_self_loop, &key.bounds) {
144 continue;
145 }
146 cycles.push(component.clone());
147 }
148
149 Arc::new(cycles)
150 }
151}
152
153pub struct IsInCycleQuery;
164
165impl DerivedQuery for IsInCycleQuery {
166 type Key = IsInCycleKey;
167 type Value = bool;
168 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::IS_IN_CYCLE;
169 const TRACKS_EDGE_REVISION: bool = true;
170
171 fn execute(key: &IsInCycleKey, db: &QueryDb, snapshot: &GraphSnapshot) -> bool {
172 if let Some(entry) = snapshot.nodes().get(key.node_id) {
175 record_file_dep(entry.file);
176 }
177
178 let self_loop = node_has_self_loop(snapshot, key.node_id, key.circular_type);
179 if self_loop && key.bounds.should_include_self_loops {
180 return true;
181 }
182
183 let edge_probe = edge_probe_for(key.circular_type);
184 let scc = db.get::<SccQuery>(&edge_probe);
185 let Some(component_idx) = scc.component_of(key.node_id) else {
186 return false;
187 };
188 let Some(component) = scc.components.get(component_idx as usize) else {
189 return false;
190 };
191
192 let size = component.len();
193 if size < 2 {
197 return false;
198 }
199 if size < key.bounds.min_depth {
200 return false;
201 }
202 if key.bounds.max_depth.is_some_and(|max| size > max) {
203 return false;
204 }
205 true
206 }
207}
208
209fn edge_probe_for(circular_type: CircularType) -> EdgeKind {
214 match circular_type {
215 CircularType::Calls => EdgeKind::Calls {
216 argument_count: 0,
217 is_async: false,
218 },
219 CircularType::Imports | CircularType::Modules => EdgeKind::Imports {
220 alias: None,
221 is_wildcard: false,
222 },
223 }
224}
225
226fn node_has_self_loop(
227 snapshot: &GraphSnapshot,
228 node_id: NodeId,
229 circular_type: CircularType,
230) -> bool {
231 for edge in &snapshot.edges().edges_from(node_id) {
232 if edge.target != node_id {
233 continue;
234 }
235 let is_match = match circular_type {
236 CircularType::Calls => matches!(edge.kind, EdgeKind::Calls { .. }),
237 CircularType::Imports | CircularType::Modules => {
238 matches!(edge.kind, EdgeKind::Imports { .. })
239 }
240 };
241 if is_match {
242 return true;
243 }
244 }
245 false
246}
247
248fn should_include(size: usize, is_self_loop: bool, bounds: &CycleBounds) -> bool {
249 if is_self_loop {
250 return bounds.should_include_self_loops;
251 }
252 if size == 1 {
253 return false;
254 }
255 if size < bounds.min_depth {
256 return false;
257 }
258 if bounds.max_depth.is_some_and(|max| size > max) {
259 return false;
260 }
261 true
262}
263
264#[cfg(test)]
269mod serde_roundtrip {
270 use super::*;
271 use postcard::{from_bytes, to_allocvec};
272 use sqry_core::graph::unified::node::id::NodeId;
273 use sqry_core::query::CircularType;
274 use std::sync::Arc;
275
276 #[test]
277 fn cycle_bounds_roundtrip() {
278 let original = CycleBounds {
279 min_depth: 3,
280 max_depth: Some(10),
281 max_results: 50,
282 should_include_self_loops: true,
283 };
284 let bytes = to_allocvec(&original).expect("serialize failed");
285 let decoded: CycleBounds = from_bytes(&bytes).expect("deserialize failed");
286 assert_eq!(decoded, original);
287 }
288
289 #[test]
290 fn cycle_bounds_no_max_depth_roundtrip() {
291 let original = CycleBounds::default();
292 let bytes = to_allocvec(&original).expect("serialize failed");
293 let decoded: CycleBounds = from_bytes(&bytes).expect("deserialize failed");
294 assert_eq!(decoded, original);
295 }
296
297 #[test]
298 fn cycles_key_roundtrip() {
299 let original = CyclesKey {
300 circular_type: CircularType::Calls,
301 bounds: CycleBounds::default(),
302 };
303 let bytes = to_allocvec(&original).expect("serialize failed");
304 let decoded: CyclesKey = from_bytes(&bytes).expect("deserialize failed");
305 assert_eq!(decoded, original);
306 }
307
308 #[test]
309 fn is_in_cycle_key_roundtrip() {
310 let original = IsInCycleKey {
311 node_id: NodeId::new(42, 7),
312 circular_type: CircularType::Imports,
313 bounds: CycleBounds {
314 min_depth: 2,
315 max_depth: None,
316 max_results: 100,
317 should_include_self_loops: false,
318 },
319 };
320 let bytes = to_allocvec(&original).expect("serialize failed");
321 let decoded: IsInCycleKey = from_bytes(&bytes).expect("deserialize failed");
322 assert_eq!(decoded, original);
323 }
324
325 #[test]
326 fn cycles_value_roundtrip() {
327 let original: CyclesValue = Arc::new(vec![vec![NodeId::new(1, 1), NodeId::new(2, 1)]]);
329 let bytes = to_allocvec(&original).expect("serialize failed");
330 let decoded: CyclesValue = from_bytes(&bytes).expect("deserialize failed");
331 assert_eq!(decoded.as_ref(), original.as_ref());
332 }
333
334 #[test]
335 fn is_in_cycle_value_roundtrip() {
336 for val in [true, false] {
338 let bytes = to_allocvec(&val).expect("serialize failed");
339 let decoded: IsInCycleValue = from_bytes(&bytes).expect("deserialize failed");
340 assert_eq!(decoded, val);
341 }
342 }
343}
344
345#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::QueryDbConfig;
353 use sqry_core::graph::unified::concurrent::CodeGraph;
354 use sqry_core::graph::unified::node::kind::NodeKind;
355 use sqry_core::graph::unified::storage::arena::NodeEntry;
356 use std::path::Path;
357
358 fn alloc_fn(graph: &mut CodeGraph, name: &str) -> NodeId {
359 let file = graph.files_mut().register(Path::new("x.rs")).unwrap();
360 let name_id = graph.strings_mut().intern(name).unwrap();
361 graph
362 .nodes_mut()
363 .alloc(NodeEntry::new(NodeKind::Function, name_id, file).with_qualified_name(name_id))
364 .unwrap()
365 }
366
367 fn add_call(graph: &mut CodeGraph, src: NodeId, tgt: NodeId) {
368 let file = graph.nodes().get(src).unwrap().file;
369 graph.edges_mut().add_edge(
370 src,
371 tgt,
372 EdgeKind::Calls {
373 argument_count: 0,
374 is_async: false,
375 },
376 file,
377 );
378 }
379
380 fn build_db(graph: CodeGraph) -> QueryDb {
381 let snapshot = Arc::new(graph.snapshot());
382 let mut db = QueryDb::new(snapshot, QueryDbConfig::default());
383 db.register::<SccQuery>();
384 db.register::<CyclesQuery>();
385 db.register::<IsInCycleQuery>();
386 db
387 }
388
389 #[test]
390 fn cycles_query_detects_two_node_cycle() {
391 let mut graph = CodeGraph::new();
392 let a = alloc_fn(&mut graph, "a");
393 let b = alloc_fn(&mut graph, "b");
394 add_call(&mut graph, a, b);
395 add_call(&mut graph, b, a);
396
397 let db = build_db(graph);
398 let key = CyclesKey {
399 circular_type: CircularType::Calls,
400 bounds: CycleBounds::default(),
401 };
402 let cycles = db.get::<CyclesQuery>(&key);
403 assert_eq!(cycles.len(), 1);
404 assert_eq!(cycles[0].len(), 2);
405 }
406
407 #[test]
408 fn is_in_cycle_self_loop_requires_opt_in() {
409 let mut graph = CodeGraph::new();
410 let a = alloc_fn(&mut graph, "a");
411 add_call(&mut graph, a, a);
412
413 let db = build_db(graph);
414 let default_key = IsInCycleKey {
415 node_id: a,
416 circular_type: CircularType::Calls,
417 bounds: CycleBounds::default(),
418 };
419 assert!(
420 !db.get::<IsInCycleQuery>(&default_key),
421 "self-loop excluded by default"
422 );
423 let opted_key = IsInCycleKey {
424 node_id: a,
425 circular_type: CircularType::Calls,
426 bounds: CycleBounds {
427 should_include_self_loops: true,
428 min_depth: 1,
429 ..CycleBounds::default()
430 },
431 };
432 assert!(
433 db.get::<IsInCycleQuery>(&opted_key),
434 "self-loop reported when opted-in"
435 );
436 }
437
438 #[test]
439 fn cycles_query_respects_max_results() {
440 let mut graph = CodeGraph::new();
441 let a = alloc_fn(&mut graph, "a");
442 let b = alloc_fn(&mut graph, "b");
443 let c = alloc_fn(&mut graph, "c");
444 let d = alloc_fn(&mut graph, "d");
445 add_call(&mut graph, a, b);
447 add_call(&mut graph, b, a);
448 add_call(&mut graph, c, d);
449 add_call(&mut graph, d, c);
450
451 let db = build_db(graph);
452 let key = CyclesKey {
453 circular_type: CircularType::Calls,
454 bounds: CycleBounds {
455 max_results: 1,
456 ..CycleBounds::default()
457 },
458 };
459 let cycles = db.get::<CyclesQuery>(&key);
460 assert_eq!(cycles.len(), 1);
461 }
462}