1use std::path::{Path, PathBuf};
23
24use anyhow::Result;
25use clap::Args;
26
27use tldr_core::analysis::references::{
28 find_references, Definition, ReferenceKind, ReferencesOptions, ReferencesReport, SearchScope,
29};
30use tldr_core::Language;
31
32use crate::output::{common_path_prefix, strip_prefix_display, OutputFormat, OutputWriter};
33
34#[derive(Debug, Args)]
55pub struct ReferencesArgs {
56 pub symbol: String,
58
59 #[arg(default_value = ".")]
61 pub path: PathBuf,
62
63 #[arg(long = "output", short = 'o', hide = true)]
65 pub output: Option<String>,
66
67 #[arg(long)]
69 pub include_definition: bool,
70
71 #[arg(long, short = 't')]
73 pub kinds: Option<String>,
74
75 #[arg(long, short = 's', default_value = "workspace")]
77 pub scope: String,
78
79 #[arg(long, short = 'n', default_value = "20")]
81 pub limit: usize,
82
83 #[arg(long, short = 'C', default_value = "0")]
85 pub context_lines: usize,
86
87 #[arg(long, default_value = "0.0")]
89 pub min_confidence: f64,
90}
91
92impl ReferencesArgs {
93 pub fn run(
95 &self,
96 cli_format: OutputFormat,
97 quiet: bool,
98 cli_lang: Option<Language>,
99 ) -> Result<()> {
100 if !self.path.exists() {
102 anyhow::bail!(
104 "Path not found: '{}'. Please provide a valid file or directory.",
105 self.path.display()
106 );
107 }
108
109 let output_format = match self.output.as_deref() {
111 Some("text") => OutputFormat::Text,
112 Some("compact") => OutputFormat::Compact,
113 Some("json") => OutputFormat::Json,
114 _ => cli_format,
115 };
116
117 let writer = OutputWriter::new(output_format, quiet);
118
119 let kinds = self.kinds.as_ref().map(|k| parse_kinds(k));
121
122 let scope = parse_scope(&self.scope);
124
125 let options = ReferencesOptions {
127 include_definition: self.include_definition,
128 kinds,
129 scope,
130 language: cli_lang.map(|l| l.as_str().to_string()),
131 limit: Some(self.limit),
132 definition_file: None,
133 context_lines: self.context_lines,
134 };
135
136 writer.progress(&format!(
137 "Finding references to '{}' in {}...",
138 self.symbol,
139 self.path.display()
140 ));
141
142 let mut report = find_references(&self.symbol, &self.path, &options)?;
144
145 let resolved_lang = cli_lang
157 .or_else(|| Language::from_directory(&self.path));
158 if matches!(resolved_lang, Some(Language::Lua) | Some(Language::Luau)) {
159 enrich_lua_alias_callers(&mut report, &self.symbol, &self.path, resolved_lang);
160 }
161
162 let report = filter_by_min_confidence(report, self.min_confidence);
164
165 match output_format {
167 OutputFormat::Text => {
168 let text = format_references_text(&report);
169 writer.write_text(&text)?;
170 }
171 _ => {
172 writer.write(&report)?;
174 }
175 }
176
177 if report.total_references == 0 && !quiet {
179 eprintln!();
180 eprintln!(
181 "No references found for '{}'. Searched {} files.",
182 self.symbol, report.stats.files_searched
183 );
184 eprintln!("Suggestions:");
185 eprintln!(" - Check the symbol spelling");
186 eprintln!(" - Try a different search scope with --scope workspace");
187 eprintln!(" - Verify the path contains relevant source files");
188 }
189
190 Ok(())
191 }
192}
193
194fn enrich_lua_alias_callers(
201 report: &mut ReferencesReport,
202 symbol: &str,
203 path: &Path,
204 language: Option<Language>,
205) {
206 use std::collections::HashSet;
207 let bare = match symbol.rsplit('.').next() {
208 Some(b) if b != symbol && !b.is_empty() => b.to_string(),
209 _ => return,
210 };
211
212 let mut bare_options = ReferencesOptions::new();
213 bare_options.kinds = Some(vec![ReferenceKind::Call]);
214 bare_options.language = language.map(|l| l.as_str().to_string());
215 bare_options.limit = Some(500);
216
217 let bare_report = match find_references(&bare, path, &bare_options) {
218 Ok(r) => r,
219 Err(_) => return,
220 };
221
222 let dot_pat = format!(".{}(", bare);
223 let space_pat = format!(".{} (", bare);
224
225 let mut existing: HashSet<(PathBuf, usize, usize)> = HashSet::new();
227 for r in &report.references {
228 existing.insert((r.file.clone(), r.line, r.column));
229 }
230
231 let mut added = 0usize;
232 for r in &bare_report.references {
233 if !r.context.contains(&dot_pat) && !r.context.contains(&space_pat) {
234 continue;
235 }
236 let key = (r.file.clone(), r.line, r.column);
237 if existing.contains(&key) {
238 continue;
239 }
240 existing.insert(key);
241 report.references.push(r.clone());
242 added += 1;
243 }
244 if added > 0 {
245 report.total_references += added;
246 report.shown_references += added;
247 }
248}
249
250fn parse_kinds(s: &str) -> Vec<ReferenceKind> {
252 s.split(',')
253 .filter_map(|k| match k.trim().to_lowercase().as_str() {
254 "call" => Some(ReferenceKind::Call),
255 "read" => Some(ReferenceKind::Read),
256 "write" => Some(ReferenceKind::Write),
257 "import" => Some(ReferenceKind::Import),
258 "type" => Some(ReferenceKind::Type),
259 "definition" => Some(ReferenceKind::Definition),
260 "other" => Some(ReferenceKind::Other),
261 _ => None,
262 })
263 .collect()
264}
265
266fn filter_by_min_confidence(mut report: ReferencesReport, min_confidence: f64) -> ReferencesReport {
278 if min_confidence > 0.0 {
279 report
280 .references
281 .retain(|r| r.confidence.unwrap_or(0.0) >= min_confidence);
282 report.total_references = report.references.len();
283 report.shown_references = report.references.len();
284 report.truncated = false;
287 }
288 report
289}
290
291fn parse_scope(s: &str) -> SearchScope {
293 match s.to_lowercase().as_str() {
294 "local" => SearchScope::Local,
295 "file" => SearchScope::File,
296 _ => SearchScope::Workspace,
297 }
298}
299
300fn format_references_text(report: &ReferencesReport) -> String {
305 use std::path::Path;
306
307 let mut output = String::new();
308
309 let defs_for_text: Vec<&Definition> = if !report.definitions.is_empty() {
315 report.definitions.iter().collect()
316 } else {
317 report.definition.iter().collect()
318 };
319
320 let mut all_paths: Vec<&Path> = report.references.iter().map(|r| r.file.as_path()).collect();
322 for def in &defs_for_text {
323 all_paths.push(def.file.as_path());
324 }
325 let prefix = if all_paths.is_empty() {
326 PathBuf::new()
327 } else {
328 common_path_prefix(&all_paths)
329 };
330
331 output.push_str(&format!(
335 "References to: {} ({})\n",
336 report.symbol,
337 defs_for_text
338 .first()
339 .map(|d| d.kind.as_str())
340 .unwrap_or("unknown")
341 ));
342 output.push('\n');
343
344 if !defs_for_text.is_empty() {
349 if defs_for_text.len() > 1 {
350 output.push_str("Definitions:\n");
351 } else {
352 output.push_str("Definition:\n");
353 }
354 for def in &defs_for_text {
355 let def_display = strip_prefix_display(&def.file, &prefix);
356 output.push_str(&format!(
357 " {}:{}:{} [{}]\n",
358 def_display,
359 def.line,
360 def.column,
361 def.kind.as_str()
362 ));
363 if let Some(sig) = &def.signature {
364 let sig_clean = sig.replace('\t', " ");
366 output.push_str(&format!(" {}\n", sig_clean.trim()));
367 }
368 }
369 output.push('\n');
370 }
371
372 output.push_str(&format!(
374 "References ({} found in {}ms):\n",
375 report.total_references, report.stats.search_time_ms
376 ));
377
378 for r in &report.references {
379 let ref_display = strip_prefix_display(&r.file, &prefix);
380 output.push_str(&format!(
381 " {}:{}:{} [{}]\n",
382 ref_display,
383 r.line,
384 r.column,
385 r.kind.as_str()
386 ));
387 let context_clean = r.context.replace('\t', " ");
389 output.push_str(&format!(" {}\n", context_clean.trim()));
390 output.push('\n');
391 }
392
393 output.push_str(&format!(
395 "Search: {} files, {} candidates -> {} verified\n",
396 report.stats.files_searched,
397 report.stats.candidates_found,
398 report.stats.verified_references
399 ));
400 output.push_str(&format!("Scope: {}\n", report.search_scope.as_str()));
401
402 output
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use std::path::PathBuf;
409 use tldr_core::analysis::references::{Definition, DefinitionKind, Reference, ReferenceStats};
410
411 fn make_test_report() -> ReferencesReport {
412 ReferencesReport {
413 symbol: "test_func".to_string(),
414 definition: Some(Definition {
415 file: PathBuf::from("src/lib.py"),
416 line: 42,
417 column: 5,
418 kind: DefinitionKind::Function,
419 signature: Some("def test_func(x: int) -> str:".to_string()),
420 }),
421 definitions: vec![Definition {
422 file: PathBuf::from("src/lib.py"),
423 line: 42,
424 column: 5,
425 kind: DefinitionKind::Function,
426 signature: Some("def test_func(x: int) -> str:".to_string()),
427 }],
428 references: vec![
429 Reference::new(
430 PathBuf::from("src/main.py"),
431 10,
432 8,
433 ReferenceKind::Call,
434 "result = test_func(42)".to_string(),
435 ),
436 Reference::new(
437 PathBuf::from("tests/test_lib.py"),
438 25,
439 12,
440 ReferenceKind::Import,
441 "from src.lib import test_func".to_string(),
442 ),
443 ],
444 total_references: 2,
445 shown_references: 2,
446 truncated: false,
447 search_scope: SearchScope::Workspace,
448 stats: ReferenceStats {
449 files_searched: 10,
450 candidates_found: 5,
451 verified_references: 2,
452 search_time_ms: 127,
453 },
454 }
455 }
456
457 #[test]
458 fn test_format_references_text() {
459 let report = make_test_report();
460 let text = format_references_text(&report);
461
462 assert!(text.contains("References to: test_func (function)"));
463 assert!(text.contains("Definition:"));
464 assert!(text.contains("src/lib.py:42:5 [function]"));
465 assert!(text.contains("def test_func(x: int) -> str:"));
466 assert!(text.contains("References (2 found in 127ms)"));
467 assert!(text.contains("src/main.py:10:8 [call]"));
468 assert!(text.contains("tests/test_lib.py:25:12 [import]"));
469 assert!(text.contains("Search: 10 files, 5 candidates -> 2 verified"));
470 assert!(text.contains("Scope: workspace"));
471 }
472
473 #[test]
474 fn test_parse_kinds() {
475 let kinds = parse_kinds("call,import,type");
476 assert_eq!(kinds.len(), 3);
477 assert!(kinds.contains(&ReferenceKind::Call));
478 assert!(kinds.contains(&ReferenceKind::Import));
479 assert!(kinds.contains(&ReferenceKind::Type));
480 }
481
482 #[test]
483 fn test_parse_kinds_case_insensitive() {
484 let kinds = parse_kinds("CALL,Read,WRITE");
485 assert_eq!(kinds.len(), 3);
486 assert!(kinds.contains(&ReferenceKind::Call));
487 assert!(kinds.contains(&ReferenceKind::Read));
488 assert!(kinds.contains(&ReferenceKind::Write));
489 }
490
491 #[test]
492 fn test_parse_scope() {
493 assert_eq!(parse_scope("local"), SearchScope::Local);
494 assert_eq!(parse_scope("file"), SearchScope::File);
495 assert_eq!(parse_scope("workspace"), SearchScope::Workspace);
496 assert_eq!(parse_scope("WORKSPACE"), SearchScope::Workspace);
497 assert_eq!(parse_scope("unknown"), SearchScope::Workspace); }
499
500 #[test]
501 fn test_tab_expansion_in_context() {
502 let mut report = make_test_report();
503 report.references[0] = Reference::new(
504 PathBuf::from("src/main.py"),
505 10,
506 8,
507 ReferenceKind::Call,
508 "\tresult = test_func(42)".to_string(), );
510
511 let text = format_references_text(&report);
512 assert!(text.contains(" result = test_func(42)"));
514 assert!(!text.contains('\t'));
515 }
516
517 #[test]
518 fn test_text_formatter_strips_common_path_prefix() {
519 let mut report = make_test_report();
521 report.definition = Some(Definition {
522 file: PathBuf::from("/home/user/project/src/lib.py"),
523 line: 42,
524 column: 5,
525 kind: DefinitionKind::Function,
526 signature: Some("def test_func(x: int) -> str:".to_string()),
527 });
528 report.definitions = vec![Definition {
529 file: PathBuf::from("/home/user/project/src/lib.py"),
530 line: 42,
531 column: 5,
532 kind: DefinitionKind::Function,
533 signature: Some("def test_func(x: int) -> str:".to_string()),
534 }];
535 report.references = vec![
536 Reference::new(
537 PathBuf::from("/home/user/project/src/main.py"),
538 10,
539 8,
540 ReferenceKind::Call,
541 "result = test_func(42)".to_string(),
542 ),
543 Reference::new(
544 PathBuf::from("/home/user/project/tests/test_lib.py"),
545 25,
546 12,
547 ReferenceKind::Import,
548 "from src.lib import test_func".to_string(),
549 ),
550 ];
551
552 let text = format_references_text(&report);
553
554 assert!(
556 !text.contains("/home/user/project/"),
557 "Text should not contain the absolute common prefix. Got:\n{}",
558 text
559 );
560 assert!(text.contains("src/lib.py:42:5"));
562 assert!(text.contains("src/main.py:10:8"));
563 assert!(text.contains("tests/test_lib.py:25:12"));
564 }
565
566 #[test]
567 fn test_default_limit_is_20() {
568 use clap::Parser;
570
571 #[derive(Parser)]
572 struct Wrapper {
573 #[command(flatten)]
574 refs: ReferencesArgs,
575 }
576
577 let wrapper = Wrapper::parse_from(["test", "my_symbol"]);
578 assert_eq!(
579 wrapper.refs.limit, 20,
580 "Default limit should be 20, got {}",
581 wrapper.refs.limit
582 );
583 }
584
585 #[test]
586 fn test_min_confidence_filtering() {
587 let report = ReferencesReport {
589 symbol: "test_func".to_string(),
590 definition: None,
591 definitions: Vec::new(),
592 references: vec![
593 Reference::with_details(
594 PathBuf::from("src/a.py"),
595 10,
596 1,
597 10,
598 ReferenceKind::Call,
599 "test_func()".to_string(),
600 1.0, ),
602 Reference::with_details(
603 PathBuf::from("src/b.py"),
604 20,
605 1,
606 10,
607 ReferenceKind::Call,
608 "test_func()".to_string(),
609 0.5, ),
611 Reference::with_details(
612 PathBuf::from("src/c.py"),
613 30,
614 1,
615 10,
616 ReferenceKind::Call,
617 "test_func()".to_string(),
618 0.3, ),
620 ],
621 total_references: 3,
622 shown_references: 3,
623 truncated: false,
624 search_scope: SearchScope::Workspace,
625 stats: ReferenceStats {
626 files_searched: 5,
627 candidates_found: 3,
628 verified_references: 3,
629 search_time_ms: 50,
630 },
631 };
632
633 let filtered = filter_by_min_confidence(report.clone(), 0.5);
635 assert_eq!(
636 filtered.references.len(),
637 2,
638 "Should have 2 refs with confidence >= 0.5, got {}",
639 filtered.references.len()
640 );
641 assert_eq!(
642 filtered.total_references, 2,
643 "total_references should be updated after filtering"
644 );
645
646 let filtered_high = filter_by_min_confidence(report.clone(), 1.0);
648 assert_eq!(filtered_high.references.len(), 1);
649 assert_eq!(filtered_high.total_references, 1);
650
651 let filtered_none = filter_by_min_confidence(report, 0.0);
653 assert_eq!(filtered_none.references.len(), 3);
654 assert_eq!(filtered_none.total_references, 3);
655 }
656
657 #[test]
658 fn test_kinds_short_flag_t() {
659 use clap::Parser;
660
661 #[derive(Parser)]
662 struct Wrapper {
663 #[command(flatten)]
664 refs: ReferencesArgs,
665 }
666
667 let wrapper = Wrapper::parse_from(["test", "my_symbol", ".", "-t", "call,import"]);
668 assert_eq!(
669 wrapper.refs.kinds.as_deref(),
670 Some("call,import"),
671 "--kinds should be settable via -t short flag"
672 );
673 }
674}