use perl_workspace::workspace_index::{SymbolKind, WorkspaceIndex, fs_path_to_uri, uri_to_fs_path};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeadCodeType {
UnusedSubroutine,
UnusedVariable,
UnusedConstant,
UnusedPackage,
UnreachableCode,
DeadBranch,
UnusedImport,
UnusedExport,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeadCode {
pub code_type: DeadCodeType,
pub name: Option<String>,
pub file_path: PathBuf,
pub start_line: usize,
pub end_line: usize,
pub reason: String,
pub confidence: f32,
pub suggestion: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeadCodeAnalysis {
pub dead_code: Vec<DeadCode>,
pub stats: DeadCodeStats,
pub files_analyzed: usize,
pub total_lines: usize,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DeadCodeStats {
pub unused_subroutines: usize,
pub unused_variables: usize,
pub unused_constants: usize,
pub unused_packages: usize,
pub unreachable_statements: usize,
pub dead_branches: usize,
pub total_dead_lines: usize,
}
pub struct DeadCodeDetector {
workspace_index: WorkspaceIndex,
entry_points: HashSet<PathBuf>,
}
impl DeadCodeDetector {
pub fn new(workspace_index: WorkspaceIndex) -> Self {
Self { workspace_index, entry_points: HashSet::new() }
}
pub fn add_entry_point(&mut self, path: PathBuf) {
self.entry_points.insert(path);
}
pub fn analyze_file(&self, file_path: &Path) -> Result<Vec<DeadCode>, String> {
let uri = fs_path_to_uri(file_path).map_err(|e| e.to_string())?;
let text = self
.workspace_index
.document_store()
.get_text(&uri)
.ok_or_else(|| "file not indexed".to_string())?;
let mut dead = Vec::new();
let mut terminator: Option<(usize, String)> = None;
for (i, line) in text.lines().enumerate() {
let trimmed = line.trim();
if let Some((term_line, term_kw)) = &terminator {
if !trimmed.is_empty() {
dead.push(DeadCode {
code_type: DeadCodeType::UnreachableCode,
name: None,
file_path: file_path.to_path_buf(),
start_line: i + 1,
end_line: i + 1,
reason: format!(
"Code is unreachable after `{}` on line {}",
term_kw, term_line
),
confidence: 0.5,
suggestion: Some("Remove or restructure this code".to_string()),
});
break;
}
}
if ["return", "die", "exit"].iter().any(|kw| trimmed.starts_with(kw)) {
if let Some(first_word) = trimmed.split_whitespace().next() {
terminator = Some((i + 1, first_word.to_string()));
}
}
}
detect_dead_branches(file_path, &text, &mut dead);
Ok(dead)
}
pub fn analyze_workspace(&self) -> DeadCodeAnalysis {
let docs = self.workspace_index.document_store().all_documents();
let mut dead_code = Vec::new();
let mut total_lines = 0;
for doc in &docs {
total_lines += doc.text.lines().count();
if let Some(path) = uri_to_fs_path(&doc.uri) {
if let Ok(mut file_dead) = self.analyze_file(&path) {
dead_code.append(&mut file_dead);
}
}
}
for sym in self.workspace_index.find_unused_symbols() {
let code_type = match sym.kind {
SymbolKind::Subroutine => DeadCodeType::UnusedSubroutine,
SymbolKind::Variable(_) => DeadCodeType::UnusedVariable,
SymbolKind::Constant => DeadCodeType::UnusedConstant,
SymbolKind::Package => DeadCodeType::UnusedPackage,
_ => continue,
};
let file_path = uri_to_fs_path(&sym.uri).unwrap_or_else(|| PathBuf::from(&sym.uri));
dead_code.push(DeadCode {
code_type,
name: Some(sym.name.clone()),
file_path,
start_line: sym.range.start.line as usize + 1,
end_line: sym.range.end.line as usize + 1,
reason: "Symbol is never used".to_string(),
confidence: 0.9,
suggestion: Some("Remove or use this symbol".to_string()),
});
}
let mut stats = DeadCodeStats::default();
for item in &dead_code {
let lines = item.end_line.saturating_sub(item.start_line) + 1;
stats.total_dead_lines += lines;
match item.code_type {
DeadCodeType::UnusedSubroutine => stats.unused_subroutines += 1,
DeadCodeType::UnusedVariable => stats.unused_variables += 1,
DeadCodeType::UnusedConstant => stats.unused_constants += 1,
DeadCodeType::UnusedPackage => stats.unused_packages += 1,
DeadCodeType::UnreachableCode => stats.unreachable_statements += 1,
DeadCodeType::DeadBranch => stats.dead_branches += 1,
_ => {}
}
}
DeadCodeAnalysis { dead_code, stats, files_analyzed: docs.len(), total_lines }
}
}
fn is_always_false(condition: &str) -> bool {
let c = condition.trim();
matches!(c, "0" | "\"\"" | "''" | "undef")
|| (c.starts_with('(') && c.ends_with(')') && is_always_false(&c[1..c.len() - 1]))
}
fn is_always_true(condition: &str) -> bool {
let c = condition.trim();
if c.parse::<i64>().is_ok_and(|n| n != 0) {
return true;
}
if c.parse::<f64>().is_ok_and(|n| n != 0.0) {
return true;
}
if (c.starts_with('"') && c.ends_with('"') || c.starts_with('\'') && c.ends_with('\''))
&& c.len() > 2
{
let inner = &c[1..c.len() - 1];
return inner != "0";
}
c.starts_with('(') && c.ends_with(')') && is_always_true(&c[1..c.len() - 1])
}
fn detect_dead_branches(file_path: &Path, text: &str, out: &mut Vec<DeadCode>) {
let lines: Vec<&str> = text.lines().collect();
let n = lines.len();
let mut i = 0;
while i < n {
let trimmed = lines[i].trim();
let dead_reason_and_keyword: Option<(String, &str)> = 'detect: {
for kw in &["if", "while", "elsif", "unless", "until", "for", "foreach"] {
let rest = match trimmed.strip_prefix(kw) {
Some(r)
if r.is_empty()
|| r.starts_with(|c: char| c.is_whitespace() || c == '(') =>
{
r.trim_start()
}
_ => continue,
};
if !rest.starts_with('(') {
continue;
}
let condition = extract_balanced_parens(rest);
let condition = match condition {
Some(c) => c,
None => continue,
};
let after_cond = rest[condition.len() + 2..].trim(); if !after_cond.starts_with('{') && !after_cond.is_empty() {
continue;
}
let inner = condition.trim();
let reason = if matches!(*kw, "unless" | "until") {
if is_always_true(inner) {
Some(format!(
"`{kw}` condition `{inner}` is always true — block is never executed"
))
} else {
None
}
} else {
if is_always_false(inner) {
Some(format!(
"`{kw}` condition `{inner}` is always false — block is never executed"
))
} else {
None
}
};
if let Some(r) = reason {
break 'detect Some((r, *kw));
}
}
None
};
if let Some((reason, _kw)) = dead_reason_and_keyword {
let block_start = i + 1; let end_line = find_block_end(&lines, i);
out.push(DeadCode {
code_type: DeadCodeType::DeadBranch,
name: None,
file_path: file_path.to_path_buf(),
start_line: block_start,
end_line,
reason,
confidence: 0.9,
suggestion: Some("Remove this dead branch or fix the condition".to_string()),
});
i = end_line;
continue;
}
i += 1;
}
}
fn extract_balanced_parens(s: &str) -> Option<&str> {
if !s.starts_with('(') {
return None;
}
let mut depth = 0usize;
for (idx, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(&s[1..idx]);
}
}
_ => {}
}
}
None
}
fn find_block_end(lines: &[&str], open_line: usize) -> usize {
let mut depth = 0i32;
for (i, line) in lines.iter().enumerate().skip(open_line) {
for ch in line.chars() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return i + 1; }
}
_ => {}
}
}
}
lines.len() }
pub fn generate_report(analysis: &DeadCodeAnalysis) -> String {
let mut report = String::new();
report.push_str("=== Dead Code Analysis Report ===\n\n");
report.push_str(&format!("Files analyzed: {}\n", analysis.files_analyzed));
report.push_str(&format!("Total lines: {}\n", analysis.total_lines));
report.push_str(&format!("Dead code items: {}\n\n", analysis.dead_code.len()));
report.push_str("Statistics:\n");
report.push_str(&format!(" Unused subroutines: {}\n", analysis.stats.unused_subroutines));
report.push_str(&format!(" Unused variables: {}\n", analysis.stats.unused_variables));
report.push_str(&format!(" Unused constants: {}\n", analysis.stats.unused_constants));
report.push_str(&format!(" Unused packages: {}\n", analysis.stats.unused_packages));
report.push_str(&format!(
" Unreachable statements: {}\n",
analysis.stats.unreachable_statements
));
report.push_str(&format!(" Dead branches: {}\n", analysis.stats.dead_branches));
report.push_str(&format!(" Total dead lines: {}\n", analysis.stats.total_dead_lines));
report
}