codelens_engine/
redundant_definitions.rs1use crate::project::{collect_files, ProjectRoot};
12use anyhow::Result;
13use regex::Regex;
14use serde::Serialize;
15use std::path::Path;
16use std::sync::LazyLock;
17
18static RUST_ONE_LINE_WRAPPER_RE: LazyLock<Regex> = LazyLock::new(|| {
24 Regex::new(
26 r#"(?m)^\s*(?:pub(?:\([^)]*\))?\s+)?fn\s+(?P<wrapper>[A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*(?:->\s*[^{]+?)?\s*\{\s*(?:self\.|Self::)?(?P<target>[A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*?(?P<default>None|Default::default\(\)|true|false|-?\d+|"[^"]*")?\s*\)\s*;?\s*\}\s*$"#
27 ).unwrap()
28});
29
30#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31pub struct RedundantDefinitionEntry {
32 pub file: String,
33 pub wrapper: String,
34 pub target: String,
35 pub line: usize,
36 pub default_arg: Option<String>,
37 pub kind: &'static str,
38}
39
40pub fn find_redundant_definitions(
46 project: &ProjectRoot,
47 max_results: usize,
48) -> Result<Vec<RedundantDefinitionEntry>> {
49 let mut results: Vec<RedundantDefinitionEntry> = Vec::new();
50 let candidates = collect_files(project.as_path(), is_rust_file)?;
51
52 for path in &candidates {
53 let source = match std::fs::read_to_string(path) {
54 Ok(s) => s,
55 Err(_) => continue,
56 };
57 let relative = project.to_relative(path);
58 if is_test_file(&relative) {
59 continue;
60 }
61 let production_source = strip_cfg_test_modules(&source);
62 scan_rust_source(&production_source, &relative, &mut results);
63 if max_results > 0 && results.len() >= max_results {
64 break;
65 }
66 }
67
68 results.sort_by(|a, b| {
69 a.target
70 .cmp(&b.target)
71 .then(a.file.cmp(&b.file))
72 .then(a.line.cmp(&b.line))
73 });
74 if max_results > 0 && results.len() > max_results {
75 results.truncate(max_results);
76 }
77 Ok(results)
78}
79
80fn scan_rust_source(source: &str, file: &str, out: &mut Vec<RedundantDefinitionEntry>) {
81 for m in RUST_ONE_LINE_WRAPPER_RE.captures_iter(source) {
82 let wrapper = m
83 .name("wrapper")
84 .map(|m| m.as_str().to_owned())
85 .unwrap_or_default();
86 let target = m
87 .name("target")
88 .map(|m| m.as_str().to_owned())
89 .unwrap_or_default();
90 if wrapper.is_empty() || target.is_empty() || wrapper == target {
91 continue;
92 }
93 let default_arg = m.name("default").map(|m| m.as_str().to_owned());
94 let line = byte_offset_to_line(source, m.get(0).map(|m| m.start()).unwrap_or(0));
95 out.push(RedundantDefinitionEntry {
96 file: file.to_owned(),
97 wrapper,
98 target,
99 line,
100 default_arg,
101 kind: "rust_one_line_wrapper",
102 });
103 }
104}
105
106fn byte_offset_to_line(source: &str, offset: usize) -> usize {
107 source[..offset.min(source.len())].matches('\n').count() + 1
108}
109
110fn is_rust_file(path: &Path) -> bool {
111 path.extension().and_then(|s| s.to_str()) == Some("rs")
112}
113
114fn is_test_file(relative: &str) -> bool {
119 if relative == "crates/codelens-engine/src/redundant_definitions.rs" {
120 return true;
121 }
122 let lower = relative.to_ascii_lowercase();
123 if lower.ends_with("_tests.rs") || lower.ends_with("_test.rs") {
124 return true;
125 }
126 lower.split('/').any(|seg| {
127 matches!(
128 seg,
129 "tests" | "test" | "bench" | "benches" | "examples" | "fixtures"
130 )
131 })
132}
133
134fn strip_cfg_test_modules(source: &str) -> String {
139 let mut out = String::with_capacity(source.len());
140 let mut chars = source.char_indices().peekable();
141 while let Some(&(idx, ch)) = chars.peek() {
142 if ch == '#' {
143 let rest = &source[idx..];
145 if let Some(open_brace_offset) = match_cfg_test_mod_header(rest) {
146 let body_start = idx + open_brace_offset;
147 let body_end = match find_matching_brace(source, body_start) {
148 Some(e) => e,
149 None => {
150 out.push(ch);
151 chars.next();
152 continue;
153 }
154 };
155 while let Some(&(j, _)) = chars.peek() {
157 if j > body_end {
158 break;
159 }
160 chars.next();
161 }
162 continue;
163 }
164 }
165 out.push(ch);
166 chars.next();
167 }
168 out
169}
170
171fn match_cfg_test_mod_header(rest: &str) -> Option<usize> {
175 static HEADER_RE: LazyLock<Regex> = LazyLock::new(|| {
176 Regex::new(r"^#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+[A-Za-z_][A-Za-z0-9_]*\s*\{")
177 .unwrap()
178 });
179 HEADER_RE.find(rest).map(|m| m.end() - 1)
180}
181
182fn find_matching_brace(source: &str, open_idx: usize) -> Option<usize> {
184 let bytes = source.as_bytes();
185 if bytes.get(open_idx) != Some(&b'{') {
186 return None;
187 }
188 let mut depth = 1usize;
189 let mut i = open_idx + 1;
190 while i < bytes.len() {
191 match bytes[i] {
192 b'{' => depth += 1,
193 b'}' => {
194 depth -= 1;
195 if depth == 0 {
196 return Some(i);
197 }
198 }
199 _ => {}
200 }
201 i += 1;
202 }
203 None
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn detects_self_dot_wrapper_with_none_default() {
212 let source = r#"
213impl Foo {
214 pub fn record_x(&self) { self.record_x_for_session(None) }
215 pub fn record_y(&self) { self.record_y_for_session(None); }
216}
217 "#;
218 let mut out = Vec::new();
219 scan_rust_source(source, "telemetry.rs", &mut out);
220 assert_eq!(out.len(), 2, "got: {:?}", out);
221 assert_eq!(out[0].wrapper, "record_x");
222 assert_eq!(out[0].target, "record_x_for_session");
223 assert_eq!(out[0].default_arg.as_deref(), Some("None"));
224 assert_eq!(out[1].wrapper, "record_y");
225 }
226
227 #[test]
228 fn detects_bare_function_wrapper() {
229 let source = r#"
230pub fn helper(x: u32) -> bool { inner(x, false) }
231 "#;
232 let mut out = Vec::new();
233 scan_rust_source(source, "lib.rs", &mut out);
234 assert_eq!(out.len(), 1);
235 assert_eq!(out[0].wrapper, "helper");
236 assert_eq!(out[0].target, "inner");
237 assert_eq!(out[0].default_arg.as_deref(), Some("false"));
238 }
239
240 #[test]
241 fn skips_self_recursive_call() {
242 let source = r#"
243pub fn loop_me(&self) { self.loop_me(0) }
244 "#;
245 let mut out = Vec::new();
246 scan_rust_source(source, "x.rs", &mut out);
247 assert!(out.is_empty(), "got: {:?}", out);
249 }
250
251 #[test]
252 fn strip_cfg_test_modules_removes_test_block() {
253 let source = r#"
254pub fn real_thing(&self) { self.real_thing_inner(None) }
255
256#[cfg(test)]
257mod tests {
258 fn helper() { foo(true) }
259}
260"#;
261 let stripped = super::strip_cfg_test_modules(source);
262 assert!(stripped.contains("real_thing"));
263 assert!(
264 !stripped.contains("foo(true)"),
265 "test mod should be gone: {}",
266 stripped
267 );
268 }
269
270 #[test]
271 fn is_test_file_recognizes_canonical_paths() {
272 assert!(super::is_test_file("crates/foo/tests/something.rs"));
273 assert!(super::is_test_file("crates/foo/src/internals_tests.rs"));
274 assert!(super::is_test_file("benchmarks/bench/runner.rs"));
275 assert!(!super::is_test_file("crates/foo/src/main.rs"));
276 assert!(super::is_test_file(
277 "crates/codelens-engine/src/redundant_definitions.rs"
278 ));
279 }
280
281 #[test]
282 #[ignore]
283 fn dogfood_self_repo() {
284 let repo = std::env::var("CODELENS_REPO_ROOT")
286 .unwrap_or_else(|_| "/Users/bagjaeseog/codelens-mcp-plugin".to_owned());
287 let project = crate::project::ProjectRoot::new(repo).expect("project root");
288 let results =
289 super::find_redundant_definitions(&project, 200).expect("find_redundant_definitions");
290 eprintln!(
291 "\n=== {} redundant definitions in self (post v2 filtering) ===\n",
292 results.len()
293 );
294 let mut groups: std::collections::BTreeMap<&str, Vec<&RedundantDefinitionEntry>> =
295 std::collections::BTreeMap::new();
296 for r in &results {
297 groups.entry(r.target.as_str()).or_default().push(r);
298 }
299 let mut multi: Vec<_> = groups.iter().filter(|(_, v)| v.len() >= 2).collect();
300 multi.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
301 eprintln!("Multi-wrapper clusters: {}\n", multi.len());
302 for (target, members) in &multi {
303 eprintln!(" {} ← {}", target, members.len());
304 for m in members.iter().take(3) {
305 eprintln!(" {} at {}:{}", m.wrapper, m.file, m.line);
306 }
307 if members.len() > 3 {
308 eprintln!(" ... +{} more", members.len() - 3);
309 }
310 }
311 eprintln!("\nFirst 30 hits:\n");
312 for r in results.iter().take(30) {
313 eprintln!(" {} -> {} ({}:{})", r.wrapper, r.target, r.file, r.line);
314 }
315 }
316
317 #[test]
318 fn skips_multi_statement_body() {
319 let source = r#"
320pub fn complex(&self) {
321 let x = 1;
322 self.do_thing(x, None);
323}
324 "#;
325 let mut out = Vec::new();
326 scan_rust_source(source, "x.rs", &mut out);
327 assert!(
328 out.is_empty(),
329 "multi-statement should not match: {:?}",
330 out
331 );
332 }
333}