Skip to main content

garbage_code_hunter/treesitter/
rule.rs

1use std::path::Path;
2
3use crate::analyzer::CodeIssue;
4use crate::context::{FileContext, ProjectConfig};
5use crate::language::Language;
6
7use super::engine::ParsedFile;
8
9/// A code quality rule that analyzes source files using tree-sitter AST.
10///
11/// Unlike the original [`crate::rules::Rule`] trait which requires `syn::File`,
12/// this trait works on tree-sitter's language-agnostic CST. Rules declare
13/// which languages they support via [`supported_languages`](TreeSitterRule::supported_languages).
14pub trait TreeSitterRule: Send + Sync {
15    /// Unique identifier for this rule (e.g. `"deep-nesting"`).
16    fn name(&self) -> &'static str;
17
18    /// Languages supported by this rule.
19    fn supported_languages(&self) -> &'static [Language];
20
21    /// Whether to skip test files (default: true).
22    fn skips_test_files(&self) -> bool {
23        true
24    }
25
26    /// Analyze a parsed file and return detected issues.
27    fn check(&self, file: &ParsedFile) -> Vec<CodeIssue>;
28
29    /// Analyze a file with additional context about the file's role in the project.
30    /// Override this when the rule needs to adjust behavior based on file context
31    /// (e.g. skipping UI files, relaxing thresholds for examples) or config.
32    #[allow(clippy::too_many_arguments)]
33    fn check_with_context(
34        &self,
35        file: &ParsedFile,
36        _is_test_file: bool,
37        _context: &FileContext,
38        _config: &ProjectConfig,
39    ) -> Vec<CodeIssue> {
40        self.check(file)
41    }
42}
43
44/// Engine that runs all registered tree-sitter rules against parsed files.
45///
46/// Supports both trait-based [`TreeSitterRule`] implementations and
47/// declarative [`QueryRule`](crate::treesitter::query::QueryRule) definitions.
48pub struct TreeSitterRuleEngine {
49    rules: Vec<Box<dyn TreeSitterRule>>,
50}
51
52impl Default for TreeSitterRuleEngine {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl TreeSitterRuleEngine {
59    pub fn new() -> Self {
60        Self { rules: Vec::new() }
61    }
62
63    /// Register a trait-based rule.
64    pub fn add(&mut self, rule: Box<dyn TreeSitterRule>) {
65        self.rules.push(rule);
66    }
67
68    /// Register a declarative query-based rule by wrapping it.
69    pub fn add_query_rule(&mut self, query_rule: crate::treesitter::query::QueryRule) {
70        self.rules.push(Box::new(QueryRuleAdapter::new(query_rule)));
71    }
72
73    /// Register multiple query rules at once.
74    pub fn add_query_rules(&mut self, query_rules: Vec<crate::treesitter::query::QueryRule>) {
75        for qr in query_rules {
76            self.add_query_rule(qr);
77        }
78    }
79
80    /// Run all applicable rules against a parsed file.
81    pub fn check_file(&self, file: &ParsedFile, is_test_file: bool) -> Vec<CodeIssue> {
82        self.check_file_with_context(
83            file,
84            is_test_file,
85            &FileContext::from_path(&file.path),
86            &ProjectConfig::default(),
87        )
88    }
89
90    /// Run all applicable rules with full context and config.
91    pub fn check_file_with_context(
92        &self,
93        file: &ParsedFile,
94        is_test_file: bool,
95        context: &FileContext,
96        config: &ProjectConfig,
97    ) -> Vec<CodeIssue> {
98        let mut issues = Vec::new();
99        for rule in &self.rules {
100            if is_test_file && rule.skips_test_files() {
101                continue;
102            }
103            if !rule.supported_languages().contains(&file.language) {
104                continue;
105            }
106            if Self::is_rule_disabled(config, rule.name()) {
107                continue;
108            }
109            issues.extend(rule.check_with_context(file, is_test_file, context, config));
110        }
111        issues
112    }
113
114    /// Check if a rule is disabled by project config.
115    fn is_rule_disabled(config: &ProjectConfig, rule_name: &str) -> bool {
116        match rule_name {
117            "terrible-naming"
118            | "single-letter-variable"
119            | "meaningless-naming"
120            | "hungarian-notation"
121            | "abbreviation-abuse" => !config.rules.naming.enabled,
122            "unwrap-abuse" => !config.rules.unwrap.enabled,
123            "magic-number" => !config.rules.magic_number.enabled,
124            "println-debugging" => !config.rules.println.enabled,
125            _ => false,
126        }
127    }
128
129    /// Check if a file path indicates a test file (shared logic).
130    pub fn is_test_file(path: &Path, content: &str) -> bool {
131        let path_str = path.to_string_lossy();
132        let normalized = path_str.strip_prefix("./").unwrap_or(&path_str);
133
134        if normalized.contains("/tests/")
135            || normalized.contains("\\tests\\")
136            || normalized.starts_with("tests/")
137            || normalized.starts_with("tests\\")
138            || normalized.contains("/test/")
139            || normalized.contains("\\test\\")
140            || normalized.ends_with("_test.rs")
141            || normalized.ends_with("_tests.rs")
142            || normalized.ends_with("_test.py")
143            || normalized.ends_with("_test.js")
144            || normalized.ends_with("_test.ts")
145            || normalized.ends_with("_test.go")
146            || normalized.ends_with("_test.java")
147            || normalized.starts_with("test_")
148        {
149            return true;
150        }
151        if normalized.contains("/examples/")
152            || normalized.contains("\\examples\\")
153            || normalized.starts_with("examples/")
154            || normalized.starts_with("examples\\")
155        {
156            return true;
157        }
158        if normalized.contains("/benches/")
159            || normalized.contains("\\benches\\")
160            || normalized.starts_with("benches/")
161            || normalized.starts_with("benches\\")
162        {
163            return true;
164        }
165
166        content.contains("#[cfg(test)]")
167    }
168
169    pub fn rule_names(&self) -> Vec<&'static str> {
170        self.rules.iter().map(|r| r.name()).collect()
171    }
172}
173
174/// Adapter that wraps a [`QueryRule`] as a [`TreeSitterRule`] trait object.
175///
176/// This enables declarative query-based rules to be used alongside
177/// imperative trait-based rules within the same engine.
178struct QueryRuleAdapter {
179    rule: crate::treesitter::query::QueryRule,
180}
181
182impl QueryRuleAdapter {
183    fn new(rule: crate::treesitter::query::QueryRule) -> Self {
184        Self { rule }
185    }
186}
187
188impl TreeSitterRule for QueryRuleAdapter {
189    fn name(&self) -> &'static str {
190        self.rule.name
191    }
192
193    fn supported_languages(&self) -> &'static [Language] {
194        self.rule.languages
195    }
196
197    fn skips_test_files(&self) -> bool {
198        self.rule.skips_test_files
199    }
200
201    fn check(&self, file: &ParsedFile) -> Vec<CodeIssue> {
202        let candidates = crate::treesitter::query::run_query_rule(file, &self.rule);
203        candidates
204            .into_iter()
205            .map(|c| CodeIssue {
206                file_path: file.path.clone(),
207                line: c.line,
208                column: c.column,
209                rule_name: self.rule.name.to_string(),
210                message: c.message,
211                severity: c.severity,
212            })
213            .collect()
214    }
215
216    fn check_with_context(
217        &self,
218        file: &ParsedFile,
219        _is_test_file: bool,
220        _context: &FileContext,
221        _config: &ProjectConfig,
222    ) -> Vec<CodeIssue> {
223        // Context-aware rules override this; for query rules, just run check
224        self.check(file)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::treesitter::engine::TreeSitterEngine;
232
233    struct DummyRule;
234
235    impl TreeSitterRule for DummyRule {
236        fn name(&self) -> &'static str {
237            "dummy"
238        }
239        fn supported_languages(&self) -> &'static [Language] {
240            &[Language::Rust]
241        }
242        fn check(&self, _file: &ParsedFile) -> Vec<CodeIssue> {
243            vec![]
244        }
245    }
246
247    /// Objective: Verify engine runs rules only for matching languages
248    /// Invariants: Rule should not fire for unsupported languages
249    #[test]
250    fn test_rule_language_filtering() {
251        let mut engine = TreeSitterRuleEngine::new();
252        engine.add(Box::new(DummyRule));
253
254        let ts = TreeSitterEngine::new();
255        let file = ts
256            .parse_file(Path::new("test.rs"), "fn main() {}")
257            .expect("Should parse");
258
259        let issues = engine.check_file(&file, false);
260        assert!(issues.is_empty(), "Dummy rule produces no issues");
261
262        assert_eq!(engine.rule_names(), vec!["dummy"]);
263    }
264
265    /// Objective: Verify test file detection works across languages
266    /// Invariants: Various test file naming patterns should be recognized
267    #[test]
268    fn test_is_test_file_various_patterns() {
269        assert!(TreeSitterRuleEngine::is_test_file(
270            Path::new("src/tests/mod.rs"),
271            ""
272        ));
273        assert!(TreeSitterRuleEngine::is_test_file(
274            Path::new("tests/test_main.rs"),
275            ""
276        ));
277        assert!(TreeSitterRuleEngine::is_test_file(
278            Path::new("foo_test.py"),
279            ""
280        ));
281        assert!(!TreeSitterRuleEngine::is_test_file(
282            Path::new("src/main.rs"),
283            ""
284        ));
285        assert!(TreeSitterRuleEngine::is_test_file(
286            Path::new("src/lib.rs"),
287            "#[cfg(test)]"
288        ));
289    }
290}