Skip to main content

rigsql_rules/layout/
lt02.rs

1use rigsql_core::{Segment, SegmentType, TokenKind};
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6/// LT02: Incorrect indentation.
7///
8/// Expects consistent indentation (default 4 spaces per level).
9#[derive(Debug)]
10pub struct RuleLT02 {
11    pub indent_size: usize,
12}
13
14impl Default for RuleLT02 {
15    fn default() -> Self {
16        Self { indent_size: 4 }
17    }
18}
19
20impl RuleLT02 {
21    /// Round `value` up to the nearest multiple of `indent_size`.
22    fn round_to_indent(&self, value: usize) -> usize {
23        if value == 0 {
24            self.indent_size
25        } else {
26            value.div_ceil(self.indent_size) * self.indent_size
27        }
28    }
29}
30
31impl Rule for RuleLT02 {
32    fn code(&self) -> &'static str {
33        "LT02"
34    }
35    fn name(&self) -> &'static str {
36        "layout.indent"
37    }
38    fn description(&self) -> &'static str {
39        "Incorrect indentation."
40    }
41    fn explanation(&self) -> &'static str {
42        "SQL should use consistent indentation. Each indentation level should use \
43         the same number of spaces (default 4). Tabs should not be mixed with spaces."
44    }
45    fn groups(&self) -> &[RuleGroup] {
46        &[RuleGroup::Layout]
47    }
48    fn is_fixable(&self) -> bool {
49        true
50    }
51
52    fn configure(&mut self, settings: &std::collections::HashMap<String, String>) {
53        if let Some(val) = settings.get("indent_unit") {
54            if val == "tab" {
55                self.indent_size = 1; // tab mode
56            }
57        }
58        if let Some(val) = settings.get("tab_space_size") {
59            if let Ok(n) = val.parse() {
60                self.indent_size = n;
61            }
62        }
63    }
64
65    fn crawl_type(&self) -> CrawlType {
66        CrawlType::Segment(vec![SegmentType::Whitespace])
67    }
68
69    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
70        let Segment::Token(t) = ctx.segment else {
71            return vec![];
72        };
73        if t.token.kind != TokenKind::Whitespace {
74            return vec![];
75        }
76
77        let text = t.token.text.as_str();
78
79        // Only check indentation (whitespace after a newline)
80        if ctx.index_in_parent == 0 {
81            return vec![];
82        }
83        let prev = &ctx.siblings[ctx.index_in_parent - 1];
84        if prev.segment_type() != SegmentType::Newline {
85            return vec![];
86        }
87
88        // Flag tabs mixed with spaces — convert tabs to spaces
89        if text.contains('\t') && text.contains(' ') {
90            let visual_width: usize = text
91                .chars()
92                .map(|c| if c == '\t' { self.indent_size } else { 1 })
93                .sum();
94            let rounded = self.round_to_indent(visual_width);
95            let fixed = " ".repeat(rounded);
96            return vec![LintViolation::with_fix_and_msg_key(
97                self.code(),
98                "Mixed tabs and spaces in indentation.",
99                t.token.span,
100                vec![SourceEdit::replace(t.token.span, fixed)],
101                "rules.LT02.msg.mixed",
102                vec![],
103            )];
104        }
105
106        // Flag non-multiple of indent_size (space-only indentation)
107        // Round up to the nearest multiple
108        if !text.contains('\t') && text.len() % self.indent_size != 0 {
109            let rounded = self.round_to_indent(text.len());
110            let fixed = " ".repeat(rounded);
111            return vec![LintViolation::with_fix_and_msg_key(
112                self.code(),
113                format!(
114                    "Indentation is not a multiple of {} spaces (found {} spaces).",
115                    self.indent_size,
116                    text.len()
117                ),
118                t.token.span,
119                vec![SourceEdit::replace(t.token.span, fixed)],
120                "rules.LT02.msg.not_multiple",
121                vec![
122                    ("size".to_string(), self.indent_size.to_string()),
123                    ("found".to_string(), text.len().to_string()),
124                ],
125            )];
126        }
127
128        vec![]
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::test_utils::lint_sql;
136
137    #[test]
138    fn test_lt02_flags_odd_indent() {
139        let violations = lint_sql("SELECT *\n   FROM t", RuleLT02::default());
140        assert_eq!(violations.len(), 1);
141        assert_eq!(violations[0].rule_code, "LT02");
142        // 3 spaces → rounded up to 4 spaces
143        assert_eq!(violations[0].fixes.len(), 1);
144        assert_eq!(violations[0].fixes[0].new_text, "    ");
145    }
146
147    #[test]
148    fn test_lt02_accepts_4space_indent() {
149        let violations = lint_sql("SELECT *\n    FROM t", RuleLT02::default());
150        assert_eq!(violations.len(), 0);
151    }
152
153    #[test]
154    fn test_lt02_flags_mixed_tabs_spaces() {
155        let violations = lint_sql("SELECT *\n\t FROM t", RuleLT02::default());
156        assert_eq!(violations.len(), 1);
157        assert_eq!(violations[0].rule_code, "LT02");
158        // Tab(4) + space(1) = 5 visual width → rounded up to 8 spaces
159        assert_eq!(violations[0].fixes.len(), 1);
160        assert_eq!(violations[0].fixes[0].new_text, "        ");
161    }
162
163    #[test]
164    fn test_lt02_fix_5_spaces_rounds_to_8() {
165        let violations = lint_sql("SELECT *\n     FROM t", RuleLT02::default());
166        assert_eq!(violations.len(), 1);
167        // 5 spaces → rounded up to 8
168        assert_eq!(violations[0].fixes[0].new_text, "        ");
169    }
170}