Skip to main content

rigsql_rules/layout/
lt09.rs

1use rigsql_core::SegmentType;
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6/// LT09: Select targets should be on separate lines unless there is only one.
7#[derive(Debug, Default)]
8pub struct RuleLT09;
9
10impl Rule for RuleLT09 {
11    fn code(&self) -> &'static str {
12        "LT09"
13    }
14    fn name(&self) -> &'static str {
15        "layout.select_targets"
16    }
17    fn description(&self) -> &'static str {
18        "Select targets should be on a new line unless there is only one."
19    }
20    fn explanation(&self) -> &'static str {
21        "When a SELECT has multiple columns, each column should be on its own line. \
22         This makes diffs cleaner and improves readability. A single column can stay \
23         on the same line as SELECT."
24    }
25    fn groups(&self) -> &[RuleGroup] {
26        &[RuleGroup::Layout]
27    }
28    fn is_fixable(&self) -> bool {
29        true
30    }
31
32    fn crawl_type(&self) -> CrawlType {
33        CrawlType::Segment(vec![SegmentType::SelectClause])
34    }
35
36    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
37        let children = ctx.segment.children();
38
39        // Count non-trivia, non-keyword, non-comma items (the actual select targets)
40        // A select target is an expression, column ref, alias expression, star, etc.
41        let targets: Vec<_> = children
42            .iter()
43            .filter(|c| {
44                let st = c.segment_type();
45                !st.is_trivia() && st != SegmentType::Keyword && st != SegmentType::Comma
46            })
47            .collect();
48
49        // If 0 or 1 target, no issue
50        if targets.len() <= 1 {
51            return vec![];
52        }
53
54        // Check if SELECT keyword and first target are on the same line
55        // and there's no newline between targets
56        let has_newline_between_targets = children
57            .iter()
58            .any(|c| c.segment_type() == SegmentType::Newline);
59
60        if !has_newline_between_targets {
61            // Build fixes: replace whitespace after each comma with newline+indent
62            let mut fixes = Vec::new();
63            let indent = "    ";
64            for (i, child) in children.iter().enumerate() {
65                // After SELECT keyword, insert newline before first target
66                if child.segment_type() == SegmentType::Keyword && i + 1 < children.len() {
67                    let next = &children[i + 1];
68                    if next.segment_type() == SegmentType::Whitespace {
69                        fixes.push(SourceEdit::replace(next.span(), format!("\n{}", indent)));
70                    }
71                }
72                // After comma, replace whitespace with newline+indent
73                if child.segment_type() == SegmentType::Comma && i + 1 < children.len() {
74                    let next = &children[i + 1];
75                    if next.segment_type() == SegmentType::Whitespace {
76                        fixes.push(SourceEdit::replace(next.span(), format!("\n{}", indent)));
77                    } else {
78                        fixes.push(SourceEdit::insert(
79                            child.span().end,
80                            format!("\n{}", indent),
81                        ));
82                    }
83                }
84            }
85
86            return vec![LintViolation::with_fix_and_msg_key(
87                self.code(),
88                "Select targets should be on separate lines.",
89                ctx.segment.span(),
90                fixes,
91                "rules.LT09.msg",
92                vec![],
93            )];
94        }
95
96        vec![]
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::test_utils::lint_sql;
104
105    #[test]
106    fn test_lt09_flags_multiple_targets_single_line() {
107        let violations = lint_sql("SELECT a, b, c FROM t", RuleLT09);
108        assert_eq!(violations.len(), 1);
109        assert_eq!(violations[0].rule_code, "LT09");
110        // Should have fixes to insert newlines
111        assert!(!violations[0].fixes.is_empty());
112    }
113
114    #[test]
115    fn test_lt09_accepts_single_target() {
116        let violations = lint_sql("SELECT a FROM t", RuleLT09);
117        assert_eq!(violations.len(), 0);
118    }
119}