//! Unused imports detector - fast graph-based alternative to pylint.
//!
//! Detects imports that are never referenced in the codebase, indicating
//! dead code that should be cleaned up.
use std::collections::HashSet;
use crate::detectors::base::{Detector, DetectorConfig, DetectorResult};
use crate::graph::GraphClient;
use crate::models::{Finding, Severity};
/// Unused imports detector
///
/// An unused import is one that appears in an IMPORTS relationship
/// but is never referenced via:
/// - Function calls (CALLS)
/// - Class inheritance (INHERITS)
/// - Attribute access
/// - Type annotations
pub struct UnusedImportsDetector {
config: DetectorConfig,
/// Patterns to ignore
ignore_patterns: Vec<String>,
/// Maximum findings to report
max_findings: usize,
}
impl UnusedImportsDetector {
/// Create a new unused imports detector
pub fn new() -> Self {
Self {
config: DetectorConfig::default(),
ignore_patterns: vec![
"__future__".to_string(),
"typing".to_string(),
"typing_extensions".to_string(),
"__init__".to_string(),
"annotations".to_string(),
],
max_findings: 100,
}
}
/// Add patterns to ignore
pub fn with_ignore_patterns(mut self, patterns: Vec<String>) -> Self {
self.ignore_patterns.extend(patterns);
self
}
/// Set max findings
pub fn with_max_findings(mut self, max: usize) -> Self {
self.max_findings = max;
self
}
/// Check if import should be ignored
fn should_ignore(&self, name: &str) -> bool {
if name.is_empty() {
return true;
}
let name_lower = name.to_lowercase();
self.ignore_patterns
.iter()
.any(|p| name_lower.contains(&p.to_lowercase()))
}
/// Check if an imported module is referenced
fn is_referenced(imported: &str, referenced: &HashSet<String>) -> bool {
// Exact match
if referenced.contains(imported) {
return true;
}
// Check if any referenced entity starts with this import
// (e.g., import foo, use foo.bar)
let prefix = format!("{}.", imported);
for r in referenced {
if r.starts_with(&prefix) {
return true;
}
}
// Check if this import is a sub-path of something referenced
let parts: Vec<&str> = imported.split('.').collect();
for i in 1..parts.len() {
let partial = parts[..i].join(".");
if referenced.contains(&partial) {
return true;
}
}
false
}
/// Detect unused imports via graph queries
fn detect_from_graph(&self, graph: &GraphClient) -> anyhow::Result<Vec<Finding>> {
// Get all imports
let imports_query = r#"
MATCH (importer)-[:IMPORTS]->(imported)
WHERE importer.qualifiedName IS NOT NULL
AND imported.qualifiedName IS NOT NULL
RETURN
imported.qualifiedName AS imported_name,
importer.qualifiedName AS importer_name,
importer.filePath AS file_path
"#;
// Get all referenced entities (called or inherited)
let refs_query = r#"
MATCH ()-[r:CALLS|INHERITS]->(target)
WHERE target.qualifiedName IS NOT NULL
RETURN DISTINCT target.qualifiedName AS ref_name
"#;
let imports_results = graph.execute(imports_query)?;
let refs_results = graph.execute(refs_query)?;
// Build set of referenced names
let mut referenced: HashSet<String> = HashSet::new();
for row in refs_results {
if let Some(ref_name) = row.get_string("ref_name") {
referenced.insert(ref_name);
}
}
// Find unused imports
let mut findings = Vec::new();
for row in imports_results {
let imported_name = row.get_string("imported_name").unwrap_or_default();
if self.should_ignore(&imported_name) {
continue;
}
if !Self::is_referenced(&imported_name, &referenced) {
if findings.len() >= self.max_findings {
break;
}
let finding = self.create_finding(
&imported_name,
&row.get_string("importer_name").unwrap_or_default(),
&row.get_string("file_path").unwrap_or_default(),
);
findings.push(finding);
}
}
// Sort by file path for consistent output
findings.sort_by(|a, b| {
let file_a = a.affected_files.first().map(|s| s.as_str()).unwrap_or("");
let file_b = b.affected_files.first().map(|s| s.as_str()).unwrap_or("");
file_a.cmp(file_b).then_with(|| a.title.cmp(&b.title))
});
Ok(findings)
}
fn create_finding(
&self,
imported_name: &str,
importer_file: &str,
file_path: &str,
) -> Finding {
let simple_name = imported_name.split('.').last().unwrap_or(imported_name);
let description = format!(
"Import '{}' is not used in '{}'. \
Unused imports add clutter and can slow down module loading.",
imported_name, file_path
);
let recommendation = format!(
"Remove the unused import:\n\
- Delete the import statement for '{}'\n\
- Or if it's needed for type checking, use:\n\
if TYPE_CHECKING:\n\
from ... import {}",
simple_name, simple_name
);
Finding {
id: format!(
"unused_import_{}_{}",
importer_file.replace('.', "_"),
imported_name.replace('.', "_")
),
detector: "UnusedImportsDetector".to_string(),
severity: Severity::Low,
title: format!("Unused import: {}", simple_name),
description,
affected_nodes: vec![imported_name.to_string()],
affected_files: if file_path.is_empty() || file_path == "unknown" {
vec![]
} else {
vec![file_path.to_string()]
},
line_start: None,
line_end: None,
suggested_fix: Some(recommendation),
estimated_effort: Some("Trivial (1-5 minutes)".to_string()),
confidence: 0.85,
tags: vec![
"unused_import".to_string(),
"dead_code".to_string(),
"cleanup".to_string(),
],
metadata: serde_json::json!({
"imported_name": imported_name,
"importer": importer_file,
}),
}
}
}
impl Default for UnusedImportsDetector {
fn default() -> Self {
Self::new()
}
}
impl Detector for UnusedImportsDetector {
fn name(&self) -> &'static str {
"UnusedImportsDetector"
}
fn description(&self) -> &'static str {
"Detects imports that are never used in the codebase"
}
fn detect(&self, graph: &GraphClient) -> DetectorResult {
self.detect_from_graph(graph)
}
fn is_dependent(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_ignore() {
let detector = UnusedImportsDetector::new();
assert!(detector.should_ignore("__future__"));
assert!(detector.should_ignore("typing.Optional"));
assert!(!detector.should_ignore("os.path"));
}
#[test]
fn test_is_referenced() {
let mut referenced = HashSet::new();
referenced.insert("foo.bar".to_string());
referenced.insert("baz".to_string());
assert!(UnusedImportsDetector::is_referenced("foo.bar", &referenced));
assert!(UnusedImportsDetector::is_referenced("foo", &referenced));
assert!(UnusedImportsDetector::is_referenced("baz", &referenced));
assert!(!UnusedImportsDetector::is_referenced("qux", &referenced));
}
}