1use perl_workspace::workspace_index::{SymbolKind, WorkspaceIndex, fs_path_to_uri, uri_to_fs_path};
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum DeadCodeType {
14 UnusedSubroutine,
16 UnusedVariable,
18 UnusedConstant,
20 UnusedPackage,
22 UnreachableCode,
24 DeadBranch,
26 UnusedImport,
28 UnusedExport,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DeadCode {
35 pub code_type: DeadCodeType,
37 pub name: Option<String>,
39 pub file_path: PathBuf,
41 pub start_line: usize,
43 pub end_line: usize,
45 pub reason: String,
47 pub confidence: f32,
49 pub suggestion: Option<String>,
51}
52
53#[derive(Debug, Serialize, Deserialize)]
55pub struct DeadCodeAnalysis {
56 pub dead_code: Vec<DeadCode>,
58 pub stats: DeadCodeStats,
60 pub files_analyzed: usize,
62 pub total_lines: usize,
64}
65
66#[derive(Debug, Default, Serialize, Deserialize)]
68pub struct DeadCodeStats {
69 pub unused_subroutines: usize,
71 pub unused_variables: usize,
73 pub unused_constants: usize,
75 pub unused_packages: usize,
77 pub unreachable_statements: usize,
79 pub dead_branches: usize,
81 pub total_dead_lines: usize,
83}
84
85pub struct DeadCodeDetector {
87 workspace_index: WorkspaceIndex,
88 entry_points: HashSet<PathBuf>,
89}
90
91impl DeadCodeDetector {
92 pub fn new(workspace_index: WorkspaceIndex) -> Self {
97 Self { workspace_index, entry_points: HashSet::new() }
98 }
99
100 pub fn add_entry_point(&mut self, path: PathBuf) {
102 self.entry_points.insert(path);
103 }
104
105 pub fn analyze_file(&self, file_path: &Path) -> Result<Vec<DeadCode>, String> {
107 let uri = fs_path_to_uri(file_path).map_err(|e| e.to_string())?;
108 let text = self
109 .workspace_index
110 .document_store()
111 .get_text(&uri)
112 .ok_or_else(|| "file not indexed".to_string())?;
113
114 let mut dead = Vec::new();
115 let mut block_depth = 0usize;
116 let mut terminator: Option<(usize, usize, String)> = None;
117
118 for (i, line) in text.lines().enumerate() {
119 let trimmed = line.trim();
120 let current_depth = block_depth;
121
122 if let Some((term_line, term_depth, term_kw)) = &terminator {
123 if current_depth < *term_depth {
124 terminator = None;
125 } else if current_depth == *term_depth
126 && !trimmed.is_empty()
127 && !trimmed.starts_with('#')
128 && !is_structural_line(trimmed)
129 {
130 dead.push(DeadCode {
131 code_type: DeadCodeType::UnreachableCode,
132 name: None,
133 file_path: file_path.to_path_buf(),
134 start_line: i + 1,
135 end_line: i + 1,
136 reason: format!(
137 "Code is unreachable after `{}` on line {}",
138 term_kw, term_line
139 ),
140 confidence: 0.9,
141 suggestion: Some("Remove or restructure this code".to_string()),
142 });
143 break;
144 }
145 }
146
147 if let Some(term_kw) = detect_unconditional_terminator(trimmed) {
148 terminator = Some((i + 1, current_depth, term_kw.to_string()));
149 }
150
151 block_depth += line.chars().filter(|&ch| ch == '{').count();
152 block_depth = block_depth.saturating_sub(line.chars().filter(|&ch| ch == '}').count());
153 }
154
155 detect_dead_branches(file_path, &text, &mut dead);
157
158 Ok(dead)
159 }
160
161 pub fn analyze_workspace(&self) -> DeadCodeAnalysis {
163 let docs = self.workspace_index.document_store().all_documents();
164 let mut dead_code = Vec::new();
165 let mut total_lines = 0;
166
167 for doc in &docs {
169 total_lines += doc.text.lines().count();
170 if let Some(path) = uri_to_fs_path(&doc.uri) {
171 if let Ok(mut file_dead) = self.analyze_file(&path) {
172 dead_code.append(&mut file_dead);
173 }
174 }
175 }
176
177 for sym in self.workspace_index.find_unused_symbols() {
179 let code_type = match sym.kind {
180 SymbolKind::Subroutine => DeadCodeType::UnusedSubroutine,
181 SymbolKind::Variable(_) => DeadCodeType::UnusedVariable,
182 SymbolKind::Constant => DeadCodeType::UnusedConstant,
183 SymbolKind::Package => DeadCodeType::UnusedPackage,
184 _ => continue,
185 };
186
187 let file_path = uri_to_fs_path(&sym.uri).unwrap_or_else(|| PathBuf::from(&sym.uri));
188
189 dead_code.push(DeadCode {
190 code_type,
191 name: Some(sym.name.clone()),
192 file_path,
193 start_line: sym.range.start.line as usize + 1,
194 end_line: sym.range.end.line as usize + 1,
195 reason: "Symbol is never used".to_string(),
196 confidence: 0.9,
197 suggestion: Some("Remove or use this symbol".to_string()),
198 });
199 }
200
201 let mut stats = DeadCodeStats::default();
203 for item in &dead_code {
204 let lines = item.end_line.saturating_sub(item.start_line) + 1;
205 stats.total_dead_lines += lines;
206 match item.code_type {
207 DeadCodeType::UnusedSubroutine => stats.unused_subroutines += 1,
208 DeadCodeType::UnusedVariable => stats.unused_variables += 1,
209 DeadCodeType::UnusedConstant => stats.unused_constants += 1,
210 DeadCodeType::UnusedPackage => stats.unused_packages += 1,
211 DeadCodeType::UnreachableCode => stats.unreachable_statements += 1,
212 DeadCodeType::DeadBranch => stats.dead_branches += 1,
213 _ => {}
214 }
215 }
216
217 DeadCodeAnalysis { dead_code, stats, files_analyzed: docs.len(), total_lines }
218 }
219}
220
221fn is_structural_line(trimmed: &str) -> bool {
222 !trimmed.is_empty() && trimmed.chars().all(|ch| ch == '}' || ch == ';')
223}
224
225fn detect_unconditional_terminator(trimmed: &str) -> Option<&str> {
226 const TERMINATORS: [&str; 4] = ["return", "die", "exit", "CORE::exit"];
227
228 let first = trimmed
229 .split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '('))
230 .next()
231 .unwrap_or_default();
232 if !TERMINATORS.contains(&first) {
233 return None;
234 }
235
236 let after_terminator = &trimmed[first.len()..];
237 let remainder = match after_terminator.split_once('#') {
238 Some((before_comment, _)) => before_comment,
239 None => after_terminator,
240 }
241 .trim_start();
242 if contains_postfix_modifier(remainder) {
243 return None;
244 }
245
246 Some(first)
247}
248
249fn contains_postfix_modifier(remainder: &str) -> bool {
250 const POSTFIX_MODIFIERS: [&str; 7] =
251 ["if", "unless", "when", "while", "until", "for", "foreach"];
252 POSTFIX_MODIFIERS.iter().any(|keyword| contains_keyword(remainder, keyword))
253}
254
255fn contains_keyword(text: &str, keyword: &str) -> bool {
256 text.match_indices(keyword).any(|(idx, _)| {
257 let before = text[..idx].chars().next_back();
258 let after = text[idx + keyword.len()..].chars().next();
259 is_keyword_boundary(before) && is_keyword_boundary(after)
260 })
261}
262
263fn is_keyword_boundary(ch: Option<char>) -> bool {
264 ch.is_none_or(|ch| !ch.is_ascii_alphanumeric() && ch != '_')
265}
266
267fn is_always_false(condition: &str) -> bool {
276 let c = strip_outer_parens(condition);
279 matches!(c, "0" | "\"\"" | "''" | "undef")
280}
281
282fn is_always_true(condition: &str) -> bool {
286 let c = strip_outer_parens(condition);
289 if c.parse::<i64>().is_ok_and(|n| n != 0) {
291 return true;
292 }
293 if c.parse::<f64>().is_ok_and(|n| n != 0.0) {
295 return true;
296 }
297 if (c.starts_with('"') && c.ends_with('"') || c.starts_with('\'') && c.ends_with('\''))
299 && c.len() > 2
300 {
301 let inner = &c[1..c.len() - 1];
302 return inner != "0";
303 }
304 false
305}
306
307fn strip_outer_parens(condition: &str) -> &str {
315 let mut s = condition.trim();
316 while s.starts_with('(') && s.ends_with(')') && s.len() >= 2 {
317 let inner = &s[1..s.len() - 1];
318 if is_outer_paren_balanced(inner) {
322 s = inner.trim();
323 } else {
324 break;
325 }
326 }
327 s
328}
329
330fn is_outer_paren_balanced(inner: &str) -> bool {
335 let mut depth = 0i32;
336 for ch in inner.chars() {
337 match ch {
338 '(' => depth += 1,
339 ')' => {
340 depth -= 1;
341 if depth < 0 {
342 return false;
343 }
344 }
345 _ => {}
346 }
347 }
348 true
349}
350
351fn detect_dead_branches(file_path: &Path, text: &str, out: &mut Vec<DeadCode>) {
365 let lines: Vec<&str> = text.lines().collect();
366 let n = lines.len();
367 let mut i = 0;
368
369 while i < n {
370 let trimmed = lines[i].trim();
371
372 let dead_reason_and_keyword: Option<(String, &str)> = 'detect: {
379 for kw in &["if", "while", "elsif", "unless", "until"] {
380 let rest = match trimmed.strip_prefix(kw) {
381 Some(r)
382 if r.is_empty()
383 || r.starts_with(|c: char| c.is_whitespace() || c == '(') =>
384 {
385 r.trim_start()
386 }
387 _ => continue,
388 };
389 if !rest.starts_with('(') {
391 continue;
392 }
393 let condition = extract_balanced_parens(rest);
394 let condition = match condition {
395 Some(c) => c,
396 None => continue,
397 };
398 let after_idx = condition.len() + 2;
402 let after_cond = match rest.get(after_idx..) {
403 Some(s) => s.trim(),
404 None => continue,
405 };
406 if !after_cond.starts_with('{') && !after_cond.is_empty() {
408 continue;
409 }
410 let inner = condition.trim();
411
412 let reason = if matches!(*kw, "unless" | "until") {
413 if is_always_true(inner) {
415 Some(format!(
416 "`{kw}` condition `{inner}` is always true — block is never executed"
417 ))
418 } else {
419 None
420 }
421 } else {
422 if is_always_false(inner) {
424 Some(format!(
425 "`{kw}` condition `{inner}` is always false — block is never executed"
426 ))
427 } else {
428 None
429 }
430 };
431
432 if let Some(r) = reason {
433 break 'detect Some((r, *kw));
434 }
435 }
436
437 None
443 };
444
445 if let Some((reason, _kw)) = dead_reason_and_keyword {
446 let block_start = i + 1; let end_line = find_block_end(&lines, i);
449 out.push(DeadCode {
450 code_type: DeadCodeType::DeadBranch,
451 name: None,
452 file_path: file_path.to_path_buf(),
453 start_line: block_start,
454 end_line,
455 reason,
456 confidence: 0.9,
457 suggestion: Some("Remove this dead branch or fix the condition".to_string()),
458 });
459 i = end_line;
461 continue;
462 }
463
464 i += 1;
465 }
466}
467
468fn extract_balanced_parens(s: &str) -> Option<&str> {
472 if !s.starts_with('(') {
473 return None;
474 }
475 let mut depth = 0usize;
476 for (idx, ch) in s.char_indices() {
477 match ch {
478 '(' => depth += 1,
479 ')' => {
480 depth -= 1;
481 if depth == 0 {
482 return Some(&s[1..idx]);
483 }
484 }
485 _ => {}
486 }
487 }
488 None
489}
490
491fn find_block_end(lines: &[&str], open_line: usize) -> usize {
496 let mut depth = 0i32;
497 for (i, line) in lines.iter().enumerate().skip(open_line) {
498 for ch in line.chars() {
499 match ch {
500 '{' => depth += 1,
501 '}' => {
502 depth -= 1;
503 if depth == 0 {
504 return i + 1; }
506 }
507 _ => {}
508 }
509 }
510 }
511 lines.len() }
513
514pub fn generate_report(analysis: &DeadCodeAnalysis) -> String {
516 let mut report = String::new();
517
518 report.push_str("=== Dead Code Analysis Report ===\n\n");
519
520 report.push_str(&format!("Files analyzed: {}\n", analysis.files_analyzed));
521 report.push_str(&format!("Total lines: {}\n", analysis.total_lines));
522 report.push_str(&format!("Dead code items: {}\n\n", analysis.dead_code.len()));
523
524 report.push_str("Statistics:\n");
525 report.push_str(&format!(" Unused subroutines: {}\n", analysis.stats.unused_subroutines));
526 report.push_str(&format!(" Unused variables: {}\n", analysis.stats.unused_variables));
527 report.push_str(&format!(" Unused constants: {}\n", analysis.stats.unused_constants));
528 report.push_str(&format!(" Unused packages: {}\n", analysis.stats.unused_packages));
529 report.push_str(&format!(
530 " Unreachable statements: {}\n",
531 analysis.stats.unreachable_statements
532 ));
533 report.push_str(&format!(" Dead branches: {}\n", analysis.stats.dead_branches));
534 report.push_str(&format!(" Total dead lines: {}\n", analysis.stats.total_dead_lines));
535
536 report
537}