Skip to main content

tldr_cli/commands/
dead.rs

1//! Dead command - Find dead code
2//!
3//! Identifies functions that are never called (unreachable code).
4//! Auto-routes through daemon when available for ~35x speedup.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use clap::Args;
11use serde::Serialize;
12use tldr_core::walker::ProjectWalker;
13
14/// Maximum number of files to scan in WalkDir traversals.
15///
16/// Prevents runaway scans in massive monorepos or symlink-heavy layouts.
17/// Projects with fewer files are unaffected.
18const MAX_FILES: usize = 10_000;
19
20use tldr_core::analysis::dead::dead_code_analysis_refcount;
21use tldr_core::analysis::refcount::count_identifiers_in_tree;
22use tldr_core::ast::parser::parse_file;
23use tldr_core::ast::{extract_file, extract_from_tree};
24use tldr_core::types::{DeadCodeReport, ModuleInfo};
25use tldr_core::{
26    build_project_call_graph, collect_all_functions, dead_code_analysis, FunctionRef, Language,
27};
28
29use crate::commands::daemon_router::{params_for_dead, try_daemon_route};
30use crate::output::{OutputFormat, OutputWriter};
31
32/// Find dead (unreachable) code
33#[derive(Debug, Args)]
34pub struct DeadArgs {
35    /// Project root directory (default: current directory)
36    #[arg(default_value = ".")]
37    pub path: PathBuf,
38
39    /// Programming language
40    #[arg(long, short = 'l')]
41    pub lang: Option<Language>,
42
43    /// Custom entry point patterns (comma-separated)
44    #[arg(long, short = 'e', value_delimiter = ',')]
45    pub entry_points: Vec<String>,
46
47    /// Maximum number of dead functions to display
48    #[arg(long, default_value = "100")]
49    pub max_items: usize,
50
51    /// Use call-graph-based analysis instead of the default reference counting
52    #[arg(long)]
53    pub call_graph: bool,
54
55    /// Walk vendored/build dirs (node_modules, target, dist, etc.) that would normally be skipped.
56    #[arg(long)]
57    pub no_default_ignore: bool,
58}
59
60impl DeadArgs {
61    /// Run the dead command
62    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
63        let writer = OutputWriter::new(format, quiet);
64
65        // Validate path exists BEFORE language detection / progress banner
66        // (lang-detect-default-v1)
67        if !self.path.exists() {
68            anyhow::bail!("Path not found: {}", self.path.display());
69        }
70
71        // Determine language (auto-detect from directory, default to Python)
72        let language = self
73            .lang
74            .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
75
76        // Try daemon first for cached result
77        let entry_points: Option<Vec<String>> = if self.entry_points.is_empty() {
78            None
79        } else {
80            Some(self.entry_points.clone())
81        };
82
83        if let Some(report) = try_daemon_route::<DeadCodeReport>(
84            &self.path,
85            "dead",
86            params_for_dead(Some(&self.path), entry_points.as_deref()),
87        ) {
88            // Apply truncation if needed
89            let (truncated_report, truncated, total_count, shown_count) =
90                apply_truncation(report, self.max_items);
91
92            // Output based on format
93            if writer.is_text() {
94                let text = format_dead_code_text_truncated(
95                    &truncated_report,
96                    truncated,
97                    total_count,
98                    shown_count,
99                );
100                writer.write_text(&text)?;
101                return Ok(());
102            } else {
103                let _ = (total_count, shown_count); // text path only
104                let output = DeadCodeOutput {
105                    report: truncated_report,
106                    truncated,
107                };
108                writer.write(&output)?;
109                return Ok(());
110            }
111        }
112
113        // Fallback to direct compute
114        let entry_points_for_analysis: Option<Vec<String>> = if self.entry_points.is_empty() {
115            None
116        } else {
117            Some(self.entry_points.clone())
118        };
119
120        let report = if self.call_graph {
121            // Old path: build call graph, then analyze
122            writer.progress(&format!(
123                "Building call graph for {} ({:?})...",
124                self.path.display(),
125                language
126            ));
127
128            let graph = build_project_call_graph(&self.path, language, None, true)?;
129
130            writer.progress("Extracting all functions...");
131            let module_infos = collect_module_infos(&self.path, language, self.no_default_ignore);
132            let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
133
134            writer.progress("Analyzing dead code (call graph)...");
135            dead_code_analysis(&graph, &all_functions, entry_points_for_analysis.as_deref())?
136        } else {
137            // New default path: reference counting (single-pass)
138            writer.progress(&format!(
139                "Scanning {} ({:?}) with reference counting...",
140                self.path.display(),
141                language
142            ));
143
144            let (module_infos, merged_ref_counts) =
145                collect_module_infos_with_refcounts(&self.path, language, self.no_default_ignore);
146            let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
147
148            writer.progress("Analyzing dead code (refcount)...");
149            dead_code_analysis_refcount(
150                &all_functions,
151                &merged_ref_counts,
152                entry_points_for_analysis.as_deref(),
153            )?
154        };
155
156        // Apply truncation if needed
157        let (truncated_report, truncated, total_count, shown_count) =
158            apply_truncation(report, self.max_items);
159
160        // Output based on format
161        if writer.is_text() {
162            let text = format_dead_code_text_truncated(
163                &truncated_report,
164                truncated,
165                total_count,
166                shown_count,
167            );
168            writer.write_text(&text)?;
169        } else {
170            let _ = (total_count, shown_count); // text path only
171            let output = DeadCodeOutput {
172                report: truncated_report,
173                truncated,
174            };
175            writer.write(&output)?;
176        }
177
178        Ok(())
179    }
180}
181
182/// Check if JS/TS source has a file-level 'use server' or 'use client' directive.
183/// This is checked on the source string directly (no file I/O) to avoid path resolution issues.
184fn source_has_framework_directive(source: &str, ext: &str) -> bool {
185    if !matches!(ext, "ts" | "tsx" | "js" | "jsx" | "mjs") {
186        return false;
187    }
188    for line in source.lines().take(5) {
189        let trimmed = line.trim();
190        if trimmed == r#""use server""#
191            || trimmed == r#"'use server'"#
192            || trimmed == r#""use server";"#
193            || trimmed == r#"'use server';"#
194            || trimmed == r#""use client""#
195            || trimmed == r#"'use client'"#
196            || trimmed == r#""use client";"#
197            || trimmed == r#"'use client';"#
198        {
199            return true;
200        }
201        // Skip empty lines and comments
202        if !trimmed.is_empty()
203            && !trimmed.starts_with("//")
204            && !trimmed.starts_with("/*")
205            && !trimmed.starts_with('*')
206            && !trimmed.starts_with('"')
207            && !trimmed.starts_with('\'')
208        {
209            break;
210        }
211    }
212    false
213}
214
215/// Tag all functions and class methods in a ModuleInfo with a synthetic decorator
216/// if the source contains a framework directive ('use server'/'use client').
217fn tag_directive_functions(info: &mut ModuleInfo, source: &str, path: &Path) {
218    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
219    if source_has_framework_directive(source, ext) {
220        for func in &mut info.functions {
221            if !func
222                .decorators
223                .contains(&"use_server_directive".to_string())
224            {
225                func.decorators.push("use_server_directive".to_string());
226            }
227        }
228        for class in &mut info.classes {
229            for method in &mut class.methods {
230                if !method
231                    .decorators
232                    .contains(&"use_server_directive".to_string())
233                {
234                    method.decorators.push("use_server_directive".to_string());
235                }
236            }
237        }
238    }
239}
240
241/// inheritance-and-dead-cleanup-v1 (M6): TypeScript declaration files
242/// (`.d.ts`) contain only `interface` / `type` / ambient declarations — no
243/// executable code. Including them in dead-code analysis produces false
244/// "possibly_dead" findings for every declared symbol. Mirrors the
245/// oversize-skip pattern used elsewhere in the codebase.
246fn is_typescript_declaration_file(path: &Path) -> bool {
247    path.to_string_lossy().to_ascii_lowercase().ends_with(".d.ts")
248}
249
250/// Collect ModuleInfo from all files in a directory using detailed AST extraction.
251///
252/// This provides the enriched function metadata (decorators, visibility, etc.)
253/// needed for accurate dead code analysis with low false-positive rates.
254fn collect_module_infos(
255    path: &Path,
256    language: Language,
257    no_default_ignore: bool,
258) -> Vec<(PathBuf, ModuleInfo)> {
259    let mut module_infos = Vec::new();
260
261    if path.is_file() {
262        // M6: skip .d.ts declaration-only files
263        if is_typescript_declaration_file(path) {
264            return module_infos;
265        }
266        if let Ok(mut info) = extract_file(path, path.parent()) {
267            if let Ok(source) = std::fs::read_to_string(path) {
268                tag_directive_functions(&mut info, &source, path);
269            }
270            // Use filename only for single files (matches call graph convention)
271            let rel_path = path
272                .file_name()
273                .map(PathBuf::from)
274                .unwrap_or_else(|| path.to_path_buf());
275            module_infos.push((rel_path, info));
276        }
277    } else {
278        // language-coverage-fixes-v1 (P4.BUG-N1, P4.BUG-N5): use
279        // `scan_extensions()` so C++ dir scans include `.h` and JS/TS
280        // sibling extensions (`.tsx` ↔ `.jsx`) participate together.
281        let extensions: &[&str] = language.scan_extensions();
282        let mut file_count: usize = 0;
283        // residual-bugs-v1 (P15.AGG14-7-cascade): pass the resolved
284        // language to the walker so JS/TS source under `src/build/` or
285        // `packages/x/dist/` is preserved (mirrors the per-language gate
286        // in `crates/tldr-core/src/callgraph/scanner.rs`). Without this
287        // hint, `tldr dead /tmp/repos/ts-dom-gen` returned
288        // `functions_analyzed: 0` because the walker silently skipped
289        // `src/build/`, where the entire authored TypeScript surface
290        // lives.
291        let mut walker = ProjectWalker::new(path).lang_hint(language);
292        if no_default_ignore {
293            walker = walker.no_default_ignore();
294        }
295        for entry in walker.iter() {
296            let file_path = entry.path();
297            if file_path.is_file() {
298                // M6: skip .d.ts declaration-only files
299                if is_typescript_declaration_file(file_path) {
300                    continue;
301                }
302                if let Some(ext_str) = file_path.extension().and_then(|e| e.to_str()) {
303                    let dotted = format!(".{}", ext_str);
304                    if extensions.contains(&dotted.as_str()) {
305                        file_count += 1;
306                        if file_count > MAX_FILES {
307                            eprintln!(
308                                "Warning: dead code scan truncated at {} files in {}",
309                                MAX_FILES,
310                                path.display()
311                            );
312                            break;
313                        }
314                        if let Ok(mut info) = extract_file(file_path, Some(path)) {
315                            // Tag functions with framework directive from source
316                            if let Ok(source) = std::fs::read_to_string(file_path) {
317                                tag_directive_functions(&mut info, &source, file_path);
318                            }
319                            // Use relative path to match call graph edge convention
320                            let rel_path = file_path
321                                .strip_prefix(path)
322                                .unwrap_or(file_path)
323                                .to_path_buf();
324                            module_infos.push((rel_path, info));
325                        }
326                    }
327                }
328            }
329        }
330    }
331
332    module_infos
333}
334
335/// Collect ModuleInfo AND identifier reference counts in a single pass.
336///
337/// For each file, we parse once with tree-sitter and then run both:
338/// - `extract_from_tree()` to get ModuleInfo (functions, classes, imports)
339/// - `count_identifiers_in_tree()` to get identifier occurrence counts
340///
341/// The identifier counts are merged into a single project-wide HashMap.
342pub(crate) fn collect_module_infos_with_refcounts(
343    path: &Path,
344    language: Language,
345    no_default_ignore: bool,
346) -> (Vec<(PathBuf, ModuleInfo)>, HashMap<String, usize>) {
347    let mut module_infos = Vec::new();
348    let mut merged_counts: HashMap<String, usize> = HashMap::new();
349
350    if path.is_file() {
351        // M6: skip .d.ts declaration-only files (still produce empty
352        // module_infos / counts so callers behave gracefully).
353        if is_typescript_declaration_file(path) {
354            return (module_infos, merged_counts);
355        }
356        if let Ok((tree, source, lang)) = parse_file(path) {
357            // Extract ModuleInfo from the parsed tree
358            if let Ok(mut info) = extract_from_tree(&tree, &source, lang, path, path.parent()) {
359                tag_directive_functions(&mut info, &source, path);
360                let rel_path = path
361                    .file_name()
362                    .map(PathBuf::from)
363                    .unwrap_or_else(|| path.to_path_buf());
364                module_infos.push((rel_path, info));
365            }
366            // Count identifiers from the same parsed tree
367            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
368            for (name, count) in file_counts {
369                *merged_counts.entry(name).or_insert(0) += count;
370            }
371        }
372    } else {
373        // language-coverage-fixes-v1 (P4.BUG-N1, P4.BUG-N5): use
374        // `scan_extensions()` so C++ dir scans include `.h` and JS/TS
375        // sibling extensions (`.tsx` ↔ `.jsx`) participate together.
376        let extensions: &[&str] = language.scan_extensions();
377        let mut file_count: usize = 0;
378        // residual-bugs-v1 (P15.AGG14-7-cascade): pass the resolved
379        // language to the walker so JS/TS source under `src/build/` or
380        // `packages/x/dist/` is preserved (mirrors the per-language gate
381        // in `crates/tldr-core/src/callgraph/scanner.rs`). Without this
382        // hint, `tldr dead /tmp/repos/ts-dom-gen` returned
383        // `functions_analyzed: 0` because the walker silently skipped
384        // `src/build/`, where the entire authored TypeScript surface
385        // lives.
386        let mut walker = ProjectWalker::new(path).lang_hint(language);
387        if no_default_ignore {
388            walker = walker.no_default_ignore();
389        }
390        for entry in walker.iter() {
391            let file_path = entry.path();
392            if file_path.is_file() {
393                // M6: skip .d.ts declaration-only files
394                if is_typescript_declaration_file(file_path) {
395                    continue;
396                }
397                if let Some(ext_str) = file_path.extension().and_then(|e| e.to_str()) {
398                    let dotted = format!(".{}", ext_str);
399                    if extensions.contains(&dotted.as_str()) {
400                        file_count += 1;
401                        if file_count > MAX_FILES {
402                            eprintln!(
403                                "Warning: born-dead scan truncated at {} files in {}",
404                                MAX_FILES,
405                                path.display()
406                            );
407                            break;
408                        }
409                        if let Ok((tree, source, lang)) = parse_file(file_path) {
410                            // Extract ModuleInfo from the parsed tree
411                            if let Ok(mut info) =
412                                extract_from_tree(&tree, &source, lang, file_path, Some(path))
413                            {
414                                // Tag functions with framework directive while we have the source
415                                tag_directive_functions(&mut info, &source, file_path);
416                                let rel_path = file_path
417                                    .strip_prefix(path)
418                                    .unwrap_or(file_path)
419                                    .to_path_buf();
420                                module_infos.push((rel_path, info));
421                            }
422                            // Count identifiers from the same parsed tree
423                            let file_counts =
424                                count_identifiers_in_tree(&tree, source.as_bytes(), lang);
425                            for (name, count) in file_counts {
426                                *merged_counts.entry(name).or_insert(0) += count;
427                            }
428                        }
429                    }
430                }
431            }
432        }
433    }
434
435    (module_infos, merged_counts)
436}
437
438/// Wrapper struct for JSON output with truncation metadata.
439///
440/// low-cleanup-bundle-v1 (L5): the previous shape redundantly carried three
441/// near-identical counters (`total_dead == total_count == shown_count` on
442/// the un-truncated case). We dropped `total_count` (duplicate of the
443/// canonical `total_dead` in `DeadCodeReport`) and `shown_count` (always
444/// derivable from `dead_functions.len()`), keeping only the boolean
445/// `truncated` flag for the rare case the list was clipped by --max-items.
446#[derive(Serialize)]
447struct DeadCodeOutput {
448    #[serde(flatten)]
449    report: DeadCodeReport,
450    #[serde(skip_serializing_if = "is_false", default)]
451    truncated: bool,
452}
453
454fn is_false(b: &bool) -> bool {
455    !b
456}
457
458/// Apply truncation to the report based on max_items.
459fn apply_truncation(
460    mut report: DeadCodeReport,
461    max_items: usize,
462) -> (DeadCodeReport, bool, usize, usize) {
463    let total_count = report.dead_functions.len();
464
465    if total_count > max_items {
466        report.dead_functions.truncate(max_items);
467        // Also truncate by_file to match
468        let mut count = 0;
469        let mut new_by_file = std::collections::HashMap::new();
470        for (path, funcs) in report.by_file {
471            let remaining = max_items - count;
472            if remaining == 0 {
473                break;
474            }
475            let to_take = funcs.len().min(remaining);
476            let truncated_funcs: Vec<String> = funcs.into_iter().take(to_take).collect();
477            count += truncated_funcs.len();
478            new_by_file.insert(path, truncated_funcs);
479        }
480        report.by_file = new_by_file;
481        (report, true, total_count, max_items)
482    } else {
483        (report, false, total_count, total_count)
484    }
485}
486
487/// Format dead code report with optional truncation notice.
488fn format_dead_code_text_truncated(
489    report: &DeadCodeReport,
490    truncated: bool,
491    total_count: usize,
492    shown_count: usize,
493) -> String {
494    use colored::Colorize;
495
496    let mut output = String::new();
497
498    output.push_str(&format!(
499        "Dead Code Analysis\n\nDefinitely dead: {} / {} functions ({:.1}% dead)\n",
500        report.total_dead.to_string().red(),
501        report.total_functions,
502        report.dead_percentage
503    ));
504
505    if report.total_possibly_dead > 0 {
506        output.push_str(&format!(
507            "Possibly dead (public but uncalled): {}\n",
508            report.total_possibly_dead.to_string().yellow()
509        ));
510    }
511
512    output.push('\n');
513
514    if !report.by_file.is_empty() {
515        output.push_str("Definitely dead:\n");
516        for (file, funcs) in &report.by_file {
517            output.push_str(&format!("{}\n", file.display().to_string().green()));
518            for func in funcs {
519                output.push_str(&format!("  - {}\n", func.red()));
520            }
521            output.push('\n');
522        }
523    }
524
525    if truncated {
526        output.push_str(&format!(
527            "\n[{}: showing {} of {} dead functions]\n",
528            "TRUNCATED".yellow(),
529            shown_count,
530            total_count
531        ));
532    }
533
534    output
535}