1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use clap::Args;
11use serde::Serialize;
12use tldr_core::walker::ProjectWalker;
13
14const 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#[derive(Debug, Args)]
34pub struct DeadArgs {
35 #[arg(default_value = ".")]
37 pub path: PathBuf,
38
39 #[arg(long, short = 'l')]
41 pub lang: Option<Language>,
42
43 #[arg(long, short = 'e', value_delimiter = ',')]
45 pub entry_points: Vec<String>,
46
47 #[arg(long, default_value = "100")]
49 pub max_items: usize,
50
51 #[arg(long)]
53 pub call_graph: bool,
54
55 #[arg(long)]
57 pub no_default_ignore: bool,
58}
59
60impl DeadArgs {
61 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
63 let writer = OutputWriter::new(format, quiet);
64
65 if !self.path.exists() {
68 anyhow::bail!("Path not found: {}", self.path.display());
69 }
70
71 let language = self
73 .lang
74 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
75
76 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 let (truncated_report, truncated, total_count, shown_count) =
90 apply_truncation(report, self.max_items);
91
92 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); let output = DeadCodeOutput {
105 report: truncated_report,
106 truncated,
107 };
108 writer.write(&output)?;
109 return Ok(());
110 }
111 }
112
113 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 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 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 let (truncated_report, truncated, total_count, shown_count) =
158 apply_truncation(report, self.max_items);
159
160 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); let output = DeadCodeOutput {
172 report: truncated_report,
173 truncated,
174 };
175 writer.write(&output)?;
176 }
177
178 Ok(())
179 }
180}
181
182fn 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 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
215fn 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
241fn is_typescript_declaration_file(path: &Path) -> bool {
247 path.to_string_lossy().to_ascii_lowercase().ends_with(".d.ts")
248}
249
250fn 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 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 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 let extensions: &[&str] = language.scan_extensions();
282 let mut file_count: usize = 0;
283 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 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 if let Ok(source) = std::fs::read_to_string(file_path) {
317 tag_directive_functions(&mut info, &source, file_path);
318 }
319 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
335pub(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 if is_typescript_declaration_file(path) {
354 return (module_infos, merged_counts);
355 }
356 if let Ok((tree, source, lang)) = parse_file(path) {
357 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 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 let extensions: &[&str] = language.scan_extensions();
377 let mut file_count: usize = 0;
378 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 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 if let Ok(mut info) =
412 extract_from_tree(&tree, &source, lang, file_path, Some(path))
413 {
414 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 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#[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
458fn 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 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
487fn 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}