1use std::collections::HashSet;
38use std::sync::Arc;
39
40use sqry_core::graph::unified::concurrent::GraphSnapshot;
41use sqry_core::graph::unified::edge::kind::EdgeKind;
42use sqry_core::graph::unified::node::id::NodeId;
43use sqry_core::graph::unified::node::kind::NodeKind;
44use sqry_core::graph::unified::storage::arena::NodeEntry;
45use sqry_core::query::UnusedScope;
46
47use crate::QueryDb;
48use crate::dependency::record_file_dep;
49use crate::query::DerivedQuery;
50
51pub struct EntryPointsQuery;
64
65impl DerivedQuery for EntryPointsQuery {
66 type Key = ();
67 type Value = Arc<HashSet<NodeId>>;
68 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::ENTRY_POINTS;
69 const TRACKS_EDGE_REVISION: bool = true;
70 const TRACKS_METADATA_REVISION: bool = true;
71
72 fn execute(_key: &(), _db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<HashSet<NodeId>> {
73 for (fid, _) in snapshot.file_segments().iter() {
74 record_file_dep(fid);
75 }
76 let mut entry_points = HashSet::new();
77 for (node_id, entry) in snapshot.nodes().iter() {
78 if entry.is_unified_loser() {
83 continue;
84 }
85 if is_entry_point(snapshot, entry) {
86 entry_points.insert(node_id);
87 }
88 }
89 Arc::new(entry_points)
90 }
91}
92
93fn is_entry_point(snapshot: &GraphSnapshot, entry: &NodeEntry) -> bool {
94 let is_public = entry
95 .visibility
96 .and_then(|id| snapshot.strings().resolve(id))
97 .is_some_and(|v| {
98 let s = v.as_ref();
99 s == "public" || s == "pub"
100 });
101 let is_main_or_test = snapshot.strings().resolve(entry.name).is_some_and(|name| {
102 let n = name.as_ref();
103 n == "main" || n.starts_with("test_") || n.ends_with("_test")
104 });
105 let is_export = matches!(entry.kind, NodeKind::Export);
106 let is_test_node = matches!(entry.kind, NodeKind::Test);
107 is_public || is_main_or_test || is_export || is_test_node
108}
109
110pub struct ReachableFromEntryPointsQuery;
120
121impl DerivedQuery for ReachableFromEntryPointsQuery {
122 type Key = ();
123 type Value = Arc<HashSet<NodeId>>;
124 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::REACHABLE_FROM_ENTRY_POINTS;
125 const TRACKS_EDGE_REVISION: bool = true;
126 const TRACKS_METADATA_REVISION: bool = true;
127
128 fn execute(_key: &(), db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<HashSet<NodeId>> {
129 for (fid, _) in snapshot.file_segments().iter() {
130 record_file_dep(fid);
131 }
132 let entry_points = db.get::<EntryPointsQuery>(&());
133 let mut reachable: HashSet<NodeId> = entry_points.as_ref().clone();
134 let mut worklist: Vec<NodeId> = reachable.iter().copied().collect();
135 while let Some(node_id) = worklist.pop() {
136 for edge in &snapshot.edges().edges_from(node_id) {
137 if is_reachability_edge(&edge.kind) && reachable.insert(edge.target) {
138 worklist.push(edge.target);
139 }
140 }
141 }
142 Arc::new(reachable)
143 }
144}
145
146fn is_reachability_edge(kind: &EdgeKind) -> bool {
147 matches!(
148 kind,
149 EdgeKind::Calls { .. }
150 | EdgeKind::References
151 | EdgeKind::Imports { .. }
152 | EdgeKind::Inherits
153 | EdgeKind::Implements
154 | EdgeKind::TypeOf { .. }
155 )
156}
157
158pub type UnusedValue = Arc<Vec<NodeId>>;
170
171pub type IsNodeUnusedValue = bool;
173
174#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
176pub struct UnusedKey {
177 pub scope: UnusedScope,
179 pub max_results: usize,
181}
182
183pub struct UnusedQuery;
190
191impl DerivedQuery for UnusedQuery {
192 type Key = UnusedKey;
193 type Value = Arc<Vec<NodeId>>;
194 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::UNUSED;
195 const TRACKS_EDGE_REVISION: bool = true;
196 const TRACKS_METADATA_REVISION: bool = true;
197
198 fn execute(key: &UnusedKey, db: &QueryDb, snapshot: &GraphSnapshot) -> Arc<Vec<NodeId>> {
199 for (fid, _) in snapshot.file_segments().iter() {
200 record_file_dep(fid);
201 }
202 let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
203 let mut unused: Vec<NodeId> = Vec::new();
204 for (node_id, entry) in snapshot.nodes().iter() {
205 if unused.len() >= key.max_results {
206 break;
207 }
208 if entry.is_unified_loser() {
211 continue;
212 }
213 if !scope_matches(entry, snapshot, key.scope) {
214 continue;
215 }
216 if is_always_entry_point(snapshot, entry) {
217 continue;
218 }
219 if !reachable.contains(&node_id) {
220 unused.push(node_id);
221 }
222 }
223 unused.sort_unstable_by_key(|id| (id.index(), id.generation()));
224 Arc::new(unused)
225 }
226}
227
228fn scope_matches(entry: &NodeEntry, snapshot: &GraphSnapshot, scope: UnusedScope) -> bool {
229 match scope {
230 UnusedScope::All => true,
231 UnusedScope::Public => entry
232 .visibility
233 .and_then(|id| snapshot.strings().resolve(id))
234 .is_some_and(|v| {
235 let s = v.as_ref();
236 s == "public" || s == "pub"
237 }),
238 UnusedScope::Private => {
239 let vis = entry
240 .visibility
241 .and_then(|id| snapshot.strings().resolve(id));
242 vis.is_none()
243 || vis.is_some_and(|v| {
244 let s = v.as_ref();
245 s != "public" && s != "pub"
246 })
247 }
248 UnusedScope::Function => matches!(entry.kind, NodeKind::Function | NodeKind::Method),
249 UnusedScope::Struct => matches!(entry.kind, NodeKind::Struct | NodeKind::Class),
250 }
251}
252
253fn is_always_entry_point(snapshot: &GraphSnapshot, entry: &NodeEntry) -> bool {
257 let is_main_or_test = snapshot.strings().resolve(entry.name).is_some_and(|name| {
258 let n = name.as_ref();
259 n == "main" || n.starts_with("test_") || n.ends_with("_test")
260 });
261 let is_export = matches!(entry.kind, NodeKind::Export);
262 let is_test_node = matches!(entry.kind, NodeKind::Test);
263 is_main_or_test || is_export || is_test_node
264}
265
266#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
272pub struct IsNodeUnusedKey {
273 pub node_id: NodeId,
275 pub scope: UnusedScope,
277}
278
279pub struct IsNodeUnusedQuery;
283
284impl DerivedQuery for IsNodeUnusedQuery {
285 type Key = IsNodeUnusedKey;
286 type Value = bool;
287 const QUERY_TYPE_ID: u32 = crate::queries::type_ids::IS_NODE_UNUSED;
288 const TRACKS_EDGE_REVISION: bool = true;
289 const TRACKS_METADATA_REVISION: bool = true;
290
291 fn execute(key: &IsNodeUnusedKey, db: &QueryDb, snapshot: &GraphSnapshot) -> bool {
292 let Some(entry) = snapshot.nodes().get(key.node_id) else {
293 return false;
294 };
295 record_file_dep(entry.file);
296 if !scope_matches(entry, snapshot, key.scope) {
297 return false;
298 }
299 if is_always_entry_point(snapshot, entry) {
300 return false;
301 }
302 let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
303 !reachable.contains(&key.node_id)
304 }
305}
306
307#[cfg(test)]
312mod serde_roundtrip {
313 use super::*;
314 use postcard::{from_bytes, to_allocvec};
315 use sqry_core::graph::unified::node::id::NodeId;
316 use sqry_core::query::UnusedScope;
317
318 #[test]
319 fn unused_key_roundtrip() {
320 let original = UnusedKey {
321 scope: UnusedScope::Public,
322 max_results: 250,
323 };
324 let bytes = to_allocvec(&original).expect("serialize failed");
325 let decoded: UnusedKey = from_bytes(&bytes).expect("deserialize failed");
326 assert_eq!(decoded, original);
327 }
328
329 #[test]
330 fn unused_key_all_scopes_roundtrip() {
331 for scope in [
332 UnusedScope::All,
333 UnusedScope::Public,
334 UnusedScope::Private,
335 UnusedScope::Function,
336 UnusedScope::Struct,
337 ] {
338 let original = UnusedKey {
339 scope,
340 max_results: 100,
341 };
342 let bytes = to_allocvec(&original).expect("serialize failed");
343 let decoded: UnusedKey = from_bytes(&bytes).expect("deserialize failed");
344 assert_eq!(decoded, original, "scope={scope:?}");
345 }
346 }
347
348 #[test]
349 fn is_node_unused_key_roundtrip() {
350 let original = IsNodeUnusedKey {
351 node_id: NodeId::new(99, 3),
352 scope: UnusedScope::Function,
353 };
354 let bytes = to_allocvec(&original).expect("serialize failed");
355 let decoded: IsNodeUnusedKey = from_bytes(&bytes).expect("deserialize failed");
356 assert_eq!(decoded, original);
357 }
358
359 #[test]
360 fn unused_value_roundtrip() {
361 let original: UnusedValue = Arc::new(vec![
363 NodeId::new(1, 1),
364 NodeId::new(2, 1),
365 NodeId::new(3, 1),
366 ]);
367 let bytes = to_allocvec(&original).expect("serialize failed");
368 let decoded: UnusedValue = from_bytes(&bytes).expect("deserialize failed");
369 assert_eq!(decoded.as_ref(), original.as_ref());
370 }
371
372 #[test]
373 fn is_node_unused_value_roundtrip() {
374 for val in [true, false] {
376 let bytes = to_allocvec(&val).expect("serialize failed");
377 let decoded: IsNodeUnusedValue = from_bytes(&bytes).expect("deserialize failed");
378 assert_eq!(decoded, val);
379 }
380 }
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::QueryDbConfig;
391 use sqry_core::graph::unified::concurrent::CodeGraph;
392 use std::path::Path;
393
394 fn alloc_fn_with_vis(graph: &mut CodeGraph, name: &str, vis: Option<&str>) -> NodeId {
395 let file = graph.files_mut().register(Path::new("main.rs")).unwrap();
396 let name_id = graph.strings_mut().intern(name).unwrap();
397 let mut entry =
398 NodeEntry::new(NodeKind::Function, name_id, file).with_qualified_name(name_id);
399 if let Some(v) = vis {
400 let vid = graph.strings_mut().intern(v).unwrap();
401 entry = entry.with_visibility(vid);
402 }
403 graph.nodes_mut().alloc(entry).unwrap()
404 }
405
406 fn add_call(graph: &mut CodeGraph, src: NodeId, tgt: NodeId) {
407 let file = graph.nodes().get(src).unwrap().file;
408 graph.edges_mut().add_edge(
409 src,
410 tgt,
411 EdgeKind::Calls {
412 argument_count: 0,
413 is_async: false,
414 },
415 file,
416 );
417 }
418
419 fn build_db(graph: CodeGraph) -> QueryDb {
420 let snapshot = Arc::new(graph.snapshot());
421 let mut db = QueryDb::new(snapshot, QueryDbConfig::default());
422 db.register::<EntryPointsQuery>();
423 db.register::<ReachableFromEntryPointsQuery>();
424 db.register::<UnusedQuery>();
425 db.register::<IsNodeUnusedQuery>();
426 db
427 }
428
429 #[test]
430 fn entry_points_include_main_test_public_export() {
431 let mut graph = CodeGraph::new();
432 let main = alloc_fn_with_vis(&mut graph, "main", None);
433 let pub_fn = alloc_fn_with_vis(&mut graph, "exported_api", Some("public"));
434 let test_fn = alloc_fn_with_vis(&mut graph, "test_thing", None);
435 let private_fn = alloc_fn_with_vis(&mut graph, "helper", None);
436
437 let db = build_db(graph);
438 let entry_points = db.get::<EntryPointsQuery>(&());
439 assert!(entry_points.contains(&main));
440 assert!(entry_points.contains(&pub_fn));
441 assert!(entry_points.contains(&test_fn));
442 assert!(!entry_points.contains(&private_fn));
443 }
444
445 #[test]
446 fn unused_query_reports_unreachable_private_symbols() {
447 let mut graph = CodeGraph::new();
448 let main = alloc_fn_with_vis(&mut graph, "main", None);
449 let used = alloc_fn_with_vis(&mut graph, "used_helper", None);
450 let unused = alloc_fn_with_vis(&mut graph, "unused_helper", None);
451 add_call(&mut graph, main, used);
452
453 let db = build_db(graph);
454 let key = UnusedKey {
455 scope: UnusedScope::All,
456 max_results: 100,
457 };
458 let result = db.get::<UnusedQuery>(&key);
459 assert_eq!(result.as_ref(), &vec![unused]);
460 }
461
462 #[test]
463 fn is_node_unused_honours_scope_filter() {
464 let mut graph = CodeGraph::new();
465 let main = alloc_fn_with_vis(&mut graph, "main", None);
466 let unused = alloc_fn_with_vis(&mut graph, "ghost", None);
467 let _ = main;
468
469 let db = build_db(graph);
470 let all_key = IsNodeUnusedKey {
471 node_id: unused,
472 scope: UnusedScope::All,
473 };
474 assert!(db.get::<IsNodeUnusedQuery>(&all_key));
475 let struct_key = IsNodeUnusedKey {
476 node_id: unused,
477 scope: UnusedScope::Struct,
478 };
479 assert!(!db.get::<IsNodeUnusedQuery>(&struct_key));
480 }
481
482 #[test]
483 fn reachable_set_follows_reachability_edges() {
484 let mut graph = CodeGraph::new();
485 let main = alloc_fn_with_vis(&mut graph, "main", None);
486 let mid = alloc_fn_with_vis(&mut graph, "mid", None);
487 let deep = alloc_fn_with_vis(&mut graph, "deep", None);
488 add_call(&mut graph, main, mid);
489 add_call(&mut graph, mid, deep);
490
491 let db = build_db(graph);
492 let reachable = db.get::<ReachableFromEntryPointsQuery>(&());
493 assert!(reachable.contains(&main));
494 assert!(reachable.contains(&mid));
495 assert!(reachable.contains(&deep));
496 }
497
498 #[test]
505 fn entry_points_and_unused_exclude_unified_losers() {
506 use sqry_core::graph::unified::string::StringId;
507
508 let mut graph = CodeGraph::new();
509 let main = alloc_fn_with_vis(&mut graph, "main", None);
510 let _ = main;
511
512 let file_a = graph.files_mut().register(Path::new("a.rs")).unwrap();
515 let file_b = graph.files_mut().register(Path::new("b.rs")).unwrap();
516 let name_id = graph.strings_mut().intern("shared").unwrap();
517 let qn_id = graph.strings_mut().intern("mod::shared").unwrap();
518 let pub_id = graph.strings_mut().intern("public").unwrap();
519
520 let (winner, loser): (NodeId, NodeId) = {
521 let arena = graph.nodes_mut();
522 let mut w = NodeEntry::new(NodeKind::Function, name_id, file_a).with_visibility(pub_id);
523 w.qualified_name = Some(qn_id);
524 let w_id = arena.alloc(w).unwrap();
525 let mut l = NodeEntry::new(NodeKind::Function, name_id, file_b).with_visibility(pub_id);
526 l.qualified_name = Some(qn_id);
527 let l_id = arena.alloc(l).unwrap();
528 (w_id, l_id)
529 };
530 graph.files_mut().record_node(file_a, winner);
531 graph.files_mut().record_node(file_b, loser);
532
533 {
537 let arena = graph.nodes_mut();
538 let l_mut = arena.get_mut(loser).expect("loser present");
539 l_mut.name = StringId::INVALID;
540 l_mut.qualified_name = None;
541 l_mut.signature = None;
542 l_mut.body_hash = None;
543 l_mut.doc = None;
544 l_mut.visibility = None;
545 }
546 graph.rebuild_indices();
547
548 let db = build_db(graph);
549 let entry_points = db.get::<EntryPointsQuery>(&());
550 assert!(
551 !entry_points.contains(&loser),
552 "EntryPointsQuery leaked unified loser"
553 );
554 assert!(
559 entry_points.contains(&winner),
560 "EntryPointsQuery must still see the winner"
561 );
562
563 let unused_key = UnusedKey {
564 scope: UnusedScope::All,
565 max_results: 100,
566 };
567 let unused = db.get::<UnusedQuery>(&unused_key);
568 assert!(!unused.contains(&loser), "UnusedQuery leaked unified loser");
569 }
570}