/home/noah/src/ruchy/src/lints/mod.rs
Line | Count | Source |
1 | | /// Custom lint rules for Ruchy code quality |
2 | | use crate::frontend::ast::{Expr, ExprKind}; |
3 | | use thiserror::Error; |
4 | | |
5 | | #[derive(Debug, Error)] |
6 | | pub enum LintViolation { |
7 | | #[error("{location}: {message} (severity: {severity:?})")] |
8 | | Violation { |
9 | | location: String, |
10 | | message: String, |
11 | | severity: Severity, |
12 | | suggestion: Option<String>, |
13 | | }, |
14 | | } |
15 | | |
16 | | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
17 | | pub enum Severity { |
18 | | Error, |
19 | | Warning, |
20 | | Info, |
21 | | } |
22 | | |
23 | | /// Trait for implementing lint rules |
24 | | pub trait LintRule: Send + Sync { |
25 | | fn name(&self) -> &str; |
26 | | fn check_expression(&self, expr: &Expr) -> Vec<LintViolation>; |
27 | | } |
28 | | |
29 | | /// Main linter that runs all rules |
30 | | pub struct RuchyLinter { |
31 | | rules: Vec<Box<dyn LintRule>>, |
32 | | } |
33 | | |
34 | | impl Default for RuchyLinter { |
35 | 1 | fn default() -> Self { |
36 | 1 | Self::new() |
37 | 1 | } |
38 | | } |
39 | | |
40 | | impl RuchyLinter { |
41 | 6 | pub fn new() -> Self { |
42 | 6 | let rules: Vec<Box<dyn LintRule>> = vec![ |
43 | 6 | Box::new(ComplexityRule::default()), |
44 | 6 | Box::new(NoDebugPrintRule), |
45 | | ]; |
46 | | |
47 | 6 | Self { rules } |
48 | 6 | } |
49 | | |
50 | 2 | pub fn add_rule(&mut self, rule: Box<dyn LintRule>) { |
51 | 2 | self.rules.push(rule); |
52 | 2 | } |
53 | | |
54 | 3 | pub fn lint(&self, expr: &Expr) -> Vec<LintViolation> { |
55 | 3 | let mut violations = Vec::new(); |
56 | | |
57 | 10 | for rule7 in &self.rules { |
58 | 7 | violations.extend(rule.check_expression(expr)); |
59 | 7 | } |
60 | | |
61 | 3 | violations |
62 | 3 | } |
63 | | } |
64 | | |
65 | | /// Rule: Cyclomatic complexity limit |
66 | | #[derive(Default)] |
67 | | struct ComplexityRule { |
68 | | max_complexity: usize, |
69 | | } |
70 | | |
71 | | impl ComplexityRule { |
72 | | #[allow(clippy::only_used_in_recursion)] |
73 | 31 | fn calculate_complexity(&self, expr: &Expr) -> usize { |
74 | 31 | match &expr.kind { |
75 | | ExprKind::If { |
76 | 4 | condition, |
77 | 4 | then_branch, |
78 | 4 | else_branch, |
79 | | } => { |
80 | 4 | 1 + self.calculate_complexity(condition) |
81 | 4 | + self.calculate_complexity(then_branch) |
82 | 4 | + else_branch |
83 | 4 | .as_ref() |
84 | 4 | .map_or(0, |e| self2 .calculate_complexity2 (e2 )) |
85 | | } |
86 | 1 | ExprKind::Match { expr, arms } => { |
87 | 1 | 1 + self.calculate_complexity(expr) |
88 | 1 | + arms |
89 | 1 | .iter() |
90 | 2 | .map1 (|arm| self.calculate_complexity(&arm.body)) |
91 | 1 | .sum::<usize>() |
92 | | } |
93 | 1 | ExprKind::While { condition, body } => { |
94 | 1 | 1 + self.calculate_complexity(condition) + self.calculate_complexity(body) |
95 | | } |
96 | 1 | ExprKind::For { iter, body, .. } => { |
97 | 1 | 1 + self.calculate_complexity(iter) + self.calculate_complexity(body) |
98 | | } |
99 | 1 | ExprKind::Binary { left, right, .. } => { |
100 | 1 | self.calculate_complexity(left) + self.calculate_complexity(right) |
101 | | } |
102 | 23 | _ => 0, |
103 | | } |
104 | 31 | } |
105 | | } |
106 | | |
107 | | impl LintRule for ComplexityRule { |
108 | 1 | fn name(&self) -> &'static str { |
109 | 1 | "complexity" |
110 | 1 | } |
111 | | |
112 | 12 | fn check_expression(&self, expr: &Expr) -> Vec<LintViolation> { |
113 | 12 | let mut violations = Vec::new(); |
114 | 12 | let max = if self.max_complexity == 0 { |
115 | 5 | 10 |
116 | | } else { |
117 | 7 | self.max_complexity |
118 | | }; |
119 | | |
120 | 12 | let complexity = self.calculate_complexity(expr); |
121 | 12 | if complexity > max { |
122 | 1 | violations.push(LintViolation::Violation { |
123 | 1 | location: format!("position {}", expr.span.start), |
124 | 1 | message: format!("Cyclomatic complexity is {complexity} (max: {max})"), |
125 | 1 | severity: Severity::Warning, |
126 | 1 | suggestion: Some("Consider breaking this into smaller functions".to_string()), |
127 | 1 | }); |
128 | 11 | } |
129 | | |
130 | 12 | violations |
131 | 12 | } |
132 | | } |
133 | | |
134 | | /// Rule: No debug print statements |
135 | | struct NoDebugPrintRule; |
136 | | |
137 | | impl LintRule for NoDebugPrintRule { |
138 | 1 | fn name(&self) -> &'static str { |
139 | 1 | "no_debug_print" |
140 | 1 | } |
141 | | |
142 | 8 | fn check_expression(&self, expr: &Expr) -> Vec<LintViolation> { |
143 | 8 | match &expr.kind { |
144 | 6 | ExprKind::Call { func, .. } => { |
145 | 6 | if let ExprKind::Identifier(name5 ) = &func.kind { |
146 | 5 | if name == "dbg" || name == "debug_print"2 { |
147 | 4 | vec![LintViolation::Violation { |
148 | 4 | location: format!("position {}", expr.span.start), |
149 | 4 | message: "Debug print statement found".to_string(), |
150 | 4 | severity: Severity::Warning, |
151 | 4 | suggestion: Some( |
152 | 4 | "Remove debug statements before committing".to_string(), |
153 | 4 | ), |
154 | 4 | }] |
155 | | } else { |
156 | 1 | vec![] |
157 | | } |
158 | | } else { |
159 | 1 | vec![] |
160 | | } |
161 | | } |
162 | 2 | _ => vec![], |
163 | | } |
164 | 8 | } |
165 | | } |
166 | | |
167 | | #[cfg(test)] |
168 | | mod tests { |
169 | | use super::*; |
170 | | use crate::frontend::ast::{Expr, ExprKind, Span, Literal, BinaryOp}; |
171 | | |
172 | 44 | fn make_test_expr(kind: ExprKind) -> Expr { |
173 | 44 | Expr { |
174 | 44 | kind, |
175 | 44 | span: Span::new(0, 10), |
176 | 44 | attributes: vec![], |
177 | 44 | } |
178 | 44 | } |
179 | | |
180 | | #[test] |
181 | 1 | fn test_linter_creation() { |
182 | 1 | let linter = RuchyLinter::new(); |
183 | 1 | assert_eq!(linter.rules.len(), 2); |
184 | 1 | } |
185 | | |
186 | | #[test] |
187 | 1 | fn test_linter_default() { |
188 | 1 | let linter = RuchyLinter::default(); |
189 | 1 | assert_eq!(linter.rules.len(), 2); |
190 | 1 | } |
191 | | |
192 | | #[test] |
193 | 1 | fn test_add_custom_rule() { |
194 | | struct TestRule; |
195 | | impl LintRule for TestRule { |
196 | 0 | fn name(&self) -> &'static str { |
197 | 0 | "test" |
198 | 0 | } |
199 | 0 | fn check_expression(&self, _expr: &Expr) -> Vec<LintViolation> { |
200 | 0 | vec![] |
201 | 0 | } |
202 | | } |
203 | | |
204 | 1 | let mut linter = RuchyLinter::new(); |
205 | 1 | linter.add_rule(Box::new(TestRule)); |
206 | 1 | assert_eq!(linter.rules.len(), 3); |
207 | 1 | } |
208 | | |
209 | | #[test] |
210 | 1 | fn test_severity_display() { |
211 | 1 | assert_eq!(format!("{:?}", Severity::Error), "Error"); |
212 | 1 | assert_eq!(format!("{:?}", Severity::Warning), "Warning"); |
213 | 1 | assert_eq!(format!("{:?}", Severity::Info), "Info"); |
214 | 1 | } |
215 | | |
216 | | #[test] |
217 | 1 | fn test_severity_equality() { |
218 | 1 | assert_eq!(Severity::Error, Severity::Error); |
219 | 1 | assert_ne!(Severity::Error, Severity::Warning); |
220 | 1 | } |
221 | | |
222 | | #[test] |
223 | 1 | fn test_lint_violation_display() { |
224 | 1 | let violation = LintViolation::Violation { |
225 | 1 | location: "line 5".to_string(), |
226 | 1 | message: "Test violation".to_string(), |
227 | 1 | severity: Severity::Warning, |
228 | 1 | suggestion: Some("Fix it".to_string()), |
229 | 1 | }; |
230 | | |
231 | 1 | let display = violation.to_string(); |
232 | 1 | assert!(display.contains("line 5")); |
233 | 1 | assert!(display.contains("Test violation")); |
234 | 1 | assert!(display.contains("Warning")); |
235 | 1 | } |
236 | | |
237 | | #[test] |
238 | 1 | fn test_lint_violation_without_suggestion() { |
239 | 1 | let violation = LintViolation::Violation { |
240 | 1 | location: "position 10".to_string(), |
241 | 1 | message: "Error found".to_string(), |
242 | 1 | severity: Severity::Error, |
243 | 1 | suggestion: None, |
244 | 1 | }; |
245 | | |
246 | 1 | let display = violation.to_string(); |
247 | 1 | assert!(display.contains("position 10")); |
248 | 1 | assert!(display.contains("Error found")); |
249 | 1 | assert!(display.contains("Error")); |
250 | 1 | } |
251 | | |
252 | | #[test] |
253 | 1 | fn test_complexity_rule_simple() { |
254 | 1 | let rule = ComplexityRule::default(); |
255 | 1 | let expr = make_test_expr(ExprKind::Literal(Literal::Integer(42))); |
256 | 1 | let violations = rule.check_expression(&expr); |
257 | 1 | assert!(violations.is_empty()); |
258 | 1 | } |
259 | | |
260 | | #[test] |
261 | 1 | fn test_complexity_rule_name() { |
262 | 1 | let rule = ComplexityRule::default(); |
263 | 1 | assert_eq!(rule.name(), "complexity"); |
264 | 1 | } |
265 | | |
266 | | #[test] |
267 | 1 | fn test_complexity_rule_if_statement() { |
268 | 1 | let rule = ComplexityRule { max_complexity: 0 }; // Will use default of 10 |
269 | | |
270 | | // Create a simple if statement |
271 | 1 | let if_expr = make_test_expr(ExprKind::If { |
272 | 1 | condition: Box::new(make_test_expr(ExprKind::Literal(Literal::Bool(true)))), |
273 | 1 | then_branch: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(1)))), |
274 | 1 | else_branch: None, |
275 | 1 | }); |
276 | | |
277 | 1 | let violations = rule.check_expression(&if_expr); |
278 | 1 | assert!(violations.is_empty()); // Complexity is 1, under limit of 10 |
279 | 1 | } |
280 | | |
281 | | #[test] |
282 | 1 | fn test_complexity_rule_nested_if_exceeds_limit() { |
283 | 1 | let rule = ComplexityRule { max_complexity: 1 }; |
284 | | |
285 | | // Create nested if statements to exceed complexity of 1 |
286 | 1 | let inner_if = make_test_expr(ExprKind::If { |
287 | 1 | condition: Box::new(make_test_expr(ExprKind::Literal(Literal::Bool(true)))), |
288 | 1 | then_branch: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(1)))), |
289 | 1 | else_branch: None, |
290 | 1 | }); |
291 | | |
292 | 1 | let outer_if = make_test_expr(ExprKind::If { |
293 | 1 | condition: Box::new(make_test_expr(ExprKind::Literal(Literal::Bool(false)))), |
294 | 1 | then_branch: Box::new(inner_if), |
295 | 1 | else_branch: Some(Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(2))))), |
296 | 1 | }); |
297 | | |
298 | 1 | let violations = rule.check_expression(&outer_if); |
299 | 1 | assert!(!violations.is_empty()); |
300 | 1 | assert!(violations[0].to_string().contains("Cyclomatic complexity")); |
301 | 1 | } |
302 | | |
303 | | #[test] |
304 | 1 | fn test_complexity_rule_while_loop() { |
305 | 1 | let rule = ComplexityRule { max_complexity: 5 }; |
306 | | |
307 | 1 | let while_expr = make_test_expr(ExprKind::While { |
308 | 1 | condition: Box::new(make_test_expr(ExprKind::Literal(Literal::Bool(true)))), |
309 | 1 | body: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(42)))), |
310 | 1 | }); |
311 | | |
312 | 1 | let violations = rule.check_expression(&while_expr); |
313 | 1 | assert!(violations.is_empty()); // Complexity is 1, under limit |
314 | 1 | } |
315 | | |
316 | | #[test] |
317 | 1 | fn test_complexity_rule_for_loop() { |
318 | 1 | let rule = ComplexityRule { max_complexity: 5 }; |
319 | | |
320 | 1 | let for_expr = make_test_expr(ExprKind::For { |
321 | 1 | var: "i".to_string(), |
322 | 1 | pattern: None, |
323 | 1 | iter: Box::new(make_test_expr(ExprKind::Range { |
324 | 1 | start: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(0)))), |
325 | 1 | end: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(10)))), |
326 | 1 | inclusive: false, |
327 | 1 | })), |
328 | 1 | body: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(42)))), |
329 | 1 | }); |
330 | | |
331 | 1 | let violations = rule.check_expression(&for_expr); |
332 | 1 | assert!(violations.is_empty()); // Complexity is 1, under limit |
333 | 1 | } |
334 | | |
335 | | #[test] |
336 | 1 | fn test_complexity_rule_binary_operation() { |
337 | 1 | let rule = ComplexityRule { max_complexity: 5 }; |
338 | | |
339 | 1 | let binary_expr = make_test_expr(ExprKind::Binary { |
340 | 1 | left: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(1)))), |
341 | 1 | op: BinaryOp::Add, |
342 | 1 | right: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(2)))), |
343 | 1 | }); |
344 | | |
345 | 1 | let violations = rule.check_expression(&binary_expr); |
346 | 1 | assert!(violations.is_empty()); // Binary operations don't add complexity |
347 | 1 | } |
348 | | |
349 | | #[test] |
350 | 1 | fn test_no_debug_print_rule() { |
351 | 1 | let rule = NoDebugPrintRule; |
352 | | |
353 | | // Test normal function call - no violation |
354 | 1 | let normal_call = make_test_expr(ExprKind::Call { |
355 | 1 | func: Box::new(make_test_expr(ExprKind::Identifier("println".to_string()))), |
356 | 1 | args: vec![], |
357 | 1 | }); |
358 | 1 | assert!(rule.check_expression(&normal_call).is_empty()); |
359 | | |
360 | | // Test debug print - should have violation |
361 | 1 | let debug_call = make_test_expr(ExprKind::Call { |
362 | 1 | func: Box::new(make_test_expr(ExprKind::Identifier("dbg".to_string()))), |
363 | 1 | args: vec![], |
364 | 1 | }); |
365 | 1 | let violations = rule.check_expression(&debug_call); |
366 | 1 | assert_eq!(violations.len(), 1); |
367 | 1 | assert!(violations[0].to_string().contains("Debug print")); |
368 | | |
369 | | // Test debug_print - should have violation |
370 | 1 | let debug_print = make_test_expr(ExprKind::Call { |
371 | 1 | func: Box::new(make_test_expr(ExprKind::Identifier("debug_print".to_string()))), |
372 | 1 | args: vec![], |
373 | 1 | }); |
374 | 1 | let violations = rule.check_expression(&debug_print); |
375 | 1 | assert_eq!(violations.len(), 1); |
376 | 1 | } |
377 | | |
378 | | #[test] |
379 | 1 | fn test_no_debug_print_rule_name() { |
380 | 1 | let rule = NoDebugPrintRule; |
381 | 1 | assert_eq!(rule.name(), "no_debug_print"); |
382 | 1 | } |
383 | | |
384 | | #[test] |
385 | 1 | fn test_linter_runs_all_rules() { |
386 | 1 | let linter = RuchyLinter::new(); |
387 | | |
388 | | // Create expression that violates debug print rule |
389 | 1 | let expr = make_test_expr(ExprKind::Call { |
390 | 1 | func: Box::new(make_test_expr(ExprKind::Identifier("dbg".to_string()))), |
391 | 1 | args: vec![], |
392 | 1 | }); |
393 | | |
394 | 1 | let violations = linter.lint(&expr); |
395 | 1 | assert!(!violations.is_empty()); |
396 | 1 | } |
397 | | |
398 | | #[test] |
399 | 1 | fn test_linter_no_violations() { |
400 | 1 | let linter = RuchyLinter::new(); |
401 | | |
402 | | // Create simple expression with no violations |
403 | 1 | let expr = make_test_expr(ExprKind::Literal(Literal::Integer(42))); |
404 | | |
405 | 1 | let violations = linter.lint(&expr); |
406 | 1 | assert!(violations.is_empty()); |
407 | 1 | } |
408 | | |
409 | | #[test] |
410 | 1 | fn test_complexity_calculation_match() { |
411 | 1 | let rule = ComplexityRule { max_complexity: 10 }; |
412 | | |
413 | | // Match expressions add complexity |
414 | | use crate::frontend::ast::{MatchArm, Pattern}; |
415 | | |
416 | 1 | let arms = vec![ |
417 | 1 | MatchArm { |
418 | 1 | pattern: Pattern::Literal(Literal::Integer(1)), |
419 | 1 | guard: None, |
420 | 1 | body: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(10)))), |
421 | 1 | span: Span::new(0, 10), |
422 | 1 | }, |
423 | 1 | MatchArm { |
424 | 1 | pattern: Pattern::Literal(Literal::Integer(2)), |
425 | 1 | guard: None, |
426 | 1 | body: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(20)))), |
427 | 1 | span: Span::new(0, 10), |
428 | 1 | }, |
429 | | ]; |
430 | | |
431 | 1 | let match_expr = make_test_expr(ExprKind::Match { |
432 | 1 | expr: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(42)))), |
433 | 1 | arms, |
434 | 1 | }); |
435 | | |
436 | 1 | let violations = rule.check_expression(&match_expr); |
437 | 1 | assert!(violations.is_empty()); // Under complexity limit |
438 | 1 | } |
439 | | |
440 | | #[test] |
441 | 1 | fn test_complexity_rule_with_custom_max() { |
442 | 1 | let rule = ComplexityRule { max_complexity: 3 }; |
443 | | |
444 | | // Simple literal should not trigger |
445 | 1 | let expr = make_test_expr(ExprKind::Literal(Literal::Integer(42))); |
446 | 1 | let violations = rule.check_expression(&expr); |
447 | 1 | assert!(violations.is_empty()); |
448 | 1 | } |
449 | | |
450 | | #[test] |
451 | 1 | fn test_no_debug_print_rule_non_call_expression() { |
452 | 1 | let rule = NoDebugPrintRule; |
453 | | |
454 | | // Test with non-call expression |
455 | 1 | let expr = make_test_expr(ExprKind::Literal(Literal::String("test".to_string()))); |
456 | 1 | let violations = rule.check_expression(&expr); |
457 | 1 | assert!(violations.is_empty()); |
458 | 1 | } |
459 | | |
460 | | #[test] |
461 | 1 | fn test_no_debug_print_rule_non_identifier_function() { |
462 | 1 | let rule = NoDebugPrintRule; |
463 | | |
464 | | // Test with non-identifier function (e.g., lambda call) |
465 | 1 | let expr = make_test_expr(ExprKind::Call { |
466 | 1 | func: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(42)))), |
467 | 1 | args: vec![], |
468 | 1 | }); |
469 | | |
470 | 1 | let violations = rule.check_expression(&expr); |
471 | 1 | assert!(violations.is_empty()); |
472 | 1 | } |
473 | | |
474 | | #[test] |
475 | 1 | fn test_lint_violation_debug_formatting() { |
476 | 1 | let violation = LintViolation::Violation { |
477 | 1 | location: "test.ruchy:10:5".to_string(), |
478 | 1 | message: "Complex expression detected".to_string(), |
479 | 1 | severity: Severity::Info, |
480 | 1 | suggestion: None, |
481 | 1 | }; |
482 | | |
483 | 1 | let debug_str = format!("{violation:?}"); |
484 | 1 | assert!(debug_str.contains("Violation")); |
485 | 1 | assert!(debug_str.contains("test.ruchy:10:5")); |
486 | 1 | } |
487 | | |
488 | | #[test] |
489 | 1 | fn test_severity_clone() { |
490 | 1 | let sev1 = Severity::Warning; |
491 | 1 | let sev2 = sev1; |
492 | 1 | assert_eq!(sev1, sev2); |
493 | 1 | } |
494 | | |
495 | | #[test] |
496 | 1 | fn test_complexity_rule_if_with_else() { |
497 | 1 | let rule = ComplexityRule { max_complexity: 5 }; |
498 | | |
499 | 1 | let if_expr = make_test_expr(ExprKind::If { |
500 | 1 | condition: Box::new(make_test_expr(ExprKind::Literal(Literal::Bool(true)))), |
501 | 1 | then_branch: Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(1)))), |
502 | 1 | else_branch: Some(Box::new(make_test_expr(ExprKind::Literal(Literal::Integer(2))))), |
503 | 1 | }); |
504 | | |
505 | 1 | let violations = rule.check_expression(&if_expr); |
506 | 1 | assert!(violations.is_empty()); |
507 | 1 | } |
508 | | |
509 | | #[test] |
510 | 1 | fn test_multiple_violations_from_linter() { |
511 | 1 | let mut linter = RuchyLinter::new(); |
512 | | |
513 | | // Add another rule that always fails |
514 | | struct AlwaysFailRule; |
515 | | impl LintRule for AlwaysFailRule { |
516 | 0 | fn name(&self) -> &'static str { |
517 | 0 | "always_fail" |
518 | 0 | } |
519 | 1 | fn check_expression(&self, expr: &Expr) -> Vec<LintViolation> { |
520 | 1 | vec![LintViolation::Violation { |
521 | 1 | location: format!("position {}", expr.span.start), |
522 | 1 | message: "Always fails".to_string(), |
523 | 1 | severity: Severity::Error, |
524 | 1 | suggestion: None, |
525 | 1 | }] |
526 | 1 | } |
527 | | } |
528 | | |
529 | 1 | linter.add_rule(Box::new(AlwaysFailRule)); |
530 | | |
531 | | // Create expression that also violates debug print rule |
532 | 1 | let expr = make_test_expr(ExprKind::Call { |
533 | 1 | func: Box::new(make_test_expr(ExprKind::Identifier("dbg".to_string()))), |
534 | 1 | args: vec![], |
535 | 1 | }); |
536 | | |
537 | 1 | let violations = linter.lint(&expr); |
538 | 1 | assert!(violations.len() >= 2); // At least 2 violations |
539 | 1 | } |
540 | | } |