rigsql_rules/layout/
lt04.rs1use rigsql_core::SegmentType;
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::{LintViolation, SourceEdit};
5
6#[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
99fn is_leading_comma(ctx: &RuleContext) -> bool {
101 if ctx.index_in_parent == 0 {
102 return false;
103 }
104 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
121fn 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
137fn build_leading_to_trailing_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
146 let comma_span = ctx.segment.span();
147
148 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 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 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 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
224fn 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
236fn build_trailing_to_leading_fix(ctx: &RuleContext) -> Vec<SourceEdit> {
238 let comma_span = ctx.segment.span();
239
240 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 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 SourceEdit::delete(comma_span),
267 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 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}