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(
71                        self.code(),
72                        "Comma should be at the end of the line, not the start.",
73                        span,
74                        fixes,
75                    )];
76                }
77            }
78            CommaStyle::Leading => {
79                if is_trailing_comma(ctx) {
80                    let fixes = build_trailing_to_leading_fix(ctx);
81                    return vec![LintViolation::with_fix(
82                        self.code(),
83                        "Comma should be at the start of the line, not the end.",
84                        span,
85                        fixes,
86                    )];
87                }
88            }
89        }
90
91        vec![]
92    }
93}
94
95/// Check if comma is in leading position (newline then optional whitespace then comma).
96fn is_leading_comma(ctx: &RuleContext) -> bool {
97    if ctx.index_in_parent == 0 {
98        return false;
99    }
100    // Walk backwards past whitespace to see if there's a newline
101    let mut i = ctx.index_in_parent - 1;
102    loop {
103        let seg = &ctx.siblings[i];
104        match seg.segment_type() {
105            SegmentType::Whitespace => {
106                if i == 0 {
107                    return false;
108                }
109                i -= 1;
110            }
111            SegmentType::Newline => return true,
112            _ => return false,
113        }
114    }
115}
116
117/// Check if comma is in trailing position (comma then optional whitespace then newline).
118fn is_trailing_comma(ctx: &RuleContext) -> bool {
119    let mut i = ctx.index_in_parent + 1;
120    while i < ctx.siblings.len() {
121        let seg = &ctx.siblings[i];
122        match seg.segment_type() {
123            SegmentType::Whitespace => {
124                i += 1;
125            }
126            SegmentType::Newline => return true,
127            _ => return false,
128        }
129    }
130    false
131}
132
133/// Build fix edits to convert leading comma to trailing comma.
134///
135/// Pattern: `col1\n    , col2` → `col1,\n    col2`
136///
137/// 1. Delete the comma and any whitespace immediately after it
138/// 2. Insert comma after the last non-trivia element before the newline
139fn build_leading_to_trailing_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
140    let comma_span = ctx.segment.span();
141
142    // Find the end of the delete range (comma + whitespace after it)
143    let mut delete_end = comma_span.end;
144    let mut i = ctx.index_in_parent + 1;
145    while i < ctx.siblings.len() {
146        let seg = &ctx.siblings[i];
147        if seg.segment_type() == SegmentType::Whitespace {
148            delete_end = seg.span().end;
149            i += 1;
150        } else {
151            break;
152        }
153    }
154
155    // Also include any whitespace before the comma (between newline and comma)
156    let mut delete_start = comma_span.start;
157    if ctx.index_in_parent > 0 {
158        let mut j = ctx.index_in_parent - 1;
159        loop {
160            let seg = &ctx.siblings[j];
161            if seg.segment_type() == SegmentType::Whitespace {
162                delete_start = seg.span().start;
163                if j == 0 {
164                    break;
165                }
166                j -= 1;
167            } else {
168                break;
169            }
170        }
171    }
172
173    // Find the last non-trivia element before the newline (to insert comma after it)
174    let mut insert_pos = comma_span.start;
175    if ctx.index_in_parent > 0 {
176        let mut j = ctx.index_in_parent - 1;
177        loop {
178            let seg = &ctx.siblings[j];
179            match seg.segment_type() {
180                SegmentType::Whitespace | SegmentType::Newline => {
181                    if j == 0 {
182                        break;
183                    }
184                    j -= 1;
185                }
186                _ => {
187                    insert_pos = seg.span().end;
188                    break;
189                }
190            }
191        }
192    }
193
194    // Reconstruct proper indentation after the comma removal
195    // Keep the newline and original indentation, just without the comma
196    let indent_size = (delete_end - comma_span.end) as usize;
197    let original_indent_size = (comma_span.start - delete_start) as usize;
198    let total_indent = original_indent_size + indent_size;
199    let indent = " ".repeat(total_indent);
200
201    vec![
202        // Insert comma after the previous element
203        SourceEdit::insert(insert_pos, ","),
204        // Replace the whitespace + comma + whitespace with just whitespace
205        SourceEdit::replace(rigsql_core::Span::new(delete_start, delete_end), indent),
206    ]
207}
208
209/// Build fix edits to convert trailing comma to leading comma.
210fn build_trailing_to_leading_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
211    let comma_span = ctx.segment.span();
212
213    // Find the newline after the comma (skip whitespace)
214    let mut newline_end = comma_span.end;
215    let mut i = ctx.index_in_parent + 1;
216    while i < ctx.siblings.len() {
217        let seg = &ctx.siblings[i];
218        match seg.segment_type() {
219            SegmentType::Whitespace => {
220                i += 1;
221            }
222            SegmentType::Newline => {
223                newline_end = seg.span().end;
224                break;
225            }
226            _ => break,
227        }
228    }
229
230    // Find the position of the next element after the newline
231    let insert_pos = if i + 1 < ctx.siblings.len() {
232        ctx.siblings[i + 1].span().start
233    } else {
234        newline_end
235    };
236
237    vec![
238        // Delete the trailing comma
239        SourceEdit::delete(comma_span),
240        // Insert comma before the next line's content
241        SourceEdit::insert(insert_pos, ", "),
242    ]
243}