Skip to main content

garbage_code_hunter/language/adapter/
go.rs

1//! GoAdapter — Go language adapter.
2
3use super::{
4    count_duplicate_imports_with, count_nested_blocks, count_params, is_boolean_or_null,
5    is_common_safe_number, is_inside_declaration, is_repeating_chars, FunctionNode,
6    LanguageAdapter, MEANINGLESS_NAMES,
7};
8use crate::language::Language;
9use crate::treesitter::engine::ParsedFile;
10use crate::treesitter::query::QueryCapture;
11use regex::Regex;
12use std::sync::LazyLock;
13
14const GO_PATTERNS: &[&str] = &[
15    // pc_ — panic calls
16    "(call_expression function: (identifier) @pc_fn (#eq? @pc_fn \"panic\"))",
17    // ex_ — extract functions
18    "[(function_declaration name: (identifier) @ex_name) (method_declaration name: (field_identifier) @ex_name)] @ex_fn",
19    // nv_ — naming violations
20    "[(short_var_declaration left: (expression_list (identifier) @nv_var)) (var_spec name: (identifier) @nv_var)]",
21    "(method_declaration receiver: (parameter_list (parameter_declaration name: (identifier) @nv_rec)))",
22    // dp_ — debug calls
23    r#"(call_expression
24  function: (selector_expression
25    operand: (identifier) @dp_pkg
26    field: (field_identifier) @dp_method)
27  (#match? @dp_pkg "^(fmt|log)$")
28  (#match? @dp_method "^(Print|Println|Printf|Fprint|Fprintln|Fprintf|Sprint|Sprintln|Sprintf)$"))"#,
29    // ep_ — excessive params
30    "[(function_declaration parameters: (parameter_list) @ep_params) (method_declaration parameters: (parameter_list) @ep_params)]",
31    // mn_ — magic numbers
32    "[(int_literal) @mn_num (float_literal) @mn_num]",
33    // gs_ — goroutine spawns
34    "(go_statement) @gs_go",
35    // cv_ — convention violations (fmt.Errorf / fmt.New)
36    r#"(call_expression function: (selector_expression operand: (identifier) @cv_pkg field: (field_identifier) @cv_method) (#eq? @cv_pkg "fmt") (#match? @cv_method "^(Errorf|New)$"))"#,
37    // ui_ — unsafe operations
38    r#"(selector_expression operand: (identifier) @ui_pkg (#eq? @ui_pkg "unsafe"))"#,
39    "(import_spec path: (interpreted_string_literal) @ui_import (#match? @ui_import \"unsafe\"))",
40];
41
42pub struct GoAdapter;
43
44impl LanguageAdapter for GoAdapter {
45    fn language(&self) -> Language {
46        Language::Go
47    }
48
49    fn query_patterns(&self) -> &[&str] {
50        GO_PATTERNS
51    }
52
53    fn count_panic_calls(&self, file: &ParsedFile) -> usize {
54        self.count_panic_from_batch(file, &self.batch_captures(file))
55    }
56
57    fn extract_functions(&self, file: &ParsedFile) -> Vec<FunctionNode> {
58        self.extract_functions_from_batch(file, &self.batch_captures(file))
59    }
60
61    fn max_nesting_depth(&self, file: &ParsedFile) -> usize {
62        fn go_scope_depth(node: tree_sitter::Node, depth: usize) -> usize {
63            let mut max = depth;
64            for i in 0..node.child_count() {
65                if let Some(child) = node.child(i as u32) {
66                    let child_depth = match child.kind() {
67                        "block" => depth + 1,
68                        _ => depth,
69                    };
70                    max = max.max(go_scope_depth(child, child_depth));
71                }
72            }
73            max
74        }
75        go_scope_depth(file.root_node(), 0)
76    }
77
78    fn count_naming_violations(&self, file: &ParsedFile) -> usize {
79        self.count_naming_from_batch(file, &self.batch_captures(file))
80    }
81
82    fn count_deeply_nested_blocks(&self, file: &ParsedFile) -> usize {
83        let mut count = 0;
84        count_nested_blocks(file.root_node(), 0, 5, &mut count);
85        count
86    }
87
88    fn count_debug_calls(&self, file: &ParsedFile) -> usize {
89        self.count_debug_from_batch(file, &self.batch_captures(file))
90    }
91
92    fn count_excessive_params(&self, file: &ParsedFile, threshold: usize) -> usize {
93        self.count_excessive_from_batch_with(file, &self.batch_captures(file), threshold)
94    }
95
96    fn count_magic_numbers(&self, file: &ParsedFile) -> usize {
97        self.count_magic_from_batch(file, &self.batch_captures(file))
98    }
99
100    fn count_goroutine_spawns(&self, file: &ParsedFile) -> usize {
101        self.count_goroutine_from_batch(file, &self.batch_captures(file))
102    }
103
104    fn count_defer_in_loop(&self, file: &ParsedFile) -> usize {
105        fn has_defer_child(node: tree_sitter::Node) -> bool {
106            let mut cursor = node.walk();
107            let mut found = cursor.goto_first_child();
108            while found {
109                if cursor.node().kind() == "defer_statement" {
110                    return true;
111                }
112                found = cursor.goto_next_sibling();
113            }
114            false
115        }
116
117        fn walk_for_loops(_file: &ParsedFile, node: tree_sitter::Node, count: &mut usize) {
118            if node.kind() == "for_statement" && has_defer_child(node) {
119                *count += 1;
120            }
121            let mut cursor = node.walk();
122            for child in node.children(&mut cursor) {
123                walk_for_loops(_file, child, count);
124            }
125        }
126
127        let mut count = 0;
128        walk_for_loops(file, file.root_node(), &mut count);
129        count
130    }
131
132    fn count_go_convention_violations(&self, file: &ParsedFile) -> usize {
133        self.count_go_convention_from_batch(file, &self.batch_captures(file))
134    }
135
136    // -- _from_batch overrides --
137
138    fn count_panic_from_batch<'a>(
139        &self,
140        _file: &ParsedFile,
141        batch: &[Vec<QueryCapture<'a>>],
142    ) -> usize {
143        batch
144            .iter()
145            .filter(|m| m.iter().any(|c| c.name == "pc_fn"))
146            .count()
147    }
148
149    fn extract_functions_from_batch<'a>(
150        &self,
151        _file: &ParsedFile,
152        batch: &[Vec<QueryCapture<'a>>],
153    ) -> Vec<FunctionNode> {
154        let mut functions = Vec::new();
155        for m in batch {
156            let has_ex = m.iter().any(|c| c.name.starts_with("ex_"));
157            if !has_ex {
158                continue;
159            }
160            let mut name = String::new();
161            let mut start_line = 0usize;
162            let mut end_line = 0usize;
163            for c in m {
164                match c.name.as_str() {
165                    "ex_name" => name = c.text.to_string(),
166                    "ex_fn" => {
167                        start_line = c.node.start_position().row + 1;
168                        end_line = c.node.end_position().row + 1;
169                    }
170                    _ => {}
171                }
172            }
173            if !name.is_empty() {
174                functions.push(FunctionNode {
175                    name,
176                    start_line,
177                    end_line,
178                    nesting_depth: 0,
179                });
180            }
181        }
182        functions
183    }
184
185    fn count_naming_from_batch<'a>(
186        &self,
187        file: &ParsedFile,
188        batch: &[Vec<QueryCapture<'a>>],
189    ) -> usize {
190        let mut count = 0usize;
191        static TERRIBLE_RE: LazyLock<Option<Regex>> = LazyLock::new(|| {
192            Regex::new(r"^(data|info|temp|tmp|val|value|thing|stuff|obj|object|manager|handler|helper|util|utils)(\d+)?$").ok()
193        });
194        let terrible_re = TERRIBLE_RE.as_ref();
195        let idiomatic_single: &[&str] = &["e", "g", "i", "j", "k", "n", "c"];
196
197        for m in batch {
198            for c in m {
199                match c.name.as_str() {
200                    "nv_var" => {
201                        let name = c.text;
202                        if name.len() == 1 && name.chars().all(|ch| ch.is_ascii_lowercase()) {
203                            if !idiomatic_single.contains(&name) {
204                                count += 1;
205                            }
206                            continue;
207                        }
208                        if let Some(re) = terrible_re {
209                            if re.is_match(&name.to_lowercase()) {
210                                count += 1;
211                                continue;
212                            }
213                        }
214                        if MEANINGLESS_NAMES.contains(&name) || is_repeating_chars(name) {
215                            count += 1;
216                        }
217                    }
218                    "nv_rec" if c.text.len() > 2 => {
219                        count += 1;
220                    }
221                    _ => {}
222                }
223            }
224        }
225
226        // go-mixed-caps: snake_case or ALL_CAPS variable names (text scanning)
227        let go_idioms = [
228            "err", "ok", "ctx", "mu", "wg", "ch", "db", "id", "ip", "tx", "rx", "fd", "fs", "ns",
229            "fn", "hp", "os", "rc",
230        ];
231        for line in file.content.lines() {
232            let trimmed = line.trim();
233            if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with("*") {
234                continue;
235            }
236            let name = if let Some(rest) = trimmed.strip_prefix("var ") {
237                rest.split_whitespace().next().unwrap_or("")
238            } else if let Some(idx) = trimmed.find(":=") {
239                trimmed[..idx].split_whitespace().last().unwrap_or("")
240            } else {
241                ""
242            };
243            if name.is_empty() || name.len() < 2 || go_idioms.contains(&name) || name == "_" {
244                continue;
245            }
246            if name.chars().next().is_some_and(|ch| ch.is_uppercase()) {
247                continue;
248            }
249            let has_underscore = name.contains('_') && name != "_";
250            let is_all_caps = name
251                .chars()
252                .all(|ch| ch.is_uppercase() || ch == '_' || ch.is_numeric())
253                && name.chars().any(|ch| ch.is_uppercase());
254            if has_underscore || is_all_caps {
255                count += 1;
256            }
257        }
258
259        count
260    }
261
262    fn count_debug_from_batch<'a>(
263        &self,
264        file: &ParsedFile,
265        batch: &[Vec<QueryCapture<'a>>],
266    ) -> usize {
267        let source = file.content.as_bytes();
268        let mut count = 0;
269        for m in batch {
270            let has_dp = m.iter().any(|c| c.name.starts_with("dp_"));
271            if !has_dp {
272                continue;
273            }
274            let mut exempt = false;
275            for c in m {
276                if c.name == "dp_pkg" && c.text == "fmt" {
277                    let mut current = Some(c.node);
278                    while let Some(n) = current {
279                        if n.kind() == "function_declaration" {
280                            if let Some(name_node) = n.child_by_field_name("name") {
281                                if let Ok(text) = name_node.utf8_text(source) {
282                                    if text == "main" {
283                                        exempt = true;
284                                        break;
285                                    }
286                                }
287                            }
288                            break;
289                        }
290                        current = n.parent();
291                    }
292                }
293                if exempt {
294                    break;
295                }
296            }
297            if !exempt {
298                count += 1;
299            }
300        }
301        count
302    }
303
304    fn count_excessive_from_batch<'a>(
305        &self,
306        _file: &ParsedFile,
307        batch: &[Vec<QueryCapture<'a>>],
308    ) -> usize {
309        self.count_excessive_from_batch_with(_file, batch, 5)
310    }
311
312    fn count_magic_from_batch<'a>(
313        &self,
314        _file: &ParsedFile,
315        batch: &[Vec<QueryCapture<'a>>],
316    ) -> usize {
317        let mut count = 0;
318        for m in batch {
319            for c in m {
320                if c.name == "mn_num" && !is_inside_declaration(c.node) {
321                    let text = c.text;
322                    if text != "0"
323                        && text != "1"
324                        && text != "-1"
325                        && !is_common_safe_number(text)
326                        && !is_boolean_or_null(text)
327                    {
328                        count += 1;
329                    }
330                }
331            }
332        }
333        count
334    }
335
336    fn count_goroutine_from_batch<'a>(
337        &self,
338        _file: &ParsedFile,
339        batch: &[Vec<QueryCapture<'a>>],
340    ) -> usize {
341        batch
342            .iter()
343            .filter(|m| m.iter().any(|c| c.name == "gs_go"))
344            .count()
345    }
346
347    fn count_go_convention_from_batch<'a>(
348        &self,
349        file: &ParsedFile,
350        batch: &[Vec<QueryCapture<'a>>],
351    ) -> usize {
352        let mut count = 0;
353
354        // go-error-string: fmt.Errorf / fmt.New with uppercase first letter
355        for m in batch {
356            if !m.iter().any(|c| c.name == "cv_method") {
357                continue;
358            }
359            for c in m {
360                if c.name == "cv_method" {
361                    if let Some(call_node) = c.node.parent().and_then(|p| p.parent()) {
362                        for child in call_node.children(&mut call_node.walk()) {
363                            if child.kind() == "argument_list" {
364                                let text = file.node_text(child);
365                                let trimmed = text.trim();
366                                let start = trimmed.find('"');
367                                let content = start
368                                    .map(|s| {
369                                        let from = &trimmed[s + 1..];
370                                        from.find('"').map(|e| &from[..e]).unwrap_or("")
371                                    })
372                                    .unwrap_or("");
373                                if let Some(first) = content.chars().next() {
374                                    if first.is_uppercase() {
375                                        count += 1;
376                                    }
377                                }
378                                break;
379                            }
380                        }
381                    }
382                }
383            }
384        }
385
386        // go-context-first: context.Context not the first parameter
387        for line in file.content.lines() {
388            let trimmed = line.trim();
389            if !trimmed.starts_with("func ") {
390                continue;
391            }
392            let params_start = trimmed.find('(');
393            let params_end = trimmed.rfind(')');
394            if let (Some(ps), Some(pe)) = (params_start, params_end) {
395                let params_str = &trimmed[ps + 1..pe];
396                if params_str.contains("context.Context") {
397                    let first = params_str.split(',').next().unwrap_or("").trim();
398                    if !first.contains("context.Context") {
399                        count += 1;
400                    }
401                }
402            }
403        }
404
405        // go-else-return: if-else with return in if-block
406        fn has_return_statement(n: tree_sitter::Node) -> bool {
407            if n.kind() == "return_statement" {
408                return true;
409            }
410            let mut cursor = n.walk();
411            let mut inner = cursor.goto_first_child();
412            while inner {
413                if cursor.node().kind() == "return_statement" {
414                    return true;
415                }
416                inner = cursor.goto_next_sibling();
417            }
418            false
419        }
420
421        fn check_else_return(_file: &ParsedFile, node: tree_sitter::Node, cnt: &mut usize) {
422            if node.kind() == "if_statement" {
423                let mut cx = node.walk();
424                let has_else = node.children(&mut cx).any(|c| c.kind() == "else");
425                if has_else {
426                    let mut cx2 = node.walk();
427                    for child in node.children(&mut cx2) {
428                        if child.kind() == "block" || child.kind() == "compound_statement" {
429                            let mut cx3 = child.walk();
430                            let has_ret = child.children(&mut cx3).any(has_return_statement);
431                            if has_ret {
432                                *cnt += 1;
433                                break;
434                            }
435                        }
436                    }
437                }
438            }
439            let mut cx4 = node.walk();
440            for child in node.children(&mut cx4) {
441                check_else_return(_file, child, cnt);
442            }
443        }
444        check_else_return(file, file.root_node(), &mut count);
445
446        for m in batch {
447            for c in m {
448                if c.name == "ui_pkg" || c.name == "ui_import" {
449                    count += 1;
450                }
451            }
452        }
453
454        count
455    }
456
457    fn count_dead_code(&self, file: &ParsedFile) -> usize {
458        let mut count = 0;
459        let mut dead_start: Option<usize> = None;
460        for (line_num, line) in file.content.lines().enumerate() {
461            let trimmed = line.trim();
462            if trimmed == "return"
463                || trimmed == "return;"
464                || trimmed == "break"
465                || trimmed == "break;"
466                || trimmed == "continue"
467                || trimmed == "continue;"
468                || (trimmed.starts_with("return ")
469                    && (trimmed.ends_with(';') || !trimmed.ends_with('}')))
470                || trimmed.starts_with("panic(")
471                || trimmed.starts_with("goto ")
472            {
473                dead_start = Some(line_num + 2);
474                continue;
475            }
476            if let Some(start) = dead_start {
477                if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("/*") {
478                    continue;
479                }
480                if trimmed == "}"
481                    || trimmed.starts_with("} else")
482                    || trimmed.starts_with("} else if")
483                {
484                    dead_start = None;
485                    continue;
486                }
487                if line_num + 1 >= start {
488                    count += 1;
489                    dead_start = None;
490                }
491            }
492        }
493        count
494    }
495
496    fn count_duplicate_imports(&self, file: &ParsedFile) -> usize {
497        count_duplicate_imports_with(file, &["import ", "import ("])
498    }
499}
500
501impl GoAdapter {
502    fn count_excessive_from_batch_with<'a>(
503        &self,
504        _file: &ParsedFile,
505        batch: &[Vec<QueryCapture<'a>>],
506        threshold: usize,
507    ) -> usize {
508        let mut count = 0;
509        for m in batch {
510            for c in m {
511                if c.name == "ep_params" && count_params(c.text) > threshold {
512                    count += 1;
513                }
514            }
515        }
516        count
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::super::parse_code;
523    use super::*;
524
525    fn parse_go(code: &str) -> ParsedFile {
526        parse_code(code, "test.go").expect("parse")
527    }
528
529    #[test]
530    fn test_go_count_panic_calls() {
531        let code = r#"
532package main
533func main() {
534    panic("boom")
535    panic("bang")
536}
537"#;
538        let file = parse_go(code);
539        let adapter = GoAdapter;
540        assert_eq!(adapter.count_panic_calls(&file), 2);
541    }
542
543    #[test]
544    fn test_go_count_panic_calls_clean() {
545        let code = "package main\nfunc main() { println(\"ok\") }\n";
546        let file = parse_go(code);
547        let adapter = GoAdapter;
548        assert_eq!(adapter.count_panic_calls(&file), 0);
549    }
550
551    #[test]
552    fn test_go_extract_functions() {
553        let code = r#"
554package main
555func foo() {}
556func bar(x int) int { return x }
557"#;
558        let file = parse_go(code);
559        let adapter = GoAdapter;
560        let fns = adapter.extract_functions(&file);
561        assert_eq!(fns.len(), 2);
562        assert_eq!(fns[0].name, "foo");
563        assert_eq!(fns[1].name, "bar");
564    }
565
566    #[test]
567    fn test_go_naming_single_letter() {
568        let code = r#"
569package main
570func main() {
571    x := 1
572    y := 2
573}
574"#;
575        let file = parse_go(code);
576        let adapter = GoAdapter;
577        assert_eq!(adapter.count_naming_violations(&file), 2);
578    }
579
580    #[test]
581    fn test_go_debug_fmt_println() {
582        let code = r#"
583package main
584import "fmt"
585func helper() {
586    fmt.Println("hello")
587    fmt.Printf("x=%d", 1)
588}
589"#;
590        let file = parse_go(code);
591        let adapter = GoAdapter;
592        assert_eq!(adapter.count_debug_calls(&file), 2);
593    }
594
595    #[test]
596    fn test_go_excessive_params() {
597        let code = "package main\nfunc process(a, b, c, d, e, f int) {}\n";
598        let file = parse_go(code);
599        let adapter = GoAdapter;
600        assert_eq!(adapter.count_excessive_params(&file, 5), 1);
601    }
602
603    #[test]
604    fn test_go_magic_numbers() {
605        let code = r#"
606package main
607func main() {
608    x := 41 + 1
609    y := x * 100
610}
611"#;
612        let file = parse_go(code);
613        let adapter = GoAdapter;
614        assert_eq!(adapter.count_magic_numbers(&file), 2);
615    }
616
617    #[test]
618    fn test_go_magic_numbers_skips_trivial() {
619        let code = r#"
620package main
621func main() {
622    x := 0 + 1
623}
624"#;
625        let file = parse_go(code);
626        let adapter = GoAdapter;
627        assert_eq!(adapter.count_magic_numbers(&file), 0);
628    }
629
630    #[test]
631    fn test_go_unsafe_pointer() {
632        let code = r#"
633package main
634import "unsafe"
635func main() {
636    p := unsafe.Pointer(nil)
637}
638"#;
639        let file = parse_go(code);
640        let adapter = GoAdapter;
641        assert!(adapter.count_go_convention_violations(&file) >= 2);
642    }
643}