sqry_cli/commands/
unused.rs1use crate::args::Cli;
17use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
18use crate::index_discovery::find_nearest_index;
19use crate::output::OutputStreams;
20use anyhow::{Context, Result};
21use serde::Serialize;
22use sqry_core::graph::unified::concurrent::GraphSnapshot;
23use sqry_core::graph::unified::node::id::NodeId;
24use sqry_core::query::UnusedScope;
25use std::collections::HashMap;
26use std::path::Path;
27use std::sync::Arc;
28
29#[derive(Debug, Serialize)]
31struct UnusedSymbol {
32 name: String,
33 qualified_name: String,
34 kind: String,
35 file: String,
36 line: u32,
37 language: String,
38 visibility: String,
39}
40
41#[derive(Debug, Serialize)]
43struct UnusedByFile {
44 file: String,
45 count: usize,
46 symbols: Vec<UnusedSymbol>,
47}
48
49pub fn run_unused(
78 cli: &Cli,
79 path: Option<&str>,
80 scope: &str,
81 lang_filter: Option<&str>,
82 kind_filter: Option<&str>,
83 max_results: usize,
84) -> Result<()> {
85 let mut streams = OutputStreams::new();
86
87 let unused_scope = UnusedScope::try_parse(scope).with_context(|| {
89 format!("Invalid scope: {scope}. Use: public, private, function, struct, all")
90 })?;
91
92 let search_path = path.map_or_else(
94 || std::env::current_dir().unwrap_or_default(),
95 std::path::PathBuf::from,
96 );
97
98 let index_location = find_nearest_index(&search_path);
99 let Some(ref loc) = index_location else {
100 streams
101 .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
102 return Ok(());
103 };
104
105 let config = GraphLoadConfig::default();
107 let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli, no_op_reporter())
108 .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
109
110 let snapshot = std::sync::Arc::new(graph.snapshot());
116 let unused_ids =
117 boundary_filtered_unused_ids(&snapshot, &loc.index_root, unused_scope, max_results);
118
119 let strings = snapshot.strings();
120 let files = snapshot.files();
121
122 let mut unused_symbols: Vec<UnusedSymbol> = Vec::new();
125 for &node_id in &unused_ids {
126 if unused_symbols.len() >= max_results {
127 break;
128 }
129
130 let Some(entry) = snapshot.nodes().get(node_id) else {
131 continue;
132 };
133
134 let language = files
135 .language_for_file(entry.file)
136 .map_or_else(|| "Unknown".to_string(), |l| l.to_string());
137
138 if let Some(lang) = lang_filter
139 && !language.to_lowercase().contains(&lang.to_lowercase())
140 {
141 continue;
142 }
143
144 if let Some(kind) = kind_filter {
145 let kind_str = format!("{:?}", entry.kind).to_lowercase();
146 if !kind_str.contains(&kind.to_lowercase()) {
147 continue;
148 }
149 }
150
151 let name = strings
152 .resolve(entry.name)
153 .map(|s| s.to_string())
154 .unwrap_or_default();
155
156 let qualified_name = entry
157 .qualified_name
158 .and_then(|id| strings.resolve(id))
159 .map_or_else(|| name.clone(), |s| s.to_string());
160
161 let file_path = files
162 .resolve(entry.file)
163 .map(|p| p.display().to_string())
164 .unwrap_or_default();
165
166 let visibility = entry
167 .visibility
168 .and_then(|id| strings.resolve(id))
169 .map_or_else(|| "unknown".to_string(), |s| s.to_string());
170
171 unused_symbols.push(UnusedSymbol {
172 name,
173 qualified_name,
174 kind: format!("{:?}", entry.kind),
175 file: file_path,
176 line: entry.start_line,
177 language,
178 visibility,
179 });
180 }
181
182 let mut by_file: HashMap<String, Vec<UnusedSymbol>> = HashMap::new();
184 for sym in unused_symbols {
185 by_file.entry(sym.file.clone()).or_default().push(sym);
186 }
187
188 let mut grouped: Vec<UnusedByFile> = by_file
189 .into_iter()
190 .map(|(file, symbols)| UnusedByFile {
191 file,
192 count: symbols.len(),
193 symbols,
194 })
195 .collect();
196
197 grouped.sort_by(|a, b| a.file.cmp(&b.file));
199
200 if cli.json {
202 let json = serde_json::to_string_pretty(&grouped).context("Failed to serialize to JSON")?;
203 streams.write_result(&json)?;
204 } else {
205 let output = format_unused_text(&grouped, unused_scope);
206 streams.write_result(&output)?;
207 }
208
209 Ok(())
210}
211
212fn boundary_filtered_unused_ids(
213 snapshot: &Arc<GraphSnapshot>,
214 index_root: &Path,
215 unused_scope: UnusedScope,
216 max_results: usize,
217) -> Vec<NodeId> {
218 let db = sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(snapshot), index_root);
220 let candidate_cap = snapshot.nodes().len().max(max_results);
221 let key = sqry_db::queries::UnusedKey {
222 scope: unused_scope,
223 max_results: candidate_cap,
224 };
225 let raw_unused_ids = db.get::<sqry_db::queries::UnusedQuery>(&key);
226 sqry_db::queries::unused_post_filter::apply_binding_plane_post_filter(
227 &raw_unused_ids,
228 snapshot,
229 &db,
230 )
231}
232
233fn format_unused_text(groups: &[UnusedByFile], scope: UnusedScope) -> String {
235 let mut lines = Vec::new();
236
237 let total: usize = groups.iter().map(|g| g.count).sum();
238 let scope_name = match scope {
239 UnusedScope::Public => "public",
240 UnusedScope::Private => "private",
241 UnusedScope::Function => "function",
242 UnusedScope::Struct => "struct",
243 UnusedScope::All => "all",
244 };
245
246 lines.push(format!(
247 "Found {total} unused symbols (scope: {scope_name})"
248 ));
249 lines.push(String::new());
250
251 for group in groups {
252 lines.push(format!("{} ({} unused):", group.file, group.count));
253 for sym in &group.symbols {
254 lines.push(format!(" {} [{}] line {}", sym.name, sym.kind, sym.line));
255 }
256 lines.push(String::new());
257 }
258
259 if groups.is_empty() {
260 lines.push("No unused symbols found.".to_string());
261 }
262
263 lines.join("\n")
264}