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        // Determine language (auto-detect from directory, default to Python)
66        let language = self
67            .lang
68            .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
69
70        // Try daemon first for cached result
71        let entry_points: Option<Vec<String>> = if self.entry_points.is_empty() {
72            None
73        } else {
74            Some(self.entry_points.clone())
75        };
76
77        if let Some(report) = try_daemon_route::<DeadCodeReport>(
78            &self.path,
79            "dead",
80            params_for_dead(Some(&self.path), entry_points.as_deref()),
81        ) {
82            // Apply truncation if needed
83            let (truncated_report, truncated, total_count, shown_count) =
84                apply_truncation(report, self.max_items);
85
86            // Output based on format
87            if writer.is_text() {
88                let text = format_dead_code_text_truncated(
89                    &truncated_report,
90                    truncated,
91                    total_count,
92                    shown_count,
93                );
94                writer.write_text(&text)?;
95                return Ok(());
96            } else {
97                let output = DeadCodeOutput {
98                    report: truncated_report,
99                    truncated,
100                    total_count,
101                    shown_count,
102                };
103                writer.write(&output)?;
104                return Ok(());
105            }
106        }
107
108        // Fallback to direct compute
109        let entry_points_for_analysis: Option<Vec<String>> = if self.entry_points.is_empty() {
110            None
111        } else {
112            Some(self.entry_points.clone())
113        };
114
115        let report = if self.call_graph {
116            // Old path: build call graph, then analyze
117            writer.progress(&format!(
118                "Building call graph for {} ({:?})...",
119                self.path.display(),
120                language
121            ));
122
123            let graph = build_project_call_graph(&self.path, language, None, true)?;
124
125            writer.progress("Extracting all functions...");
126            let module_infos = collect_module_infos(&self.path, language, self.no_default_ignore);
127            let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
128
129            writer.progress("Analyzing dead code (call graph)...");
130            dead_code_analysis(&graph, &all_functions, entry_points_for_analysis.as_deref())?
131        } else {
132            // New default path: reference counting (single-pass)
133            writer.progress(&format!(
134                "Scanning {} ({:?}) with reference counting...",
135                self.path.display(),
136                language
137            ));
138
139            let (module_infos, merged_ref_counts) =
140                collect_module_infos_with_refcounts(&self.path, language, self.no_default_ignore);
141            let all_functions: Vec<FunctionRef> = collect_all_functions(&module_infos);
142
143            writer.progress("Analyzing dead code (refcount)...");
144            dead_code_analysis_refcount(
145                &all_functions,
146                &merged_ref_counts,
147                entry_points_for_analysis.as_deref(),
148            )?
149        };
150
151        // Apply truncation if needed
152        let (truncated_report, truncated, total_count, shown_count) =
153            apply_truncation(report, self.max_items);
154
155        // Output based on format
156        if writer.is_text() {
157            let text = format_dead_code_text_truncated(
158                &truncated_report,
159                truncated,
160                total_count,
161                shown_count,
162            );
163            writer.write_text(&text)?;
164        } else {
165            let output = DeadCodeOutput {
166                report: truncated_report,
167                truncated,
168                total_count,
169                shown_count,
170            };
171            writer.write(&output)?;
172        }
173
174        Ok(())
175    }
176}
177
178/// Check if JS/TS source has a file-level 'use server' or 'use client' directive.
179/// This is checked on the source string directly (no file I/O) to avoid path resolution issues.
180fn source_has_framework_directive(source: &str, ext: &str) -> bool {
181    if !matches!(ext, "ts" | "tsx" | "js" | "jsx" | "mjs") {
182        return false;
183    }
184    for line in source.lines().take(5) {
185        let trimmed = line.trim();
186        if trimmed == r#""use server""#
187            || trimmed == r#"'use server'"#
188            || trimmed == r#""use server";"#
189            || trimmed == r#"'use server';"#
190            || trimmed == r#""use client""#
191            || trimmed == r#"'use client'"#
192            || trimmed == r#""use client";"#
193            || trimmed == r#"'use client';"#
194        {
195            return true;
196        }
197        // Skip empty lines and comments
198        if !trimmed.is_empty()
199            && !trimmed.starts_with("//")
200            && !trimmed.starts_with("/*")
201            && !trimmed.starts_with('*')
202            && !trimmed.starts_with('"')
203            && !trimmed.starts_with('\'')
204        {
205            break;
206        }
207    }
208    false
209}
210
211/// Tag all functions and class methods in a ModuleInfo with a synthetic decorator
212/// if the source contains a framework directive ('use server'/'use client').
213fn tag_directive_functions(info: &mut ModuleInfo, source: &str, path: &Path) {
214    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
215    if source_has_framework_directive(source, ext) {
216        for func in &mut info.functions {
217            if !func
218                .decorators
219                .contains(&"use_server_directive".to_string())
220            {
221                func.decorators.push("use_server_directive".to_string());
222            }
223        }
224        for class in &mut info.classes {
225            for method in &mut class.methods {
226                if !method
227                    .decorators
228                    .contains(&"use_server_directive".to_string())
229                {
230                    method.decorators.push("use_server_directive".to_string());
231                }
232            }
233        }
234    }
235}
236
237/// Collect ModuleInfo from all files in a directory using detailed AST extraction.
238///
239/// This provides the enriched function metadata (decorators, visibility, etc.)
240/// needed for accurate dead code analysis with low false-positive rates.
241fn collect_module_infos(
242    path: &Path,
243    language: Language,
244    no_default_ignore: bool,
245) -> Vec<(PathBuf, ModuleInfo)> {
246    let mut module_infos = Vec::new();
247
248    if path.is_file() {
249        if let Ok(mut info) = extract_file(path, path.parent()) {
250            if let Ok(source) = std::fs::read_to_string(path) {
251                tag_directive_functions(&mut info, &source, path);
252            }
253            // Use filename only for single files (matches call graph convention)
254            let rel_path = path
255                .file_name()
256                .map(PathBuf::from)
257                .unwrap_or_else(|| path.to_path_buf());
258            module_infos.push((rel_path, info));
259        }
260    } else {
261        let extensions: &[&str] = language.extensions();
262        let mut file_count: usize = 0;
263        let mut walker = ProjectWalker::new(path);
264        if no_default_ignore {
265            walker = walker.no_default_ignore();
266        }
267        for entry in walker.iter() {
268            let file_path = entry.path();
269            if file_path.is_file() {
270                if let Some(ext_str) = file_path.extension().and_then(|e| e.to_str()) {
271                    let dotted = format!(".{}", ext_str);
272                    if extensions.contains(&dotted.as_str()) {
273                        file_count += 1;
274                        if file_count > MAX_FILES {
275                            eprintln!(
276                                "Warning: dead code scan truncated at {} files in {}",
277                                MAX_FILES,
278                                path.display()
279                            );
280                            break;
281                        }
282                        if let Ok(mut info) = extract_file(file_path, Some(path)) {
283                            // Tag functions with framework directive from source
284                            if let Ok(source) = std::fs::read_to_string(file_path) {
285                                tag_directive_functions(&mut info, &source, file_path);
286                            }
287                            // Use relative path to match call graph edge convention
288                            let rel_path = file_path
289                                .strip_prefix(path)
290                                .unwrap_or(file_path)
291                                .to_path_buf();
292                            module_infos.push((rel_path, info));
293                        }
294                    }
295                }
296            }
297        }
298    }
299
300    module_infos
301}
302
303/// Collect ModuleInfo AND identifier reference counts in a single pass.
304///
305/// For each file, we parse once with tree-sitter and then run both:
306/// - `extract_from_tree()` to get ModuleInfo (functions, classes, imports)
307/// - `count_identifiers_in_tree()` to get identifier occurrence counts
308///
309/// The identifier counts are merged into a single project-wide HashMap.
310pub(crate) fn collect_module_infos_with_refcounts(
311    path: &Path,
312    language: Language,
313    no_default_ignore: bool,
314) -> (Vec<(PathBuf, ModuleInfo)>, HashMap<String, usize>) {
315    let mut module_infos = Vec::new();
316    let mut merged_counts: HashMap<String, usize> = HashMap::new();
317
318    if path.is_file() {
319        if let Ok((tree, source, lang)) = parse_file(path) {
320            // Extract ModuleInfo from the parsed tree
321            if let Ok(mut info) = extract_from_tree(&tree, &source, lang, path, path.parent()) {
322                tag_directive_functions(&mut info, &source, path);
323                let rel_path = path
324                    .file_name()
325                    .map(PathBuf::from)
326                    .unwrap_or_else(|| path.to_path_buf());
327                module_infos.push((rel_path, info));
328            }
329            // Count identifiers from the same parsed tree
330            let file_counts = count_identifiers_in_tree(&tree, source.as_bytes(), lang);
331            for (name, count) in file_counts {
332                *merged_counts.entry(name).or_insert(0) += count;
333            }
334        }
335    } else {
336        let extensions: &[&str] = language.extensions();
337        let mut file_count: usize = 0;
338        let mut walker = ProjectWalker::new(path);
339        if no_default_ignore {
340            walker = walker.no_default_ignore();
341        }
342        for entry in walker.iter() {
343            let file_path = entry.path();
344            if file_path.is_file() {
345                if let Some(ext_str) = file_path.extension().and_then(|e| e.to_str()) {
346                    let dotted = format!(".{}", ext_str);
347                    if extensions.contains(&dotted.as_str()) {
348                        file_count += 1;
349                        if file_count > MAX_FILES {
350                            eprintln!(
351                                "Warning: born-dead scan truncated at {} files in {}",
352                                MAX_FILES,
353                                path.display()
354                            );
355                            break;
356                        }
357                        if let Ok((tree, source, lang)) = parse_file(file_path) {
358                            // Extract ModuleInfo from the parsed tree
359                            if let Ok(mut info) =
360                                extract_from_tree(&tree, &source, lang, file_path, Some(path))
361                            {
362                                // Tag functions with framework directive while we have the source
363                                tag_directive_functions(&mut info, &source, file_path);
364                                let rel_path = file_path
365                                    .strip_prefix(path)
366                                    .unwrap_or(file_path)
367                                    .to_path_buf();
368                                module_infos.push((rel_path, info));
369                            }
370                            // Count identifiers from the same parsed tree
371                            let file_counts =
372                                count_identifiers_in_tree(&tree, source.as_bytes(), lang);
373                            for (name, count) in file_counts {
374                                *merged_counts.entry(name).or_insert(0) += count;
375                            }
376                        }
377                    }
378                }
379            }
380        }
381    }
382
383    (module_infos, merged_counts)
384}
385
386/// Wrapper struct for JSON output with truncation metadata.
387#[derive(Serialize)]
388struct DeadCodeOutput {
389    #[serde(flatten)]
390    report: DeadCodeReport,
391    #[serde(skip_serializing_if = "is_false", default)]
392    truncated: bool,
393    total_count: usize,
394    shown_count: usize,
395}
396
397fn is_false(b: &bool) -> bool {
398    !b
399}
400
401/// Apply truncation to the report based on max_items.
402fn apply_truncation(
403    mut report: DeadCodeReport,
404    max_items: usize,
405) -> (DeadCodeReport, bool, usize, usize) {
406    let total_count = report.dead_functions.len();
407
408    if total_count > max_items {
409        report.dead_functions.truncate(max_items);
410        // Also truncate by_file to match
411        let mut count = 0;
412        let mut new_by_file = std::collections::HashMap::new();
413        for (path, funcs) in report.by_file {
414            let remaining = max_items - count;
415            if remaining == 0 {
416                break;
417            }
418            let to_take = funcs.len().min(remaining);
419            let truncated_funcs: Vec<String> = funcs.into_iter().take(to_take).collect();
420            count += truncated_funcs.len();
421            new_by_file.insert(path, truncated_funcs);
422        }
423        report.by_file = new_by_file;
424        (report, true, total_count, max_items)
425    } else {
426        (report, false, total_count, total_count)
427    }
428}
429
430/// Format dead code report with optional truncation notice.
431fn format_dead_code_text_truncated(
432    report: &DeadCodeReport,
433    truncated: bool,
434    total_count: usize,
435    shown_count: usize,
436) -> String {
437    use colored::Colorize;
438
439    let mut output = String::new();
440
441    output.push_str(&format!(
442        "Dead Code Analysis\n\nDefinitely dead: {} / {} functions ({:.1}% dead)\n",
443        report.total_dead.to_string().red(),
444        report.total_functions,
445        report.dead_percentage
446    ));
447
448    if report.total_possibly_dead > 0 {
449        output.push_str(&format!(
450            "Possibly dead (public but uncalled): {}\n",
451            report.total_possibly_dead.to_string().yellow()
452        ));
453    }
454
455    output.push('\n');
456
457    if !report.by_file.is_empty() {
458        output.push_str("Definitely dead:\n");
459        for (file, funcs) in &report.by_file {
460            output.push_str(&format!("{}\n", file.display().to_string().green()));
461            for func in funcs {
462                output.push_str(&format!("  - {}\n", func.red()));
463            }
464            output.push('\n');
465        }
466    }
467
468    if truncated {
469        output.push_str(&format!(
470            "\n[{}: showing {} of {} dead functions]\n",
471            "TRUNCATED".yellow(),
472            shown_count,
473            total_count
474        ));
475    }
476
477    output
478}