sqry_cli/commands/
similar.rs1use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result, anyhow};
10use serde::Serialize;
11
12#[derive(Debug, Serialize)]
14struct SimilarOutput {
15 reference: NodeRef,
17 similar: Vec<SimilarSymbol>,
19 stats: SimilarStats,
21}
22
23#[derive(Debug, Serialize)]
24struct NodeRef {
25 name: String,
26 qualified_name: String,
27 kind: String,
28 file: String,
29 line: u32,
30}
31
32#[derive(Debug, Serialize)]
33struct SimilarSymbol {
34 name: String,
35 qualified_name: String,
36 kind: String,
37 file: String,
38 line: u32,
39 similarity: f64,
41}
42
43#[derive(Debug, Serialize)]
44struct SimilarStats {
45 total_found: usize,
46 threshold: f64,
47}
48
49#[allow(clippy::too_many_lines)]
55pub fn run_similar(
56 cli: &Cli,
57 file_path: &str,
58 symbol_name: &str,
59 path: Option<&str>,
60 threshold: f64,
61 max_results: usize,
62) -> Result<()> {
63 let mut streams = OutputStreams::new();
64
65 let search_path = path.map_or_else(
67 || std::env::current_dir().unwrap_or_default(),
68 std::path::PathBuf::from,
69 );
70
71 let index_location = find_nearest_index(&search_path);
72 let Some(ref loc) = index_location else {
73 streams
74 .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
75 return Ok(());
76 };
77
78 let config = GraphLoadConfig::default();
80 let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli, no_op_reporter())
81 .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
82
83 let strings = graph.strings();
84 let files_registry = graph.files();
85 let target_file = std::path::Path::new(file_path);
86
87 let (ref_node_id, ref_entry) = graph
89 .nodes()
90 .iter()
91 .find(|(_, entry)| {
92 let sym_file = files_registry.resolve(entry.file);
94 let file_matches = sym_file
95 .as_ref()
96 .is_some_and(|p| p.as_ref() == target_file || p.ends_with(file_path));
97
98 if !file_matches {
99 return false;
100 }
101
102 let name = strings.resolve(entry.name);
104 let qname = entry.qualified_name.and_then(|id| strings.resolve(id));
105
106 name.is_some_and(|n| n.as_ref() == symbol_name)
107 || qname.is_some_and(|q| q.as_ref() == symbol_name)
108 })
109 .ok_or_else(|| anyhow!("Symbol '{symbol_name}' not found in '{file_path}'"))?;
110
111 let ref_name = strings
112 .resolve(ref_entry.name)
113 .map(|s| s.to_string())
114 .unwrap_or_default();
115
116 let ref_qualified_name = ref_entry
117 .qualified_name
118 .and_then(|id| strings.resolve(id))
119 .map_or_else(|| ref_name.clone(), |s| s.to_string());
120
121 let ref_file_path = files_registry
122 .resolve(ref_entry.file)
123 .map(|p| p.display().to_string())
124 .unwrap_or_default();
125
126 let reference = NodeRef {
127 name: ref_name.clone(),
128 qualified_name: ref_qualified_name.clone(),
129 kind: format!("{:?}", ref_entry.kind),
130 file: ref_file_path,
131 line: ref_entry.start_line,
132 };
133
134 let mut similar_symbols: Vec<_> = graph
136 .nodes()
137 .iter()
138 .filter(|(node_id, entry)| {
139 if *node_id == ref_node_id {
141 return false;
142 }
143 if entry.kind != ref_entry.kind {
145 return false;
146 }
147 true
148 })
149 .filter_map(|(_, entry)| {
150 let name = strings.resolve(entry.name)?;
151 let similarity = compute_similarity(&ref_name, &name);
152
153 if similarity >= threshold {
154 let file_path = files_registry
155 .resolve(entry.file)
156 .map(|p| p.display().to_string())
157 .unwrap_or_default();
158
159 let qualified_name = entry
160 .qualified_name
161 .and_then(|id| strings.resolve(id))
162 .map_or_else(|| name.to_string(), |s| s.to_string());
163
164 Some(SimilarSymbol {
165 name: name.to_string(),
166 qualified_name,
167 kind: format!("{:?}", entry.kind),
168 file: file_path,
169 line: entry.start_line,
170 similarity,
171 })
172 } else {
173 None
174 }
175 })
176 .collect();
177
178 similar_symbols.sort_by(|a, b| {
180 b.similarity
181 .partial_cmp(&a.similarity)
182 .unwrap_or(std::cmp::Ordering::Equal)
183 });
184 similar_symbols.truncate(max_results);
185
186 let stats = SimilarStats {
187 total_found: similar_symbols.len(),
188 threshold,
189 };
190
191 let output = SimilarOutput {
192 reference,
193 similar: similar_symbols,
194 stats,
195 };
196
197 if cli.json {
199 let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
200 streams.write_result(&json)?;
201 } else {
202 let text = format_similar_text(&output);
203 streams.write_result(&text)?;
204 }
205
206 Ok(())
207}
208
209fn compute_similarity(a: &str, b: &str) -> f64 {
211 let a_lower = a.to_lowercase();
212 let b_lower = b.to_lowercase();
213
214 if a_lower == b_lower {
215 return 1.0;
216 }
217
218 let distance = levenshtein_distance(&a_lower, &b_lower);
219 let max_len = a_lower.len().max(b_lower.len());
220
221 if max_len == 0 {
222 return 1.0;
223 }
224
225 let distance_f = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
226 let max_len_f = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
227 1.0 - (distance_f / max_len_f)
228}
229
230fn levenshtein_distance(a: &str, b: &str) -> usize {
232 let a_chars: Vec<char> = a.chars().collect();
233 let b_chars: Vec<char> = b.chars().collect();
234 let a_len = a_chars.len();
235 let b_len = b_chars.len();
236
237 if a_len == 0 {
238 return b_len;
239 }
240 if b_len == 0 {
241 return a_len;
242 }
243
244 let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1];
245
246 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
247 row[0] = i;
248 }
249 for (j, val) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
250 *val = j;
251 }
252
253 for i in 1..=a_len {
254 for j in 1..=b_len {
255 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
256 matrix[i][j] = (matrix[i - 1][j] + 1)
257 .min(matrix[i][j - 1] + 1)
258 .min(matrix[i - 1][j - 1] + cost);
259 }
260 }
261
262 matrix[a_len][b_len]
263}
264
265fn format_similar_text(output: &SimilarOutput) -> String {
266 let mut lines = Vec::new();
267
268 lines.push(format!(
269 "Finding symbols similar to: {} [{}]",
270 output.reference.qualified_name, output.reference.kind
271 ));
272 lines.push(format!(
273 "Threshold: {:.0}%, Found: {}",
274 output.stats.threshold * 100.0,
275 output.stats.total_found
276 ));
277 lines.push(String::new());
278
279 if output.similar.is_empty() {
280 lines.push("No similar symbols found.".to_string());
281 } else {
282 lines.push("Similar symbols:".to_string());
283 for sym in &output.similar {
284 lines.push(format!(
285 " {} ({:.0}% similar)",
286 sym.qualified_name,
287 sym.similarity * 100.0
288 ));
289 lines.push(format!(" {}:{}", sym.file, sym.line));
290 }
291 }
292
293 lines.join("\n")
294}