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 let language = self
67 .lang
68 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
69
70 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 let (truncated_report, truncated, total_count, shown_count) =
84 apply_truncation(report, self.max_items);
85
86 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 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 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 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 let (truncated_report, truncated, total_count, shown_count) =
153 apply_truncation(report, self.max_items);
154
155 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
178fn 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 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
211fn 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
237fn 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 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 if let Ok(source) = std::fs::read_to_string(file_path) {
285 tag_directive_functions(&mut info, &source, file_path);
286 }
287 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
303pub(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 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 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 if let Ok(mut info) =
360 extract_from_tree(&tree, &source, lang, file_path, Some(path))
361 {
362 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 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#[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
401fn 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 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
430fn 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}