Skip to main content

garbage_code_hunter/language/adapter/
ruby.rs

1//! RubyAdapter — Ruby language adapter.
2
3use super::{
4    count_dead_code_with, count_duplicate_imports_with, count_params, is_boolean_or_null,
5    is_common_safe_number, is_inside_declaration, is_repeating_chars, FunctionNode,
6    LanguageAdapter,
7};
8use crate::language::Language;
9use crate::treesitter::engine::ParsedFile;
10use crate::treesitter::query::QueryCapture;
11use regex::Regex;
12use std::sync::LazyLock;
13
14const RUBY_PATTERNS: &[&str] = &[
15    "(call method: (identifier) @pc_raise (#eq? @pc_raise \"raise\"))",
16    "(method name: (identifier) @ex_name) @ex_fn",
17    "(assignment left: (identifier) @nv_var)",
18    "(method parameters: (_) @ep_params)",
19    "[(integer) @mn_num (float) @mn_num]",
20    "(global_variable) @ri_gv",
21    "(call method: (identifier) @dp_method (#match? @dp_method \"^(puts|p|print|warn|byebug|pry)$\"))",
22];
23
24pub struct RubyAdapter;
25
26impl LanguageAdapter for RubyAdapter {
27    fn language(&self) -> Language {
28        Language::Ruby
29    }
30
31    fn query_patterns(&self) -> &[&str] {
32        RUBY_PATTERNS
33    }
34
35    fn count_panic_calls(&self, file: &ParsedFile) -> usize {
36        self.count_panic_from_batch(file, &self.batch_captures(file))
37    }
38
39    fn extract_functions(&self, file: &ParsedFile) -> Vec<FunctionNode> {
40        self.extract_functions_from_batch(file, &self.batch_captures(file))
41    }
42
43    fn max_nesting_depth(&self, file: &ParsedFile) -> usize {
44        fn ruby_scope_depth(node: tree_sitter::Node, depth: usize) -> usize {
45            let mut max = depth;
46            for i in 0..node.child_count() {
47                if let Some(child) = node.child(i as u32) {
48                    let child_depth = if child.kind() == "body_statement" {
49                        depth + 1
50                    } else {
51                        depth
52                    };
53                    max = max.max(ruby_scope_depth(child, child_depth));
54                }
55            }
56            max
57        }
58        ruby_scope_depth(file.root_node(), 0)
59    }
60
61    fn count_naming_violations(&self, file: &ParsedFile) -> usize {
62        self.count_naming_from_batch(file, &self.batch_captures(file))
63    }
64
65    fn count_deeply_nested_blocks(&self, file: &ParsedFile) -> usize {
66        fn walk_body(node: tree_sitter::Node, depth: usize, threshold: usize, count: &mut usize) {
67            if node.kind() == "body_statement" && depth >= threshold {
68                *count += 1;
69            }
70            let child_depth = if node.kind() == "body_statement" {
71                depth + 1
72            } else {
73                depth
74            };
75            for i in 0..node.child_count() {
76                if let Some(child) = node.child(i as u32) {
77                    walk_body(child, child_depth, threshold, count);
78                }
79            }
80        }
81        let threshold = 5;
82        let mut count = 0;
83        walk_body(file.root_node(), 0, threshold, &mut count);
84        count
85    }
86
87    fn count_debug_calls(&self, file: &ParsedFile) -> usize {
88        self.count_debug_from_batch(file, &self.batch_captures(file))
89    }
90
91    fn count_excessive_params(&self, file: &ParsedFile, threshold: usize) -> usize {
92        self.count_excessive_from_batch_with(file, &self.batch_captures(file), threshold)
93    }
94
95    fn count_magic_numbers(&self, file: &ParsedFile) -> usize {
96        self.count_magic_from_batch(file, &self.batch_captures(file))
97    }
98
99    fn count_dead_code(&self, file: &ParsedFile) -> usize {
100        count_dead_code_with(
101            file,
102            &["return", "return;", "break", "break;", "next", "next;"],
103            &["return ", "raise ", "exit", "abort"],
104            "#",
105        )
106    }
107
108    fn count_duplicate_imports(&self, file: &ParsedFile) -> usize {
109        count_duplicate_imports_with(file, &["require ", "require_relative "])
110    }
111
112    fn count_ruby_issues(&self, file: &ParsedFile) -> usize {
113        self.count_ruby_from_batch(file, &self.batch_captures(file))
114    }
115
116    fn count_panic_from_batch<'a>(
117        &self,
118        _file: &ParsedFile,
119        batch: &[Vec<QueryCapture<'a>>],
120    ) -> usize {
121        batch
122            .iter()
123            .filter(|m| m.iter().any(|c| c.name == "pc_raise"))
124            .count()
125    }
126
127    fn extract_functions_from_batch<'a>(
128        &self,
129        _file: &ParsedFile,
130        batch: &[Vec<QueryCapture<'a>>],
131    ) -> Vec<FunctionNode> {
132        let mut functions = Vec::new();
133        for m in batch {
134            let has_ex = m.iter().any(|c| c.name.starts_with("ex_"));
135            if !has_ex {
136                continue;
137            }
138            let mut name = String::new();
139            let mut start_line = 0usize;
140            let mut end_line = 0usize;
141            for c in m {
142                match c.name.as_str() {
143                    "ex_name" => name = c.text.to_string(),
144                    "ex_fn" => {
145                        start_line = c.node.start_position().row + 1;
146                        end_line = c.node.end_position().row + 1;
147                    }
148                    _ => {}
149                }
150            }
151            if !name.is_empty() {
152                functions.push(FunctionNode {
153                    name,
154                    start_line,
155                    end_line,
156                    nesting_depth: 0,
157                });
158            }
159        }
160        functions
161    }
162
163    fn count_naming_from_batch<'a>(
164        &self,
165        file: &ParsedFile,
166        batch: &[Vec<QueryCapture<'a>>],
167    ) -> usize {
168        let mut count = 0usize;
169        let idiomatic_single: &[&str] = &["e", "i", "j", "k", "x", "n"];
170
171        static TERRIBLE_RE: LazyLock<Option<Regex>> = LazyLock::new(|| {
172            Regex::new(r"^(data|info|temp|tmp|val|value|thing|stuff|obj|object|manager|handler|helper|util|utils)(\d+)?$").ok()
173        });
174        let terrible_re = TERRIBLE_RE.as_ref();
175        let meaningless: &[&str] = &[
176            "foo", "bar", "baz", "qux", "quux", "quuz", "aaa", "bbb", "ccc", "ddd", "eee", "xxx",
177            "yyy", "zzz", "test1", "test2", "test3",
178        ];
179
180        for m in batch {
181            for c in m {
182                if c.name == "nv_var" {
183                    let name = c.text;
184                    if name.len() == 1 && name.chars().all(|ch| ch.is_ascii_lowercase()) {
185                        if !idiomatic_single.contains(&name) {
186                            count += 1;
187                        }
188                        continue;
189                    }
190                    let name_lower = name.to_lowercase();
191                    if let Some(re) = terrible_re {
192                        if re.is_match(&name_lower) {
193                            count += 1;
194                            continue;
195                        }
196                    }
197                    if meaningless.contains(&name) || is_repeating_chars(name) {
198                        count += 1;
199                    }
200                }
201            }
202        }
203
204        // ruby-predicate-method: boolean methods should end with ?
205        for line in file.content.lines() {
206            let trimmed = line.trim();
207            if trimmed.starts_with('#') {
208                continue;
209            }
210            if let Some(name) = trimmed
211                .strip_prefix("def ")
212                .and_then(|s| s.split(&['(', ' ', '\t'][..]).next())
213            {
214                let is_predicate = name.starts_with("is_")
215                    || name.starts_with("has_")
216                    || name.starts_with("can_")
217                    || name.starts_with("should_");
218                if is_predicate && !name.ends_with('?') {
219                    count += 1;
220                }
221            }
222        }
223
224        count
225    }
226
227    fn count_debug_from_batch<'a>(
228        &self,
229        file: &ParsedFile,
230        batch: &[Vec<QueryCapture<'a>>],
231    ) -> usize {
232        let base = batch
233            .iter()
234            .filter(|m| m.iter().any(|c| c.name == "dp_method"))
235            .count();
236        let stderr = file
237            .content
238            .lines()
239            .filter(|l| {
240                let t = l.trim();
241                !t.starts_with("#") && t.contains("STDERR.puts")
242            })
243            .count();
244        base + stderr
245    }
246
247    fn count_excessive_from_batch<'a>(
248        &self,
249        _file: &ParsedFile,
250        batch: &[Vec<QueryCapture<'a>>],
251    ) -> usize {
252        self.count_excessive_from_batch_with(_file, batch, 5)
253    }
254
255    fn count_magic_from_batch<'a>(
256        &self,
257        _file: &ParsedFile,
258        batch: &[Vec<QueryCapture<'a>>],
259    ) -> usize {
260        let mut count = 0;
261        for m in batch {
262            for c in m {
263                if c.name == "mn_num" && !is_inside_declaration(c.node) {
264                    let text = c.text;
265                    if text != "0"
266                        && text != "1"
267                        && !is_common_safe_number(text)
268                        && !is_boolean_or_null(text)
269                    {
270                        count += 1;
271                    }
272                }
273            }
274        }
275        count
276    }
277
278    fn count_ruby_from_batch<'a>(
279        &self,
280        file: &ParsedFile,
281        batch: &[Vec<QueryCapture<'a>>],
282    ) -> usize {
283        let mut count = 0;
284
285        // global-variable: non-built-in global variables ($xxx)
286        let acceptable: &[&str] = &[
287            "$stdout",
288            "$stderr",
289            "$stdin",
290            "$VERBOSE",
291            "$DEBUG",
292            "$SAFE",
293            "$LOAD_PATH",
294            "$LOADED_FEATURES",
295            "$PROGRAM_NAME",
296            "$FILENAME",
297            "$.",
298            "$,",
299            "$;",
300            "$/",
301            "$\\",
302            "$&",
303            "$`",
304            "$'",
305            "$+",
306            "$~",
307            "$=",
308            "$<",
309            "$>",
310            "$!",
311            "$?",
312            "$0",
313            "$*",
314            "$_",
315            "$-d",
316            "$-v",
317            "$-w",
318            "$-W",
319        ];
320        for m in batch {
321            for c in m {
322                if c.name == "ri_gv" && !acceptable.contains(&c.text.trim()) {
323                    count += 1;
324                }
325            }
326        }
327
328        // bare-rescue: rescue without exception class
329        fn walk_rescue(node: tree_sitter::Node, count: &mut usize) {
330            if node.kind() == "rescue" && node.is_named() {
331                let has_exceptions = (0..node.child_count())
332                    .filter_map(|i| node.child(i as u32))
333                    .any(|c| c.kind() == "exceptions");
334                if !has_exceptions {
335                    *count += 1;
336                }
337            }
338            for i in 0..node.child_count() {
339                if let Some(child) = node.child(i as u32) {
340                    walk_rescue(child, count);
341                }
342            }
343        }
344        walk_rescue(file.root_node(), &mut count);
345
346        // frozen-string: missing # frozen_string_literal: true
347        let first_line = file.content.lines().next().unwrap_or("");
348        if !first_line.contains("frozen_string_literal: true") {
349            count += 1;
350        }
351
352        // negated-if: if !x → should use unless x
353        for line in file.content.lines() {
354            let trimmed = line.trim();
355            if trimmed.starts_with('#') {
356                continue;
357            }
358            if (trimmed.starts_with("if !") || trimmed.starts_with("if("))
359                && trimmed.contains('!')
360                && !trimmed.contains("!= ")
361            {
362                count += 1;
363            }
364        }
365
366        // ruby-two-space-indent: indentation must be multiple of 2
367        for line in file.content.lines() {
368            if line.trim().is_empty() {
369                continue;
370            }
371            let indent = line.len() - line.trim_start().len();
372            if indent > 0 && indent % 2 != 0 {
373                count += 1;
374            }
375        }
376
377        count
378    }
379}
380
381impl RubyAdapter {
382    fn count_excessive_from_batch_with<'a>(
383        &self,
384        _file: &ParsedFile,
385        batch: &[Vec<QueryCapture<'a>>],
386        threshold: usize,
387    ) -> usize {
388        let mut count = 0;
389        for m in batch {
390            for c in m {
391                if c.name == "ep_params" && count_params(c.text) > threshold {
392                    count += 1;
393                }
394            }
395        }
396        count
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::super::parse_code;
403    use super::*;
404
405    fn parse_ruby(code: &str) -> ParsedFile {
406        parse_code(code, "test.rb").expect("parse")
407    }
408
409    #[test]
410    fn test_ruby_count_panic_raise() {
411        let code = r#"
412def foo
413  raise "error"
414end
415"#;
416        let file = parse_ruby(code);
417        let adapter = RubyAdapter;
418        assert_eq!(adapter.count_panic_calls(&file), 1);
419    }
420
421    #[test]
422    fn test_ruby_count_panic_clean() {
423        let code = r#"
424def foo
425  puts "hello"
426end
427"#;
428        let file = parse_ruby(code);
429        let adapter = RubyAdapter;
430        assert_eq!(adapter.count_panic_calls(&file), 0);
431    }
432
433    #[test]
434    fn test_ruby_extract_functions() {
435        let code = "def foo; end\ndef bar(x); end\n";
436        let file = parse_ruby(code);
437        let adapter = RubyAdapter;
438        let fns = adapter.extract_functions(&file);
439        assert_eq!(fns.len(), 2);
440        assert_eq!(fns[0].name, "foo");
441        assert_eq!(fns[1].name, "bar");
442    }
443
444    #[test]
445    fn test_ruby_max_nesting_depth_flat() {
446        let code = "def foo; 1; end\n";
447        let file = parse_ruby(code);
448        let adapter = RubyAdapter;
449        let depth = adapter.max_nesting_depth(&file);
450        assert!(depth >= 1, "method body should give depth >= 1");
451    }
452
453    #[test]
454    fn test_ruby_max_nesting_depth_nested() {
455        let code = r#"
456def foo
457  if true
458    if true
459      puts "hi"
460    end
461  end
462end
463"#;
464        let file = parse_ruby(code);
465        let adapter = RubyAdapter;
466        let depth = adapter.max_nesting_depth(&file);
467        assert!(
468            depth >= 1,
469            "nested bodies should give depth >= 1, got {depth}"
470        );
471    }
472
473    #[test]
474    fn test_ruby_max_nesting_depth_empty() {
475        let code = "";
476        let file = parse_ruby(code);
477        let adapter = RubyAdapter;
478        assert_eq!(adapter.max_nesting_depth(&file), 0);
479    }
480
481    #[test]
482    fn test_ruby_naming_single_letter() {
483        let code = "a = 1\nb = 2\n";
484        let file = parse_ruby(code);
485        let adapter = RubyAdapter;
486        assert_eq!(adapter.count_naming_violations(&file), 2);
487    }
488
489    #[test]
490    fn test_ruby_naming_clean() {
491        let code = "user_name = \"alice\"\nitem_count = 42\n";
492        let file = parse_ruby(code);
493        let adapter = RubyAdapter;
494        assert_eq!(adapter.count_naming_violations(&file), 0);
495    }
496
497    #[test]
498    fn test_ruby_naming_terrible() {
499        let code = "data = 1\nmanager = 2\n";
500        let file = parse_ruby(code);
501        let adapter = RubyAdapter;
502        assert_eq!(adapter.count_naming_violations(&file), 2);
503    }
504
505    #[test]
506    fn test_ruby_naming_meaningless() {
507        let code = "foo = 1\naaa = 2\n";
508        let file = parse_ruby(code);
509        let adapter = RubyAdapter;
510        assert_eq!(adapter.count_naming_violations(&file), 2);
511    }
512
513    #[test]
514    fn test_ruby_debug_puts() {
515        let code = r#"
516puts "hello"
517print "world"
518p x
519binding.pry
520byebug
521"#;
522        let file = parse_ruby(code);
523        let adapter = RubyAdapter;
524        assert_eq!(adapter.count_debug_calls(&file), 4);
525    }
526
527    #[test]
528    fn test_ruby_debug_clean() {
529        let code = "result = add(1, 2)\n";
530        let file = parse_ruby(code);
531        let adapter = RubyAdapter;
532        assert_eq!(adapter.count_debug_calls(&file), 0);
533    }
534
535    #[test]
536    fn test_ruby_excessive_params() {
537        let code = "def process(a, b, c, d, e, f); end\n";
538        let file = parse_ruby(code);
539        let adapter = RubyAdapter;
540        assert_eq!(adapter.count_excessive_params(&file, 5), 1);
541    }
542
543    #[test]
544    fn test_ruby_excessive_params_ok() {
545        let code = "def process(a, b); end\n";
546        let file = parse_ruby(code);
547        let adapter = RubyAdapter;
548        assert_eq!(adapter.count_excessive_params(&file, 5), 0);
549    }
550
551    #[test]
552    fn test_ruby_magic_numbers_expression() {
553        let code = "foo(42)\nbar(100)\n";
554        let file = parse_ruby(code);
555        let adapter = RubyAdapter;
556        assert_eq!(adapter.count_magic_numbers(&file), 2);
557    }
558
559    #[test]
560    fn test_ruby_magic_numbers_skips_trivial() {
561        let code = "foo(0)\nbar(1)\n";
562        let file = parse_ruby(code);
563        let adapter = RubyAdapter;
564        assert_eq!(adapter.count_magic_numbers(&file), 0);
565    }
566
567    #[test]
568    fn test_ruby_magic_numbers_skips_declaration() {
569        let code = "x = 42\n";
570        let file = parse_ruby(code);
571        let adapter = RubyAdapter;
572        assert_eq!(adapter.count_magic_numbers(&file), 0);
573    }
574
575    #[test]
576    fn test_ruby_dead_code_after_return() {
577        let code = r#"
578def foo
579  return 42
580  puts "dead"
581end
582"#;
583        let file = parse_ruby(code);
584        let adapter = RubyAdapter;
585        assert_eq!(adapter.count_dead_code(&file), 1);
586    }
587
588    #[test]
589    fn test_ruby_duplicate_imports() {
590        let code = "require 'json'\nrequire 'yaml'\nrequire 'json'\n";
591        let file = parse_ruby(code);
592        let adapter = RubyAdapter;
593        assert_eq!(adapter.count_duplicate_imports(&file), 1);
594    }
595}