1use 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 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 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 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 let first_line = file.content.lines().next().unwrap_or("");
348 if !first_line.contains("frozen_string_literal: true") {
349 count += 1;
350 }
351
352 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 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}