1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use clap::Args;
11use serde::Serialize;
12use walkdir::WalkDir;
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
56impl DeadArgs {
57 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
59 let writer = OutputWriter::new(format, quiet);
60
61 let language = self
63 .lang
64 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
65
66 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 let (truncated_report, truncated, total_count, shown_count) =
80 apply_truncation(report, self.max_items);
81
82 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 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 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 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 let (truncated_report, truncated, total_count, shown_count) =
149 apply_truncation(report, self.max_items);
150
151 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
174fn 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 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
207fn 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
227fn 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 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 if let Ok(source) = std::fs::read_to_string(file_path) {
271 tag_directive_functions(&mut info, &source, file_path);
272 }
273 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
289pub(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 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 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 if let Ok(mut info) =
345 extract_from_tree(&tree, &source, lang, file_path, Some(path))
346 {
347 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 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#[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
386fn 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 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
415fn 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}