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_and_msg_key(
58                    self.code(),
59                    "Expected blank line before CTE definition.",
60                    child.span(),
61                    vec![SourceEdit::insert(insert_offset, "\n")],
62                    "rules.LT08.msg",
63                    vec![],
64                ));
65            }
66        }
67
68        violations
69    }
70}
71
72/// Single backward scan: count consecutive newlines before this CTE
73/// and find the insertion point (end of the nearest newline).
74fn scan_trivia_before(children: &[rigsql_core::Segment], cte_idx: usize) -> (usize, u32) {
75    let mut newline_count = 0;
76    let mut last_newline_end = children[cte_idx].span().start;
77
78    for child in children[..cte_idx].iter().rev() {
79        let st = child.segment_type();
80        if st == SegmentType::Newline {
81            newline_count += 1;
82            if newline_count == 1 {
83                last_newline_end = child.span().end;
84            }
85        } else if st.is_trivia() {
86            continue;
87        } else {
88            break;
89        }
90    }
91
92    (newline_count, last_newline_end)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::test_utils::lint_sql;
99
100    #[test]
101    fn test_lt08_accepts_single_cte() {
102        let violations = lint_sql("WITH cte AS (SELECT 1) SELECT * FROM cte", RuleLT08);
103        assert_eq!(violations.len(), 0);
104    }
105}