Skip to main content

rigsql_rules/aliasing/
al05.rs

1use rigsql_core::{Segment, SegmentType};
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::LintViolation;
5
6/// AL05: Tables/CTEs should not be unused.
7///
8/// Detects WITH clauses where a CTE name is defined but never referenced.
9#[derive(Debug, Default)]
10pub struct RuleAL05;
11
12impl Rule for RuleAL05 {
13    fn code(&self) -> &'static str {
14        "AL05"
15    }
16    fn name(&self) -> &'static str {
17        "aliasing.unused"
18    }
19    fn description(&self) -> &'static str {
20        "Tables/CTEs should not be unused."
21    }
22    fn explanation(&self) -> &'static str {
23        "Every CTE (Common Table Expression) defined in a WITH clause should be \
24         referenced in the main query or in another CTE. Unused CTEs add complexity \
25         without benefit and should be removed."
26    }
27    fn groups(&self) -> &[RuleGroup] {
28        &[RuleGroup::Aliasing]
29    }
30    fn is_fixable(&self) -> bool {
31        false
32    }
33
34    fn crawl_type(&self) -> CrawlType {
35        CrawlType::Segment(vec![SegmentType::WithClause])
36    }
37
38    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
39        let children = ctx.segment.children();
40
41        // Collect CTE names
42        let mut cte_names: Vec<(String, rigsql_core::Span)> = Vec::new();
43        for child in children {
44            if child.segment_type() == SegmentType::CteDefinition {
45                if let Some(name) = extract_cte_name(child) {
46                    cte_names.push((name.to_lowercase(), child.span()));
47                }
48            }
49        }
50
51        if cte_names.is_empty() {
52            return vec![];
53        }
54
55        // Search the root (File) for references, not just the parent statement.
56        // When parsing partially fails, references may end up in sibling Unparsable
57        // segments outside the parent SelectStatement.
58        let raw = ctx.root.raw().to_lowercase();
59
60        let mut violations = Vec::new();
61        for (name, span) in &cte_names {
62            // Simple heuristic: check if the CTE name appears elsewhere in the statement
63            // beyond its own definition. Count occurrences.
64            let count = raw.matches(name.as_str()).count();
65            // The name appears at least once in its own definition, so if count <= 1, unused
66            if count <= 1 {
67                violations.push(LintViolation::new(
68                    self.code(),
69                    format!("CTE '{}' is defined but not used.", name),
70                    *span,
71                ));
72            }
73        }
74
75        violations
76    }
77}
78
79fn extract_cte_name(cte_def: &Segment) -> Option<String> {
80    // CteDefinition children: name (Identifier) [WS] AS [WS] ( subquery )
81    for child in cte_def.children() {
82        let st = child.segment_type();
83        if st == SegmentType::Identifier || st == SegmentType::QuotedIdentifier {
84            if let Segment::Token(t) = child {
85                return Some(t.token.text.to_string());
86            }
87        }
88        if st == SegmentType::Keyword {
89            // Stop at AS keyword
90            break;
91        }
92    }
93    None
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::test_utils::lint_sql;
100
101    #[test]
102    fn test_al05_flags_unused_cte() {
103        let violations = lint_sql(
104            "WITH unused AS (SELECT 1) SELECT * FROM other_table",
105            RuleAL05,
106        );
107        assert_eq!(violations.len(), 1);
108    }
109
110    #[test]
111    fn test_al05_accepts_used_cte() {
112        let violations = lint_sql("WITH cte AS (SELECT 1) SELECT * FROM cte", RuleAL05);
113        assert_eq!(violations.len(), 0);
114    }
115}