Skip to main content

tldr_cli/commands/
references.rs

1//! References command - Find all references to a symbol
2//!
3//! Wires tldr-core::analysis::references to the CLI (Session 7 Phase 12).
4//!
5//! # Features
6//! - Text search with word boundary matching
7//! - AST-based verification to filter false positives
8//! - Reference kind classification (call, read, write, import, type)
9//! - Search scope optimization based on symbol visibility
10//! - Multiple output formats: JSON, text
11//!
12//! # Risk Mitigations
13//! - S7-R31: Command registered in mod.rs and main.rs
14//! - S7-R32: format_references_text implemented below
15//! - S7-R33: find_references exported from tldr_core::analysis::references
16//! - S7-R46: Tab alignment - expand tabs to spaces in context
17//! - S7-R50: No suggestions on no match - suggest similar symbols
18//! - S7-R51: Too many references - respect limit, show count
19//! - S7-R56: Path not found error - include tried path in message
20//! - S7-R57: Unsupported language - list supported languages in error
21
22use 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/// Find all references to a symbol
35///
36/// Search for all occurrences of a symbol (function, variable, class, etc.)
37/// across the codebase using text search followed by AST verification.
38///
39/// # Examples
40///
41/// ```bash
42/// # Find all references to 'analyze_dependencies'
43/// tldr references analyze_dependencies .
44///
45/// # Include the definition in results
46/// tldr references login . --include-definition
47///
48/// # Filter by reference kinds
49/// tldr references process_data . --kinds call,import
50///
51/// # Output as text
52/// tldr references MyClass . --format text
53/// ```
54#[derive(Debug, Args)]
55pub struct ReferencesArgs {
56    /// Symbol to find references for
57    pub symbol: String,
58
59    /// Path to search in (directory)
60    #[arg(default_value = ".")]
61    pub path: PathBuf,
62
63    /// Output format override (backwards compatibility, prefer global --format/-f)
64    #[arg(long = "output", short = 'o', hide = true)]
65    pub output: Option<String>,
66
67    /// Include definition location in results
68    #[arg(long)]
69    pub include_definition: bool,
70
71    /// Filter by reference kinds (comma-separated: call,read,write,import,type)
72    #[arg(long, short = 't')]
73    pub kinds: Option<String>,
74
75    /// Search scope: local, file, workspace
76    #[arg(long, short = 's', default_value = "workspace")]
77    pub scope: String,
78
79    /// Maximum number of results to return
80    #[arg(long, short = 'n', default_value = "20")]
81    pub limit: usize,
82
83    /// Number of context lines before and after (not implemented yet)
84    #[arg(long, short = 'C', default_value = "0")]
85    pub context_lines: usize,
86
87    /// Minimum confidence threshold (0.0-1.0). References below this are filtered out.
88    #[arg(long, default_value = "0.0")]
89    pub min_confidence: f64,
90}
91
92impl ReferencesArgs {
93    /// Run the references command
94    pub fn run(
95        &self,
96        cli_format: OutputFormat,
97        quiet: bool,
98        cli_lang: Option<Language>,
99    ) -> Result<()> {
100        // Validate path exists
101        if !self.path.exists() {
102            // S7-R56: Path not found error - include tried path in message
103            anyhow::bail!(
104                "Path not found: '{}'. Please provide a valid file or directory.",
105                self.path.display()
106            );
107        }
108
109        // Resolve format: hidden -o override takes precedence, else global -f
110        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        // Parse reference kinds filter
120        let kinds = self.kinds.as_ref().map(|k| parse_kinds(k));
121
122        // Parse search scope
123        let scope = parse_scope(&self.scope);
124
125        // Build options
126        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        // Run analysis
143        let mut report = find_references(&self.symbol, &self.path, &options)?;
144
145        // sibling-resolver-gaps-v1 (P14.AGG14-13): when the user
146        // searches for `m.reset` in a Lua project, the call-graph
147        // resolves the qualified `m.<method>` form but
148        // `find_references` only looks up the dotted form literally.
149        // Cross-module callers that invoke through an alias (e.g.
150        // `local files = require("files"); files.reset()`) write the
151        // call site as `<alias>.<method>(...)` so the literal lookup
152        // misses them. `tldr explain m.open` solved the same gap in
153        // P13.AGG13-12 by also querying the bare method name and
154        // accepting hits whose context is `\.<method>(`. Apply the
155        // same enrichment here so `references` agrees with `explain`.
156        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        // Filter by minimum confidence if specified
163        let report = filter_by_min_confidence(report, self.min_confidence);
164
165        // Output based on format
166        match output_format {
167            OutputFormat::Text => {
168                let text = format_references_text(&report);
169                writer.write_text(&text)?;
170            }
171            _ => {
172                // JSON output (default)
173                writer.write(&report)?;
174            }
175        }
176
177        // S7-R50: If no references found, give helpful message
178        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
194/// sibling-resolver-gaps-v1 (P14.AGG14-13): augment a Lua references
195/// report with cross-module alias callers. When the user searches for
196/// `m.reset`, also query the bare `reset` and append hits whose
197/// surrounding context looks like `<receiver>.reset(...)` (i.e. a
198/// method invocation through some alias) and which weren't already in
199/// the report. Mirrors the P13.AGG13-12 fix in `tldr explain`.
200fn 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    // Index existing references by (file, line, column) to dedupe.
226    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
250/// Parse comma-separated reference kinds
251fn 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
266/// Filter a report by minimum confidence threshold.
267///
268/// Removes references with confidence below `min_confidence` and
269/// updates `total_references` / `shown_references` to match. References
270/// with `None` confidence are treated as 0.0.
271///
272/// med-low-schema-cleanup-v1 (N6): also keeps `shown_references`
273/// consistent with the post-filter Vec and clears `truncated` if the
274/// filter happens to drop the report below the original limit (this
275/// is a defensive sync; the truncation flag was set upstream against
276/// the pre-filter total).
277fn 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        // After confidence filtering the Vec is the full surviving set;
285        // there's no longer a hidden tail behind a `--limit`.
286        report.truncated = false;
287    }
288    report
289}
290
291/// Parse search scope string
292fn 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
300/// Format the references report as human-readable text
301///
302/// # S7-R32: format_references_text implementation
303/// # S7-R46: Tab alignment - expand tabs to spaces in context
304fn format_references_text(report: &ReferencesReport) -> String {
305    use std::path::Path;
306
307    let mut output = String::new();
308
309    // M3 detection-accuracy-v1 BUG-20: prefer the canonical multi-definition
310    // shape `report.definitions`. The legacy singular `report.definition` is
311    // a back-compat first-element view; using `definitions` here makes the
312    // text output honest about multiple definitions (e.g. flask
313    // `_make_timedelta` defined in both sansio/app.py and app.py).
314    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    // Collect all file paths (definitions + references) to compute common prefix
321    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    // Header — when multiple definitions exist, list the kind of the first
332    // (defs_for_text is sorted by canonical-def tier so the first is
333    // the highest-confidence definition).
334    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    // Definition(s) (if found). Pre-M3 the header was hard-coded "Definition:"
345    // (singular) even when multiple defs were present in the body — a
346    // pluralization mismatch flagged by BUG-20. Switch to "Definitions:" when
347    // the count exceeds one.
348    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                // S7-R46: Expand tabs to spaces
365                let sig_clean = sig.replace('\t', "    ");
366                output.push_str(&format!("    {}\n", sig_clean.trim()));
367            }
368        }
369        output.push('\n');
370    }
371
372    // References
373    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        // S7-R46: Expand tabs to spaces in context
388        let context_clean = r.context.replace('\t', "    ");
389        output.push_str(&format!("    {}\n", context_clean.trim()));
390        output.push('\n');
391    }
392
393    // Stats
394    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); // default
498    }
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(), // Leading tab
509        );
510
511        let text = format_references_text(&report);
512        // Tab should be expanded to 4 spaces
513        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        // Use absolute-like paths that share a common prefix
520        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        // The common prefix /home/user/project/ should be stripped
555        assert!(
556            !text.contains("/home/user/project/"),
557            "Text should not contain the absolute common prefix. Got:\n{}",
558            text
559        );
560        // But the relative paths should be present
561        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        // Verify the default limit arg is 20 by parsing default args
569        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        // Build a report with references at different confidence levels
588        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, // high confidence
601                ),
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, // medium confidence
610                ),
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, // low confidence
619                ),
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        // Filter at 0.5 threshold should keep 2 references
634        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        // Filter at 1.0 should keep only 1
647        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        // Filter at 0.0 should keep all
652        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}