Skip to main content

rigsql_rules/layout/
lt08.rs

1use rigsql_core::SegmentType;
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6/// LT08: Blank line expected but not found before CTE definition.
7///
8/// Within a WITH clause, each CTE definition (after the first) should be
9/// preceded by a blank line for readability.
10#[derive(Debug, Default)]
11pub struct RuleLT08;
12
13impl Rule for RuleLT08 {
14    fn code(&self) -> &'static str {
15        "LT08"
16    }
17    fn name(&self) -> &'static str {
18        "layout.cte_newline"
19    }
20    fn description(&self) -> &'static str {
21        "Blank line expected but not found before CTE definition."
22    }
23    fn explanation(&self) -> &'static str {
24        "When a WITH clause contains multiple CTEs, each CTE after the first should \
25         be separated by a blank line to improve readability. A single newline between \
26         CTEs makes it harder to distinguish where one ends and the next begins."
27    }
28    fn groups(&self) -> &[RuleGroup] {
29        &[RuleGroup::Layout]
30    }
31    fn is_fixable(&self) -> bool {
32        true
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        let mut violations = Vec::new();
42        let mut cte_count = 0;
43
44        for (i, child) in children.iter().enumerate() {
45            if child.segment_type() != SegmentType::CteDefinition {
46                continue;
47            }
48            cte_count += 1;
49            if cte_count <= 1 {
50                continue;
51            }
52
53            // Single backward scan: count newlines and find insertion point
54            let (newline_count, insert_offset) = scan_trivia_before(children, i);
55
56            if newline_count < 2 {
57                violations.push(LintViolation::with_fix(
58                    self.code(),
59                    "Expected blank line before CTE definition.",
60                    child.span(),
61                    vec![SourceEdit::insert(insert_offset, "\n")],
62                ));
63            }
64        }
65
66        violations
67    }
68}
69
70/// Single backward scan: count consecutive newlines before this CTE
71/// and find the insertion point (end of the nearest newline).
72fn scan_trivia_before(children: &[rigsql_core::Segment], cte_idx: usize) -> (usize, u32) {
73    let mut newline_count = 0;
74    let mut last_newline_end = children[cte_idx].span().start;
75
76    for child in children[..cte_idx].iter().rev() {
77        let st = child.segment_type();
78        if st == SegmentType::Newline {
79            newline_count += 1;
80            if newline_count == 1 {
81                last_newline_end = child.span().end;
82            }
83        } else if st.is_trivia() {
84            continue;
85        } else {
86            break;
87        }
88    }
89
90    (newline_count, last_newline_end)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::test_utils::lint_sql;
97
98    #[test]
99    fn test_lt08_accepts_single_cte() {
100        let violations = lint_sql("WITH cte AS (SELECT 1) SELECT * FROM cte", RuleLT08);
101        assert_eq!(violations.len(), 0);
102    }
103}