Skip to main content

rigsql_rules/layout/
lt14.rs

1use rigsql_core::SegmentType;
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::utils::has_trailing_newline;
5use crate::violation::{LintViolation, SourceEdit};
6
7/// LT14: Keyword clauses should follow a standard for being before/after newlines.
8///
9/// Major clause keywords (FROM, WHERE, GROUP BY, etc.) should start on a new line.
10#[derive(Debug, Default)]
11pub struct RuleLT14;
12
13const CLAUSE_TYPES: &[SegmentType] = &[
14    SegmentType::FromClause,
15    SegmentType::WhereClause,
16    SegmentType::GroupByClause,
17    SegmentType::HavingClause,
18    SegmentType::OrderByClause,
19    SegmentType::LimitClause,
20];
21
22impl Rule for RuleLT14 {
23    fn code(&self) -> &'static str {
24        "LT14"
25    }
26    fn name(&self) -> &'static str {
27        "layout.keyword_newline"
28    }
29    fn description(&self) -> &'static str {
30        "Keyword clauses should follow a standard for being before/after newlines."
31    }
32    fn explanation(&self) -> &'static str {
33        "Major SQL clauses (FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT) should \
34         start on a new line for readability. Placing them on the same line as the \
35         previous clause makes the query harder to scan."
36    }
37    fn groups(&self) -> &[RuleGroup] {
38        &[RuleGroup::Layout]
39    }
40    fn is_fixable(&self) -> bool {
41        true
42    }
43
44    fn crawl_type(&self) -> CrawlType {
45        CrawlType::Segment(vec![SegmentType::SelectStatement])
46    }
47
48    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
49        let children = ctx.segment.children();
50        let mut violations = Vec::new();
51
52        for (i, child) in children.iter().enumerate() {
53            if !CLAUSE_TYPES.contains(&child.segment_type()) {
54                continue;
55            }
56
57            // Check if there's a Newline in siblings before this clause.
58            // Newlines may also be absorbed as the last child of the preceding clause,
59            // so we check both siblings and the trailing children of the previous segment.
60            let mut found_newline = false;
61            for prev in children[..i].iter().rev() {
62                let st = prev.segment_type();
63                if st == SegmentType::Newline {
64                    found_newline = true;
65                    break;
66                }
67                if st == SegmentType::Whitespace {
68                    continue;
69                }
70                // Non-trivia sibling: also check if it ends with a Newline
71                found_newline = has_trailing_newline(prev);
72                break;
73            }
74
75            if !found_newline && i > 0 {
76                let clause_name = match child.segment_type() {
77                    SegmentType::FromClause => "FROM",
78                    SegmentType::WhereClause => "WHERE",
79                    SegmentType::GroupByClause => "GROUP BY",
80                    SegmentType::HavingClause => "HAVING",
81                    SegmentType::OrderByClause => "ORDER BY",
82                    SegmentType::LimitClause => "LIMIT",
83                    _ => "Clause",
84                };
85                // Determine indentation from the statement's line start
86                let indent = get_line_indent(ctx.source, ctx.segment.span().start);
87                let newline_with_indent = format!("\n{}", indent);
88
89                // Replace preceding whitespace with newline+indent, or insert
90                let fix = if i > 0 {
91                    let prev = &children[i - 1];
92                    if prev.segment_type() == SegmentType::Whitespace {
93                        vec![SourceEdit::replace(prev.span(), newline_with_indent)]
94                    } else {
95                        vec![SourceEdit::insert(child.span().start, &newline_with_indent)]
96                    }
97                } else {
98                    vec![SourceEdit::insert(child.span().start, &newline_with_indent)]
99                };
100                violations.push(LintViolation::with_fix(
101                    self.code(),
102                    format!("{} clause should start on a new line.", clause_name),
103                    child.span(),
104                    fix,
105                ));
106            }
107        }
108
109        violations
110    }
111}
112
113/// Extract the leading whitespace of the line containing the given byte offset.
114fn get_line_indent(source: &str, offset: u32) -> &str {
115    let bytes = source.as_bytes();
116    let pos = offset as usize;
117    // Find start of line
118    let mut line_start = pos;
119    while line_start > 0 && bytes[line_start - 1] != b'\n' {
120        line_start -= 1;
121    }
122    // Find end of leading whitespace
123    let mut indent_end = line_start;
124    while indent_end < bytes.len() && (bytes[indent_end] == b' ' || bytes[indent_end] == b'\t') {
125        indent_end += 1;
126    }
127    &source[line_start..indent_end]
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::test_utils::lint_sql;
134
135    #[test]
136    fn test_lt14_accepts_newlines_before_clauses() {
137        let violations = lint_sql("SELECT a\nFROM t\nWHERE x = 1", RuleLT14);
138        assert_eq!(violations.len(), 0);
139    }
140
141    #[test]
142    fn test_lt14_flags_inline_clauses() {
143        let violations = lint_sql("SELECT a FROM t WHERE x = 1", RuleLT14);
144        assert!(!violations.is_empty());
145    }
146
147    #[test]
148    fn test_lt14_accepts_single_clause() {
149        let violations = lint_sql("SELECT 1", RuleLT14);
150        assert_eq!(violations.len(), 0);
151    }
152}