Skip to main content

rigsql_rules/structure/
st03.rs

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