codelens_engine/
phantom_modules.rs1use crate::project::{collect_files, ProjectRoot};
14use anyhow::Result;
15use regex::Regex;
16use serde::Serialize;
17use std::collections::HashSet;
18use std::path::Path;
19use std::sync::LazyLock;
20
21static MOD_DECL_RE: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(r"(?m)^\s*(?P<vis>pub(?:\([^)]*\))?\s+)?mod\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*;")
24 .unwrap()
25});
26
27#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub struct PhantomModuleEntry {
29 pub parent_file: String,
30 pub module_name: String,
31 pub line: usize,
32 pub visibility: &'static str,
33 pub kind: &'static str,
34}
35
36pub fn find_phantom_modules(
51 project: &ProjectRoot,
52 max_results: usize,
53) -> Result<Vec<PhantomModuleEntry>> {
54 let mut declarations: Vec<PhantomModuleEntry> = Vec::new();
55 let mut referenced: HashSet<String> = HashSet::new();
56 let candidates = collect_files(project.as_path(), is_rust_file)?;
57
58 for path in &candidates {
59 let source = match std::fs::read_to_string(path) {
60 Ok(s) => s,
61 Err(_) => continue,
62 };
63 let relative = project.to_relative(path);
64 if is_excluded_path(&relative) {
65 continue;
66 }
67 scan_declarations(&source, &relative, &mut declarations);
68 collect_referenced_names(&source, &mut referenced);
69 }
70
71 let mut phantoms: Vec<PhantomModuleEntry> = declarations
72 .into_iter()
73 .filter(|d| !referenced.contains(&d.module_name))
74 .filter(|d| !is_test_module_name(&d.module_name))
75 .collect();
76
77 phantoms.sort_by(|a, b| {
78 a.parent_file
79 .cmp(&b.parent_file)
80 .then(a.line.cmp(&b.line))
81 .then(a.module_name.cmp(&b.module_name))
82 });
83 if max_results > 0 && phantoms.len() > max_results {
84 phantoms.truncate(max_results);
85 }
86 Ok(phantoms)
87}
88
89fn scan_declarations(source: &str, file: &str, out: &mut Vec<PhantomModuleEntry>) {
90 for caps in MOD_DECL_RE.captures_iter(source) {
91 let name = match caps.name("name") {
92 Some(m) => m.as_str().to_owned(),
93 None => continue,
94 };
95 let visibility = if caps.name("vis").is_some() {
96 "public"
97 } else {
98 "private"
99 };
100 let line = caps
101 .get(0)
102 .map(|m| source[..m.start()].matches('\n').count() + 1)
103 .unwrap_or(0);
104 out.push(PhantomModuleEntry {
105 parent_file: file.to_owned(),
106 module_name: name,
107 line,
108 visibility,
109 kind: "rust_mod_declaration",
110 });
111 }
112}
113
114fn collect_referenced_names(source: &str, into: &mut HashSet<String>) {
122 static LEADING_RE: LazyLock<Regex> =
123 LazyLock::new(|| Regex::new(r"([A-Za-z_][A-Za-z0-9_]*)::").unwrap());
124 static TRAILING_RE: LazyLock<Regex> =
125 LazyLock::new(|| Regex::new(r"::([A-Za-z_][A-Za-z0-9_]*)").unwrap());
126 for caps in LEADING_RE.captures_iter(source) {
127 if let Some(m) = caps.get(1) {
128 into.insert(m.as_str().to_owned());
129 }
130 }
131 for caps in TRAILING_RE.captures_iter(source) {
132 if let Some(m) = caps.get(1) {
133 into.insert(m.as_str().to_owned());
134 }
135 }
136}
137
138fn is_rust_file(path: &Path) -> bool {
139 path.extension().and_then(|s| s.to_str()) == Some("rs")
140}
141
142fn is_excluded_path(relative: &str) -> bool {
143 if relative == "crates/codelens-engine/src/phantom_modules.rs" {
144 return true;
145 }
146 let lower = relative.to_ascii_lowercase();
147 if lower.ends_with("_tests.rs") || lower.ends_with("_test.rs") {
148 return true;
149 }
150 lower.split('/').any(|seg| {
151 matches!(
152 seg,
153 "tests"
154 | "test"
155 | "bench"
156 | "benches"
157 | "examples"
158 | "fixtures"
159 | "integration_tests"
160 | "http_tests"
161 )
162 })
163}
164
165fn is_test_module_name(name: &str) -> bool {
166 name.ends_with("_tests") || name.ends_with("_test") || name == "tests" || name == "test"
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn detects_unreferenced_private_mod() {
175 let mut decls = Vec::new();
176 scan_declarations("mod ghost;\nmod live;\n", "lib.rs", &mut decls);
177 assert_eq!(decls.len(), 2);
178 assert_eq!(decls[0].module_name, "ghost");
179 assert_eq!(decls[0].visibility, "private");
180 assert_eq!(decls[1].module_name, "live");
181 }
182
183 #[test]
184 fn detects_pub_mod_as_public() {
185 let mut decls = Vec::new();
186 scan_declarations("pub mod api;\n", "lib.rs", &mut decls);
187 assert_eq!(decls.len(), 1);
188 assert_eq!(decls[0].visibility, "public");
189 }
190
191 #[test]
192 fn skips_inline_mod_blocks() {
193 let mut decls = Vec::new();
194 scan_declarations("mod inline { fn x() {} }\n", "lib.rs", &mut decls);
195 assert!(decls.is_empty(), "got: {:?}", decls);
197 }
198
199 #[test]
200 fn referenced_set_picks_up_path_segments() {
201 let mut set = HashSet::new();
202 collect_referenced_names("use crate::foo::bar;\nlet z = self::baz::x();\n", &mut set);
203 assert!(set.contains("foo"));
204 assert!(set.contains("bar"));
205 assert!(set.contains("baz"));
206 }
207
208 #[test]
209 fn referenced_set_picks_up_pub_use_with_braces() {
210 let mut set = HashSet::new();
215 collect_referenced_names(
216 "pub use dead_code::{DeadCodeEntryV2, find_dead_code, find_dead_code_v2};",
217 &mut set,
218 );
219 assert!(set.contains("dead_code"), "missing dead_code in {:?}", set);
220 }
221
222 #[test]
223 #[ignore]
224 fn dogfood_self_repo() {
225 let repo = std::env::var("CODELENS_REPO_ROOT")
227 .unwrap_or_else(|_| "/Users/bagjaeseog/codelens-mcp-plugin".to_owned());
228 let project = crate::project::ProjectRoot::new(repo).expect("project root");
229 let results = super::find_phantom_modules(&project, 200).expect("find_phantom_modules");
230 eprintln!("\n=== {} phantom mod declarations ===\n", results.len());
231 for r in &results {
232 eprintln!(
233 " {} (vis={}) at {}:{}",
234 r.module_name, r.visibility, r.parent_file, r.line
235 );
236 }
237 }
238
239 #[test]
240 fn is_excluded_path_skips_test_dirs() {
241 assert!(is_excluded_path("crates/foo/tests/x.rs"));
242 assert!(is_excluded_path("crates/foo/src/x_tests.rs"));
243 assert!(!is_excluded_path("crates/foo/src/lib.rs"));
244 assert!(is_excluded_path(
245 "crates/codelens-engine/src/phantom_modules.rs"
246 ));
247 }
248}