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