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::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/// Find all references to a symbol
34///
35/// Search for all occurrences of a symbol (function, variable, class, etc.)
36/// across the codebase using text search followed by AST verification.
37///
38/// # Examples
39///
40/// ```bash
41/// # Find all references to 'analyze_dependencies'
42/// tldr references analyze_dependencies .
43///
44/// # Include the definition in results
45/// tldr references login . --include-definition
46///
47/// # Filter by reference kinds
48/// tldr references process_data . --kinds call,import
49///
50/// # Output as text
51/// tldr references MyClass . --format text
52/// ```
53#[derive(Debug, Args)]
54pub struct ReferencesArgs {
55    /// Symbol to find references for
56    pub symbol: String,
57
58    /// Path to search in (directory)
59    #[arg(default_value = ".")]
60    pub path: PathBuf,
61
62    /// Output format override (backwards compatibility, prefer global --format/-f)
63    #[arg(long = "output", short = 'o', hide = true)]
64    pub output: Option<String>,
65
66    /// Language filter: python, typescript, go, rust
67    #[arg(long, short = 'l')]
68    pub lang: Option<String>,
69
70    /// Include definition location in results
71    #[arg(long)]
72    pub include_definition: bool,
73
74    /// Filter by reference kinds (comma-separated: call,read,write,import,type)
75    #[arg(long, short = 't')]
76    pub kinds: Option<String>,
77
78    /// Search scope: local, file, workspace
79    #[arg(long, short = 's', default_value = "workspace")]
80    pub scope: String,
81
82    /// Maximum number of results to return
83    #[arg(long, short = 'n', default_value = "20")]
84    pub limit: usize,
85
86    /// Number of context lines before and after (not implemented yet)
87    #[arg(long, short = 'C', default_value = "0")]
88    pub context_lines: usize,
89
90    /// Minimum confidence threshold (0.0-1.0). References below this are filtered out.
91    #[arg(long, default_value = "0.0")]
92    pub min_confidence: f64,
93}
94
95impl ReferencesArgs {
96    /// Run the references command
97    pub fn run(&self, cli_format: OutputFormat, quiet: bool) -> Result<()> {
98        // Validate path exists
99        if !self.path.exists() {
100            // S7-R56: Path not found error - include tried path in message
101            anyhow::bail!(
102                "Path not found: '{}'. Please provide a valid file or directory.",
103                self.path.display()
104            );
105        }
106
107        // Resolve format: hidden -o override takes precedence, else global -f
108        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        // Parse reference kinds filter
118        let kinds = self.kinds.as_ref().map(|k| parse_kinds(k));
119
120        // Parse search scope
121        let scope = parse_scope(&self.scope);
122
123        // Build options
124        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        // Run analysis
141        let report = find_references(&self.symbol, &self.path, &options)?;
142
143        // Filter by minimum confidence if specified
144        let report = filter_by_min_confidence(report, self.min_confidence);
145
146        // Output based on format
147        match output_format {
148            OutputFormat::Text => {
149                let text = format_references_text(&report);
150                writer.write_text(&text)?;
151            }
152            _ => {
153                // JSON output (default)
154                writer.write(&report)?;
155            }
156        }
157
158        // S7-R50: If no references found, give helpful message
159        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
175/// Parse comma-separated reference kinds
176fn 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
191/// Filter a report by minimum confidence threshold.
192///
193/// Removes references with confidence below `min_confidence` and
194/// updates `total_references` to match. References with `None` confidence
195/// are treated as 0.0.
196fn 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
206/// Parse search scope string
207fn 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
215/// Format the references report as human-readable text
216///
217/// # S7-R32: format_references_text implementation
218/// # S7-R46: Tab alignment - expand tabs to spaces in context
219fn format_references_text(report: &ReferencesReport) -> String {
220    use std::path::Path;
221
222    let mut output = String::new();
223
224    // Collect all file paths (definition + references) to compute common prefix
225    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    // Header
236    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    // Definition (if found)
248    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            // S7-R46: Expand tabs to spaces
260            let sig_clean = sig.replace('\t', "    ");
261            output.push_str(&format!("    {}\n", sig_clean.trim()));
262        }
263        output.push('\n');
264    }
265
266    // References
267    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        // S7-R46: Expand tabs to spaces in context
282        let context_clean = r.context.replace('\t', "    ");
283        output.push_str(&format!("    {}\n", context_clean.trim()));
284        output.push('\n');
285    }
286
287    // Stats
288    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); // default
383    }
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(), // Leading tab
394        );
395
396        let text = format_references_text(&report);
397        // Tab should be expanded to 4 spaces
398        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        // Use absolute-like paths that share a common prefix
405        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        // The common prefix /home/user/project/ should be stripped
433        assert!(
434            !text.contains("/home/user/project/"),
435            "Text should not contain the absolute common prefix. Got:\n{}",
436            text
437        );
438        // But the relative paths should be present
439        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        // Verify the default limit arg is 20 by parsing default args
447        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        // Build a report with references at different confidence levels
466        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, // high confidence
478                ),
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, // medium confidence
487                ),
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, // low confidence
496                ),
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        // Filter at 0.5 threshold should keep 2 references
509        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        // Filter at 1.0 should keep only 1
522        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        // Filter at 0.0 should keep all
527        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}