1use std::path::PathBuf;
23
24use anyhow::Result;
25use clap::Args;
26
27use tldr_core::analysis::references::{
28 find_references, ReferenceKind, ReferencesOptions, ReferencesReport, SearchScope,
29};
30
31use crate::output::{common_path_prefix, strip_prefix_display, OutputFormat, OutputWriter};
32
33#[derive(Debug, Args)]
54pub struct ReferencesArgs {
55 pub symbol: String,
57
58 #[arg(default_value = ".")]
60 pub path: PathBuf,
61
62 #[arg(long = "output", short = 'o', hide = true)]
64 pub output: Option<String>,
65
66 #[arg(long, short = 'l')]
68 pub lang: Option<String>,
69
70 #[arg(long)]
72 pub include_definition: bool,
73
74 #[arg(long, short = 't')]
76 pub kinds: Option<String>,
77
78 #[arg(long, short = 's', default_value = "workspace")]
80 pub scope: String,
81
82 #[arg(long, short = 'n', default_value = "20")]
84 pub limit: usize,
85
86 #[arg(long, short = 'C', default_value = "0")]
88 pub context_lines: usize,
89
90 #[arg(long, default_value = "0.0")]
92 pub min_confidence: f64,
93}
94
95impl ReferencesArgs {
96 pub fn run(&self, cli_format: OutputFormat, quiet: bool) -> Result<()> {
98 if !self.path.exists() {
100 anyhow::bail!(
102 "Path not found: '{}'. Please provide a valid file or directory.",
103 self.path.display()
104 );
105 }
106
107 let output_format = match self.output.as_deref() {
109 Some("text") => OutputFormat::Text,
110 Some("compact") => OutputFormat::Compact,
111 Some("json") => OutputFormat::Json,
112 _ => cli_format,
113 };
114
115 let writer = OutputWriter::new(output_format, quiet);
116
117 let kinds = self.kinds.as_ref().map(|k| parse_kinds(k));
119
120 let scope = parse_scope(&self.scope);
122
123 let options = ReferencesOptions {
125 include_definition: self.include_definition,
126 kinds,
127 scope,
128 language: self.lang.clone(),
129 limit: Some(self.limit),
130 definition_file: None,
131 context_lines: self.context_lines,
132 };
133
134 writer.progress(&format!(
135 "Finding references to '{}' in {}...",
136 self.symbol,
137 self.path.display()
138 ));
139
140 let report = find_references(&self.symbol, &self.path, &options)?;
142
143 let report = filter_by_min_confidence(report, self.min_confidence);
145
146 match output_format {
148 OutputFormat::Text => {
149 let text = format_references_text(&report);
150 writer.write_text(&text)?;
151 }
152 _ => {
153 writer.write(&report)?;
155 }
156 }
157
158 if report.total_references == 0 && !quiet {
160 eprintln!();
161 eprintln!(
162 "No references found for '{}'. Searched {} files.",
163 self.symbol, report.stats.files_searched
164 );
165 eprintln!("Suggestions:");
166 eprintln!(" - Check the symbol spelling");
167 eprintln!(" - Try a different search scope with --scope workspace");
168 eprintln!(" - Verify the path contains relevant source files");
169 }
170
171 Ok(())
172 }
173}
174
175fn parse_kinds(s: &str) -> Vec<ReferenceKind> {
177 s.split(',')
178 .filter_map(|k| match k.trim().to_lowercase().as_str() {
179 "call" => Some(ReferenceKind::Call),
180 "read" => Some(ReferenceKind::Read),
181 "write" => Some(ReferenceKind::Write),
182 "import" => Some(ReferenceKind::Import),
183 "type" => Some(ReferenceKind::Type),
184 "definition" => Some(ReferenceKind::Definition),
185 "other" => Some(ReferenceKind::Other),
186 _ => None,
187 })
188 .collect()
189}
190
191fn filter_by_min_confidence(mut report: ReferencesReport, min_confidence: f64) -> ReferencesReport {
197 if min_confidence > 0.0 {
198 report
199 .references
200 .retain(|r| r.confidence.unwrap_or(0.0) >= min_confidence);
201 report.total_references = report.references.len();
202 }
203 report
204}
205
206fn parse_scope(s: &str) -> SearchScope {
208 match s.to_lowercase().as_str() {
209 "local" => SearchScope::Local,
210 "file" => SearchScope::File,
211 _ => SearchScope::Workspace,
212 }
213}
214
215fn format_references_text(report: &ReferencesReport) -> String {
220 use std::path::Path;
221
222 let mut output = String::new();
223
224 let mut all_paths: Vec<&Path> = report.references.iter().map(|r| r.file.as_path()).collect();
226 if let Some(def) = &report.definition {
227 all_paths.push(def.file.as_path());
228 }
229 let prefix = if all_paths.is_empty() {
230 PathBuf::new()
231 } else {
232 common_path_prefix(&all_paths)
233 };
234
235 output.push_str(&format!(
237 "References to: {} ({})\n",
238 report.symbol,
239 report
240 .definition
241 .as_ref()
242 .map(|d| d.kind.as_str())
243 .unwrap_or("unknown")
244 ));
245 output.push('\n');
246
247 if let Some(def) = &report.definition {
249 output.push_str("Definition:\n");
250 let def_display = strip_prefix_display(&def.file, &prefix);
251 output.push_str(&format!(
252 " {}:{}:{} [{}]\n",
253 def_display,
254 def.line,
255 def.column,
256 def.kind.as_str()
257 ));
258 if let Some(sig) = &def.signature {
259 let sig_clean = sig.replace('\t', " ");
261 output.push_str(&format!(" {}\n", sig_clean.trim()));
262 }
263 output.push('\n');
264 }
265
266 output.push_str(&format!(
268 "References ({} found in {}ms):\n",
269 report.total_references, report.stats.search_time_ms
270 ));
271
272 for r in &report.references {
273 let ref_display = strip_prefix_display(&r.file, &prefix);
274 output.push_str(&format!(
275 " {}:{}:{} [{}]\n",
276 ref_display,
277 r.line,
278 r.column,
279 r.kind.as_str()
280 ));
281 let context_clean = r.context.replace('\t', " ");
283 output.push_str(&format!(" {}\n", context_clean.trim()));
284 output.push('\n');
285 }
286
287 output.push_str(&format!(
289 "Search: {} files, {} candidates -> {} verified\n",
290 report.stats.files_searched,
291 report.stats.candidates_found,
292 report.stats.verified_references
293 ));
294 output.push_str(&format!("Scope: {}\n", report.search_scope.as_str()));
295
296 output
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::path::PathBuf;
303 use tldr_core::analysis::references::{Definition, DefinitionKind, Reference, ReferenceStats};
304
305 fn make_test_report() -> ReferencesReport {
306 ReferencesReport {
307 symbol: "test_func".to_string(),
308 definition: Some(Definition {
309 file: PathBuf::from("src/lib.py"),
310 line: 42,
311 column: 5,
312 kind: DefinitionKind::Function,
313 signature: Some("def test_func(x: int) -> str:".to_string()),
314 }),
315 references: vec![
316 Reference::new(
317 PathBuf::from("src/main.py"),
318 10,
319 8,
320 ReferenceKind::Call,
321 "result = test_func(42)".to_string(),
322 ),
323 Reference::new(
324 PathBuf::from("tests/test_lib.py"),
325 25,
326 12,
327 ReferenceKind::Import,
328 "from src.lib import test_func".to_string(),
329 ),
330 ],
331 total_references: 2,
332 search_scope: SearchScope::Workspace,
333 stats: ReferenceStats {
334 files_searched: 10,
335 candidates_found: 5,
336 verified_references: 2,
337 search_time_ms: 127,
338 },
339 }
340 }
341
342 #[test]
343 fn test_format_references_text() {
344 let report = make_test_report();
345 let text = format_references_text(&report);
346
347 assert!(text.contains("References to: test_func (function)"));
348 assert!(text.contains("Definition:"));
349 assert!(text.contains("src/lib.py:42:5 [function]"));
350 assert!(text.contains("def test_func(x: int) -> str:"));
351 assert!(text.contains("References (2 found in 127ms)"));
352 assert!(text.contains("src/main.py:10:8 [call]"));
353 assert!(text.contains("tests/test_lib.py:25:12 [import]"));
354 assert!(text.contains("Search: 10 files, 5 candidates -> 2 verified"));
355 assert!(text.contains("Scope: workspace"));
356 }
357
358 #[test]
359 fn test_parse_kinds() {
360 let kinds = parse_kinds("call,import,type");
361 assert_eq!(kinds.len(), 3);
362 assert!(kinds.contains(&ReferenceKind::Call));
363 assert!(kinds.contains(&ReferenceKind::Import));
364 assert!(kinds.contains(&ReferenceKind::Type));
365 }
366
367 #[test]
368 fn test_parse_kinds_case_insensitive() {
369 let kinds = parse_kinds("CALL,Read,WRITE");
370 assert_eq!(kinds.len(), 3);
371 assert!(kinds.contains(&ReferenceKind::Call));
372 assert!(kinds.contains(&ReferenceKind::Read));
373 assert!(kinds.contains(&ReferenceKind::Write));
374 }
375
376 #[test]
377 fn test_parse_scope() {
378 assert_eq!(parse_scope("local"), SearchScope::Local);
379 assert_eq!(parse_scope("file"), SearchScope::File);
380 assert_eq!(parse_scope("workspace"), SearchScope::Workspace);
381 assert_eq!(parse_scope("WORKSPACE"), SearchScope::Workspace);
382 assert_eq!(parse_scope("unknown"), SearchScope::Workspace); }
384
385 #[test]
386 fn test_tab_expansion_in_context() {
387 let mut report = make_test_report();
388 report.references[0] = Reference::new(
389 PathBuf::from("src/main.py"),
390 10,
391 8,
392 ReferenceKind::Call,
393 "\tresult = test_func(42)".to_string(), );
395
396 let text = format_references_text(&report);
397 assert!(text.contains(" result = test_func(42)"));
399 assert!(!text.contains('\t'));
400 }
401
402 #[test]
403 fn test_text_formatter_strips_common_path_prefix() {
404 let mut report = make_test_report();
406 report.definition = Some(Definition {
407 file: PathBuf::from("/home/user/project/src/lib.py"),
408 line: 42,
409 column: 5,
410 kind: DefinitionKind::Function,
411 signature: Some("def test_func(x: int) -> str:".to_string()),
412 });
413 report.references = vec![
414 Reference::new(
415 PathBuf::from("/home/user/project/src/main.py"),
416 10,
417 8,
418 ReferenceKind::Call,
419 "result = test_func(42)".to_string(),
420 ),
421 Reference::new(
422 PathBuf::from("/home/user/project/tests/test_lib.py"),
423 25,
424 12,
425 ReferenceKind::Import,
426 "from src.lib import test_func".to_string(),
427 ),
428 ];
429
430 let text = format_references_text(&report);
431
432 assert!(
434 !text.contains("/home/user/project/"),
435 "Text should not contain the absolute common prefix. Got:\n{}",
436 text
437 );
438 assert!(text.contains("src/lib.py:42:5"));
440 assert!(text.contains("src/main.py:10:8"));
441 assert!(text.contains("tests/test_lib.py:25:12"));
442 }
443
444 #[test]
445 fn test_default_limit_is_20() {
446 use clap::Parser;
448
449 #[derive(Parser)]
450 struct Wrapper {
451 #[command(flatten)]
452 refs: ReferencesArgs,
453 }
454
455 let wrapper = Wrapper::parse_from(["test", "my_symbol"]);
456 assert_eq!(
457 wrapper.refs.limit, 20,
458 "Default limit should be 20, got {}",
459 wrapper.refs.limit
460 );
461 }
462
463 #[test]
464 fn test_min_confidence_filtering() {
465 let report = ReferencesReport {
467 symbol: "test_func".to_string(),
468 definition: None,
469 references: vec![
470 Reference::with_details(
471 PathBuf::from("src/a.py"),
472 10,
473 1,
474 10,
475 ReferenceKind::Call,
476 "test_func()".to_string(),
477 1.0, ),
479 Reference::with_details(
480 PathBuf::from("src/b.py"),
481 20,
482 1,
483 10,
484 ReferenceKind::Call,
485 "test_func()".to_string(),
486 0.5, ),
488 Reference::with_details(
489 PathBuf::from("src/c.py"),
490 30,
491 1,
492 10,
493 ReferenceKind::Call,
494 "test_func()".to_string(),
495 0.3, ),
497 ],
498 total_references: 3,
499 search_scope: SearchScope::Workspace,
500 stats: ReferenceStats {
501 files_searched: 5,
502 candidates_found: 3,
503 verified_references: 3,
504 search_time_ms: 50,
505 },
506 };
507
508 let filtered = filter_by_min_confidence(report.clone(), 0.5);
510 assert_eq!(
511 filtered.references.len(),
512 2,
513 "Should have 2 refs with confidence >= 0.5, got {}",
514 filtered.references.len()
515 );
516 assert_eq!(
517 filtered.total_references, 2,
518 "total_references should be updated after filtering"
519 );
520
521 let filtered_high = filter_by_min_confidence(report.clone(), 1.0);
523 assert_eq!(filtered_high.references.len(), 1);
524 assert_eq!(filtered_high.total_references, 1);
525
526 let filtered_none = filter_by_min_confidence(report, 0.0);
528 assert_eq!(filtered_none.references.len(), 3);
529 assert_eq!(filtered_none.total_references, 3);
530 }
531
532 #[test]
533 fn test_kinds_short_flag_t() {
534 use clap::Parser;
535
536 #[derive(Parser)]
537 struct Wrapper {
538 #[command(flatten)]
539 refs: ReferencesArgs,
540 }
541
542 let wrapper = Wrapper::parse_from(["test", "my_symbol", ".", "-t", "call,import"]);
543 assert_eq!(
544 wrapper.refs.kinds.as_deref(),
545 Some("call,import"),
546 "--kinds should be settable via -t short flag"
547 );
548 }
549}