Skip to main content

rigsql_rules/layout/
lt04.rs

1use rigsql_core::SegmentType;
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6/// LT04: Leading/trailing commas.
7///
8/// By default, expects trailing commas (comma at end of line).
9#[derive(Debug)]
10pub struct RuleLT04 {
11    pub style: CommaStyle,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CommaStyle {
16    Trailing,
17    Leading,
18}
19
20impl Default for RuleLT04 {
21    fn default() -> Self {
22        Self {
23            style: CommaStyle::Trailing,
24        }
25    }
26}
27
28impl Rule for RuleLT04 {
29    fn code(&self) -> &'static str {
30        "LT04"
31    }
32    fn name(&self) -> &'static str {
33        "layout.commas"
34    }
35    fn description(&self) -> &'static str {
36        "Commas should be at the end of the line, not the start."
37    }
38    fn explanation(&self) -> &'static str {
39        "Commas in SELECT lists, GROUP BY, and other clauses should consistently appear \
40         at the end of the line (trailing) or the start of the next line (leading). \
41         Mixing styles reduces readability."
42    }
43    fn groups(&self) -> &[RuleGroup] {
44        &[RuleGroup::Layout]
45    }
46    fn is_fixable(&self) -> bool {
47        true
48    }
49
50    fn configure(&mut self, settings: &std::collections::HashMap<String, String>) {
51        if let Some(val) = settings.get("comma_style") {
52            self.style = match val.as_str() {
53                "leading" => CommaStyle::Leading,
54                _ => CommaStyle::Trailing,
55            };
56        }
57    }
58
59    fn crawl_type(&self) -> CrawlType {
60        CrawlType::Segment(vec![SegmentType::Comma])
61    }
62
63    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
64        let span = ctx.segment.span();
65
66        match self.style {
67            CommaStyle::Trailing => {
68                if is_leading_comma(ctx) {
69                    let fixes = build_leading_to_trailing_fix(ctx);
70                    return vec![LintViolation::with_fix_and_msg_key(
71                        self.code(),
72                        "Comma should be at the end of the line, not the start.",
73                        span,
74                        fixes,
75                        "rules.LT04.msg.trailing",
76                        vec![],
77                    )];
78                }
79            }
80            CommaStyle::Leading => {
81                if is_trailing_comma(ctx) {
82                    let fixes = build_trailing_to_leading_fix(ctx);
83                    return vec![LintViolation::with_fix_and_msg_key(
84                        self.code(),
85                        "Comma should be at the start of the line, not the end.",
86                        span,
87                        fixes,
88                        "rules.LT04.msg.leading",
89                        vec![],
90                    )];
91                }
92            }
93        }
94
95        vec![]
96    }
97}
98
99/// Check if comma is in leading position (newline then optional whitespace then comma).
100fn is_leading_comma(ctx: &RuleContext) -> bool {
101    if ctx.index_in_parent == 0 {
102        return false;
103    }
104    // Walk backwards past whitespace to see if there's a newline
105    let mut i = ctx.index_in_parent - 1;
106    loop {
107        let seg = &ctx.siblings[i];
108        match seg.segment_type() {
109            SegmentType::Whitespace => {
110                if i == 0 {
111                    return false;
112                }
113                i -= 1;
114            }
115            SegmentType::Newline => return true,
116            _ => return false,
117        }
118    }
119}
120
121/// Check if comma is in trailing position (comma then optional whitespace then newline).
122fn is_trailing_comma(ctx: &RuleContext) -> bool {
123    let mut i = ctx.index_in_parent + 1;
124    while i < ctx.siblings.len() {
125        let seg = &ctx.siblings[i];
126        match seg.segment_type() {
127            SegmentType::Whitespace => {
128                i += 1;
129            }
130            SegmentType::Newline => return true,
131            _ => return false,
132        }
133    }
134    false
135}
136
137/// Build fix edits to convert leading comma to trailing comma.
138///
139/// Pattern: `col1\n    , col2` → `col1,\n    col2`
140///
141/// Emits a single edit that replaces the region from the last non-trivia element
142/// to the end of the comma (+ trailing whitespace) with a comma followed by the
143/// preserved content (newlines, comments) and indentation. Using a single edit
144/// avoids conflicts with LT01 trailing-whitespace fixes that target the same region.
145fn build_leading_to_trailing_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
146    let comma_span = ctx.segment.span();
147
148    // Find the end of the delete range (comma + whitespace after it)
149    let mut delete_end = comma_span.end;
150    let mut i = ctx.index_in_parent + 1;
151    while i < ctx.siblings.len() {
152        let seg = &ctx.siblings[i];
153        if seg.segment_type() == SegmentType::Whitespace {
154            delete_end = seg.span().end;
155            i += 1;
156        } else {
157            break;
158        }
159    }
160
161    // Also include any whitespace before the comma (between newline and comma)
162    let mut delete_start = comma_span.start;
163    if ctx.index_in_parent > 0 {
164        let mut j = ctx.index_in_parent - 1;
165        loop {
166            let seg = &ctx.siblings[j];
167            if seg.segment_type() == SegmentType::Whitespace {
168                delete_start = seg.span().start;
169                if j == 0 {
170                    break;
171                }
172                j -= 1;
173            } else {
174                break;
175            }
176        }
177    }
178
179    // Find the last non-trivia element before the newline (to insert comma after it).
180    // Must skip LineComment/BlockComment too — inserting a comma after a line comment
181    // would place it inside the comment, breaking the SQL.
182    let mut insert_pos = comma_span.start;
183    if ctx.index_in_parent > 0 {
184        let mut j = ctx.index_in_parent - 1;
185        loop {
186            let seg = &ctx.siblings[j];
187            match seg.segment_type() {
188                SegmentType::Whitespace
189                | SegmentType::Newline
190                | SegmentType::LineComment
191                | SegmentType::BlockComment => {
192                    if j == 0 {
193                        break;
194                    }
195                    j -= 1;
196                }
197                _ => {
198                    insert_pos = seg.span().end;
199                    break;
200                }
201            }
202        }
203    }
204
205    // Build a single combined edit covering [insert_pos, delete_end).
206    // Replacement = "," + content between insert_pos and delete_start + indent.
207    // The content between insert_pos and delete_start contains newlines, comments,
208    // and potentially trailing whitespace. We strip trailing horizontal whitespace
209    // before each newline to avoid creating new LT01 violations.
210    let between = &ctx.source[insert_pos as usize..delete_start as usize];
211    let between_clean = strip_trailing_hws_before_newlines(between);
212
213    let indent_size = (delete_end - comma_span.end) as usize;
214    let original_indent_size = (comma_span.start - delete_start) as usize;
215    let total_indent = original_indent_size + indent_size;
216    let indent = " ".repeat(total_indent);
217
218    vec![SourceEdit::replace(
219        rigsql_core::Span::new(insert_pos, delete_end),
220        format!(",{}{}", between_clean, indent),
221    )]
222}
223
224/// Strip trailing horizontal whitespace (spaces/tabs) before each newline in a string.
225fn strip_trailing_hws_before_newlines(s: &str) -> String {
226    let mut result = String::with_capacity(s.len());
227    for (i, line) in s.split('\n').enumerate() {
228        if i > 0 {
229            result.push('\n');
230        }
231        result.push_str(line.trim_end_matches([' ', '\t']));
232    }
233    result
234}
235
236/// Build fix edits to convert trailing comma to leading comma.
237fn build_trailing_to_leading_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
238    let comma_span = ctx.segment.span();
239
240    // Find the newline after the comma (skip whitespace)
241    let mut newline_end = comma_span.end;
242    let mut i = ctx.index_in_parent + 1;
243    while i < ctx.siblings.len() {
244        let seg = &ctx.siblings[i];
245        match seg.segment_type() {
246            SegmentType::Whitespace => {
247                i += 1;
248            }
249            SegmentType::Newline => {
250                newline_end = seg.span().end;
251                break;
252            }
253            _ => break,
254        }
255    }
256
257    // Find the position of the next element after the newline
258    let insert_pos = if i + 1 < ctx.siblings.len() {
259        ctx.siblings[i + 1].span().start
260    } else {
261        newline_end
262    };
263
264    vec![
265        // Delete the trailing comma
266        SourceEdit::delete(comma_span),
267        // Insert comma before the next line's content
268        SourceEdit::insert(insert_pos, ", "),
269    ]
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::test_utils::lint_sql;
276
277    #[test]
278    fn test_lt04_accepts_trailing_comma() {
279        let violations = lint_sql("SELECT a, b FROM t", RuleLT04::default());
280        assert_eq!(violations.len(), 0);
281    }
282
283    #[test]
284    fn test_lt04_flags_leading_comma() {
285        let violations = lint_sql("SELECT a\n    ,b FROM t", RuleLT04::default());
286        assert!(!violations.is_empty());
287        assert!(violations.iter().all(|v| v.rule_code == "LT04"));
288    }
289
290    #[test]
291    fn test_lt04_fix_leading_comma_after_end_with_trailing_whitespace() {
292        // Regression: comma on its own line after `end` with trailing whitespace
293        // was being deleted instead of moved to trailing position, because the
294        // LT04 insert edit conflicted with LT01 trailing-whitespace edit.
295        use crate::rule::apply_fixes;
296
297        let sql = "SELECT\n  end   \n,\n    NextColumn\nFROM t";
298        let violations = lint_sql(sql, RuleLT04::default());
299        assert!(!violations.is_empty(), "should flag leading comma");
300
301        let fixed = apply_fixes(sql, &violations);
302        assert!(
303            fixed.contains("end,"),
304            "comma should be moved to trailing position after 'end': {fixed}"
305        );
306        assert!(
307            !fixed.contains("\n,"),
308            "standalone leading comma should be removed: {fixed}"
309        );
310    }
311
312    #[test]
313    fn test_lt04_fix_standalone_comma_line() {
314        use crate::rule::apply_fixes;
315
316        let sql = "SELECT\n    col1\n,\n    col2\nFROM t";
317        let violations = lint_sql(sql, RuleLT04::default());
318        let fixed = apply_fixes(sql, &violations);
319        assert!(fixed.contains("col1,"), "comma should trail col1: {fixed}");
320        assert!(
321            !fixed.contains("\n,"),
322            "standalone comma line should be gone: {fixed}"
323        );
324    }
325}