1use crate::linter::config::LintConfig;
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
9use sqlparser::ast::Statement;
10use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer, Whitespace};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13enum CommaLinePosition {
14 Trailing,
15 Leading,
16}
17
18impl CommaLinePosition {
19 fn from_config(config: &LintConfig) -> Self {
20 if let Some(value) = config.rule_option_str(issue_codes::LINT_LT_004, "line_position") {
21 return match value.to_ascii_lowercase().as_str() {
22 "leading" => Self::Leading,
23 _ => Self::Trailing,
24 };
25 }
26
27 match config
29 .rule_option_str(issue_codes::LINT_LT_004, "comma_style")
30 .unwrap_or("trailing")
31 .to_ascii_lowercase()
32 .as_str()
33 {
34 "leading" => Self::Leading,
35 _ => Self::Trailing,
36 }
37 }
38}
39
40pub struct LayoutCommas {
41 line_position: CommaLinePosition,
42}
43
44impl LayoutCommas {
45 pub fn from_config(config: &LintConfig) -> Self {
46 Self {
47 line_position: CommaLinePosition::from_config(config),
48 }
49 }
50}
51
52impl Default for LayoutCommas {
53 fn default() -> Self {
54 Self {
55 line_position: CommaLinePosition::Trailing,
56 }
57 }
58}
59
60impl LintRule for LayoutCommas {
61 fn code(&self) -> &'static str {
62 issue_codes::LINT_LT_004
63 }
64
65 fn name(&self) -> &'static str {
66 "Layout commas"
67 }
68
69 fn description(&self) -> &'static str {
70 "Leading/Trailing comma enforcement."
71 }
72
73 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
74 let has_remaining_non_whitespace = ctx.sql[ctx.statement_range.end..]
75 .chars()
76 .any(|ch| !ch.is_whitespace());
77 let parser_fragment_fallback = ctx.statement_index == 0
78 && ctx.statement_range.start == 0
79 && ctx.statement_range.end < ctx.sql.len()
80 && has_remaining_non_whitespace;
81
82 if parser_fragment_fallback {
83 let full_ctx = LintContext {
84 sql: ctx.sql,
85 statement_range: 0..ctx.sql.len(),
86 statement_index: ctx.statement_index,
87 };
88 let full_violations = comma_spacing_violations(&full_ctx, self.line_position);
89 if let Some(issue) = issue_from_violations(&full_ctx, &full_violations) {
90 return vec![issue];
91 }
92 }
93
94 let violations = comma_spacing_violations(ctx, self.line_position);
95 issue_from_violations(ctx, &violations)
96 .map(|issue| vec![issue])
97 .unwrap_or_default()
98 }
99}
100
101type Lt04Span = (usize, usize);
102type Lt04AutofixEdit = (usize, usize, String);
103type Lt04Violation = (Lt04Span, Vec<Lt04AutofixEdit>);
104
105fn issue_from_violations(ctx: &LintContext, violations: &[Lt04Violation]) -> Option<Issue> {
106 if violations.is_empty() {
107 return None;
108 }
109
110 let ((start, end), _) = &violations[0];
113 let all_edits: Vec<Lt04AutofixEdit> = violations
114 .iter()
115 .flat_map(|(_, edits)| edits.iter().cloned())
116 .collect();
117
118 let mut issue = Issue::info(
119 issue_codes::LINT_LT_004,
120 "Comma spacing appears inconsistent.",
121 )
122 .with_statement(ctx.statement_index)
123 .with_span(ctx.span_from_statement_offset(*start, *end));
124 if !all_edits.is_empty() {
125 let edits = all_edits
126 .into_iter()
127 .map(|(edit_start, edit_end, replacement)| {
128 IssuePatchEdit::new(
129 ctx.span_from_statement_offset(edit_start, edit_end),
130 replacement,
131 )
132 })
133 .collect();
134 issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
135 }
136 Some(issue)
137}
138
139fn comma_spacing_violations(
140 ctx: &LintContext,
141 line_position: CommaLinePosition,
142) -> Vec<Lt04Violation> {
143 let tokens =
147 tokenized(ctx.statement_sql(), ctx.dialect()).or_else(|| tokenized_for_context(ctx));
148 let Some(tokens) = tokens else {
149 return Vec::new();
150 };
151 let sql = ctx.statement_sql();
152 let mut violations = Vec::new();
153
154 for (index, token) in tokens.iter().enumerate() {
155 if !matches!(token.token, Token::Comma) {
156 continue;
157 }
158
159 let prev_sig_idx = tokens[..index]
160 .iter()
161 .rposition(|candidate| !is_trivia_token(&candidate.token));
162 let Some(prev_sig_idx) = prev_sig_idx else {
163 continue;
164 };
165 let next_sig_idx = tokens
166 .iter()
167 .enumerate()
168 .skip(index + 1)
169 .find(|(_, candidate)| !is_trivia_token(&candidate.token))
170 .map(|(idx, _)| idx);
171 let Some(next_sig_idx) = next_sig_idx else {
172 continue;
173 };
174 let Some((comma_start, comma_end)) = token_with_span_offsets(sql, token) else {
175 continue;
176 };
177
178 let Some((_, prev_end)) = token_with_span_offsets(sql, &tokens[prev_sig_idx]) else {
179 continue;
180 };
181 let Some((next_start, _)) = token_with_span_offsets(sql, &tokens[next_sig_idx]) else {
182 continue;
183 };
184 if prev_end > comma_start || comma_end > next_start || next_start > sql.len() {
185 continue;
186 }
187
188 if is_postgres_cast_precision_scale_comma(&tokens, index, prev_sig_idx, next_sig_idx) {
191 continue;
192 }
193
194 let line_break_before = gap_has_newline(&sql[prev_end..comma_start]);
198 let line_break_after = gap_has_newline(&sql[comma_end..next_start]);
199
200 let line_position_violation = match line_position {
201 CommaLinePosition::Trailing => line_break_before && !line_break_after,
202 CommaLinePosition::Leading => line_break_after && !line_break_before,
203 };
204 if line_position_violation {
205 let edits = safe_comma_line_move_edits(
206 sql,
207 &tokens,
208 index,
209 prev_sig_idx,
210 next_sig_idx,
211 line_position,
212 )
213 .or_else(|| fallback_comma_line_move_edits(sql, comma_start, comma_end, line_position))
214 .unwrap_or_default();
215 violations.push(((comma_start, comma_end), edits));
216 continue;
217 }
218
219 let mut edits = Vec::new();
220 let mut violation = false;
221
222 let has_pre_inline_space = !line_break_before
224 && tokens[prev_sig_idx + 1..index]
225 .iter()
226 .any(|candidate| is_inline_space_token(&candidate.token));
227 if has_pre_inline_space {
228 violation = true;
229 if let Some((gap_start, gap_end)) =
230 safe_inline_gap_between(sql, &tokens[prev_sig_idx], token)
231 {
232 if gap_start < gap_end {
233 edits.push((gap_start, gap_end, String::new()));
234 }
235 }
236 }
237
238 let comma_in_preferred_position = match line_position {
243 CommaLinePosition::Leading => line_break_before,
244 CommaLinePosition::Trailing => line_break_after,
245 };
246 let missing_post_inline_space = !comma_in_preferred_position
247 && !line_break_after
248 && !tokens[index + 1..next_sig_idx]
249 .iter()
250 .any(|candidate| is_inline_space_token(&candidate.token));
251 if missing_post_inline_space {
252 violation = true;
253 if let Some((gap_start, gap_end)) =
254 safe_inline_gap_between(sql, token, &tokens[next_sig_idx])
255 {
256 edits.push((gap_start, gap_end, " ".to_string()));
257 }
258 }
259
260 if violation {
261 violations.push(((comma_start, comma_end), edits));
262 }
263 }
264
265 violations
266}
267
268fn is_postgres_cast_precision_scale_comma(
269 tokens: &[TokenWithSpan],
270 comma_idx: usize,
271 prev_sig_idx: usize,
272 next_sig_idx: usize,
273) -> bool {
274 if !matches!(tokens[comma_idx].token, Token::Comma) {
275 return false;
276 }
277 if !matches!(tokens[prev_sig_idx].token, Token::Number(_, _)) {
278 return false;
279 }
280 if !matches!(tokens[next_sig_idx].token, Token::Number(_, _)) {
281 return false;
282 }
283
284 let Some(lparen_idx) = tokens[..prev_sig_idx]
285 .iter()
286 .rposition(|candidate| !is_trivia_token(&candidate.token))
287 else {
288 return false;
289 };
290 if !matches!(tokens[lparen_idx].token, Token::LParen) {
291 return false;
292 }
293
294 let Some(type_name_idx) = tokens[..lparen_idx]
295 .iter()
296 .rposition(|candidate| !is_trivia_token(&candidate.token))
297 else {
298 return false;
299 };
300 if !matches!(tokens[type_name_idx].token, Token::Word(_)) {
301 return false;
302 }
303
304 tokens[..type_name_idx]
305 .iter()
306 .rposition(|candidate| !is_trivia_token(&candidate.token))
307 .is_some_and(|cast_marker_idx| matches!(tokens[cast_marker_idx].token, Token::DoubleColon))
308}
309
310fn safe_comma_line_move_edits(
318 sql: &str,
319 tokens: &[TokenWithSpan],
320 comma_idx: usize,
321 prev_sig_idx: usize,
322 next_sig_idx: usize,
323 line_position: CommaLinePosition,
324) -> Option<Vec<Lt04AutofixEdit>> {
325 let (_, prev_end) = token_with_span_offsets(sql, &tokens[prev_sig_idx])?;
326 let (comma_start, comma_end) = token_with_span_offsets(sql, &tokens[comma_idx])?;
327 let (next_start, _) = token_with_span_offsets(sql, &tokens[next_sig_idx])?;
328
329 if prev_end > comma_start || comma_end > next_start || next_start > sql.len() {
330 return None;
331 }
332
333 if matches!(line_position, CommaLinePosition::Trailing) {
337 let context_start = line_start_after_newline(sql, prev_end);
338 if gap_has_template_marker(&sql[context_start..comma_start]) {
339 return None;
340 }
341 }
342
343 let before_gap = &sql[prev_end..comma_start];
344 let after_gap = &sql[comma_end..next_start];
345 let has_comments = gap_has_comment(before_gap) || gap_has_comment(after_gap);
346
347 if !has_comments {
348 if !before_gap.chars().all(char::is_whitespace)
350 || !after_gap.chars().all(char::is_whitespace)
351 {
352 return None;
353 }
354 let indent = line_indent_at(sql, next_start);
355 return match line_position {
356 CommaLinePosition::Trailing => {
357 Some(vec![(prev_end, next_start, format!(",\n{indent}"))])
358 }
359 CommaLinePosition::Leading => {
360 Some(vec![(prev_end, next_start, format!("\n{indent}, "))])
361 }
362 };
363 }
364
365 let indent = line_indent_at(sql, next_start);
371
372 match line_position {
373 CommaLinePosition::Trailing => {
374 let mut edits = Vec::new();
378
379 if prev_end > 0 && gap_has_comment(&sql[prev_end..comma_start]) {
385 let anchor = prev_end - 1;
386 let ch = &sql[anchor..prev_end];
387 edits.push((anchor, prev_end, format!("{ch},")));
388 } else {
389 edits.push((prev_end, prev_end, ",".to_string()));
390 }
391
392 let delete_start = line_start_after_newline(sql, comma_start);
399 let delete_end = skip_inline_whitespace(sql, comma_end);
400 edits.push((delete_start, delete_end, indent.to_string()));
401
402 Some(edits)
403 }
404 CommaLinePosition::Leading => {
405 let mut edits = Vec::new();
409
410 let delete_start = whitespace_before_on_same_line(sql, comma_start, prev_end);
413 edits.push((delete_start, comma_end, String::new()));
414
415 let insert_pos = line_start_after_newline(sql, next_start);
417 edits.push((insert_pos, next_start, format!("{indent}, ")));
418
419 Some(edits)
420 }
421 }
422}
423
424fn fallback_comma_line_move_edits(
425 sql: &str,
426 comma_start: usize,
427 comma_end: usize,
428 line_position: CommaLinePosition,
429) -> Option<Vec<Lt04AutofixEdit>> {
430 if comma_start >= comma_end || comma_end > sql.len() {
431 return None;
432 }
433
434 match line_position {
435 CommaLinePosition::Leading => {
436 let newline_idx = sql[comma_end..].find('\n').map(|idx| comma_end + idx)?;
437 let line_start = newline_idx + 1;
438 let mut insert_pos = line_start;
439 let bytes = sql.as_bytes();
440 while insert_pos < bytes.len()
441 && (bytes[insert_pos] == b' ' || bytes[insert_pos] == b'\t')
442 {
443 insert_pos += 1;
444 }
445
446 let delete_end = skip_inline_whitespace(sql, comma_end);
447 let indent = &sql[line_start..insert_pos];
448 Some(vec![
449 (comma_start, delete_end, String::new()),
450 (line_start, insert_pos, format!("{indent}, ")),
451 ])
452 }
453 CommaLinePosition::Trailing => {
454 let line_start = line_start_after_newline(sql, comma_start);
455 if line_start == 0 {
456 return None;
457 }
458
459 let mut insert_pos = line_start - 1; let bytes = sql.as_bytes();
461 while insert_pos > 0
462 && (bytes[insert_pos - 1] == b' ' || bytes[insert_pos - 1] == b'\t')
463 {
464 insert_pos -= 1;
465 }
466
467 let delete_end = skip_inline_whitespace(sql, comma_end);
468 Some(vec![
469 (insert_pos, insert_pos, ",".to_string()),
470 (comma_start, delete_end, String::new()),
471 ])
472 }
473 }
474}
475
476fn gap_has_comment(gap: &str) -> bool {
478 gap.contains("--") || gap.contains("/*")
479}
480
481fn gap_has_template_marker(gap: &str) -> bool {
482 gap.contains("{{")
483 || gap.contains("{%")
484 || gap.contains("{#")
485 || gap.contains("}}")
486 || gap.contains("%}")
487 || gap.contains("#}")
488}
489
490fn gap_has_newline(gap: &str) -> bool {
491 gap.contains('\n') || gap.contains('\r')
492}
493
494fn line_start_after_newline(sql: &str, offset: usize) -> usize {
497 sql[..offset].rfind('\n').map(|pos| pos + 1).unwrap_or(0)
498}
499
500fn skip_inline_whitespace(sql: &str, offset: usize) -> usize {
502 let mut pos = offset;
503 let bytes = sql.as_bytes();
504 while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
505 pos += 1;
506 }
507 pos
508}
509
510fn whitespace_before_on_same_line(sql: &str, offset: usize, floor: usize) -> usize {
513 let mut pos = offset;
514 let bytes = sql.as_bytes();
515 while pos > floor && (bytes[pos - 1] == b' ' || bytes[pos - 1] == b'\t') {
516 pos -= 1;
517 }
518 pos
519}
520
521fn line_indent_at(sql: &str, offset: usize) -> &str {
523 let line_start = sql[..offset].rfind('\n').map(|pos| pos + 1).unwrap_or(0);
524 let indent_end = sql[line_start..]
525 .find(|ch: char| !ch.is_whitespace() || ch == '\n')
526 .map(|pos| line_start + pos)
527 .unwrap_or(offset);
528 &sql[line_start..indent_end]
529}
530
531fn safe_inline_gap_between(
532 sql: &str,
533 left: &TokenWithSpan,
534 right: &TokenWithSpan,
535) -> Option<(usize, usize)> {
536 let (_, start) = token_with_span_offsets(sql, left)?;
537 let (end, _) = token_with_span_offsets(sql, right)?;
538 if start > end || end > sql.len() {
539 return None;
540 }
541
542 let gap = &sql[start..end];
543 if gap.chars().all(char::is_whitespace) && !gap.contains('\n') && !gap.contains('\r') {
544 Some((start, end))
545 } else {
546 None
547 }
548}
549
550fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
551 let dialect = dialect.to_sqlparser_dialect();
552 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
553 tokenizer.tokenize_with_location().ok()
554}
555
556fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<TokenWithSpan>> {
557 let (statement_start_line, statement_start_column) =
558 offset_to_line_col(ctx.sql, ctx.statement_range.start)?;
559
560 ctx.with_document_tokens(|tokens| {
561 if tokens.is_empty() {
562 return None;
563 }
564
565 let mut out = Vec::new();
566 for token in tokens {
567 let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
568 continue;
569 };
570 if start < ctx.statement_range.start || end > ctx.statement_range.end {
571 continue;
572 }
573
574 let Some(start_loc) = relative_location(
575 token.span.start,
576 statement_start_line,
577 statement_start_column,
578 ) else {
579 continue;
580 };
581 let Some(end_loc) =
582 relative_location(token.span.end, statement_start_line, statement_start_column)
583 else {
584 continue;
585 };
586
587 out.push(TokenWithSpan::new(
588 token.token.clone(),
589 Span::new(start_loc, end_loc),
590 ));
591 }
592
593 if out.is_empty() {
594 None
595 } else {
596 Some(out)
597 }
598 })
599}
600
601fn is_trivia_token(token: &Token) -> bool {
602 matches!(
603 token,
604 Token::Whitespace(Whitespace::Space | Whitespace::Newline | Whitespace::Tab)
605 | Token::Whitespace(Whitespace::SingleLineComment { .. })
606 | Token::Whitespace(Whitespace::MultiLineComment(_))
607 )
608}
609
610fn is_inline_space_token(token: &Token) -> bool {
611 matches!(
612 token,
613 Token::Whitespace(Whitespace::Space | Whitespace::Tab)
614 )
615}
616
617fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
618 if line == 0 || column == 0 {
619 return None;
620 }
621
622 let mut current_line = 1usize;
623 let mut current_col = 1usize;
624
625 for (offset, ch) in sql.char_indices() {
626 if current_line == line && current_col == column {
627 return Some(offset);
628 }
629
630 if ch == '\n' {
631 current_line += 1;
632 current_col = 1;
633 } else {
634 current_col += 1;
635 }
636 }
637
638 if current_line == line && current_col == column {
639 return Some(sql.len());
640 }
641
642 None
643}
644
645fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
646 let start = line_col_to_offset(
647 sql,
648 token.span.start.line as usize,
649 token.span.start.column as usize,
650 )?;
651 let end = line_col_to_offset(
652 sql,
653 token.span.end.line as usize,
654 token.span.end.column as usize,
655 )?;
656 Some((start, end))
657}
658
659fn offset_to_line_col(sql: &str, offset: usize) -> Option<(usize, usize)> {
660 if offset > sql.len() {
661 return None;
662 }
663 if offset == sql.len() {
664 let mut line = 1usize;
665 let mut column = 1usize;
666 for ch in sql.chars() {
667 if ch == '\n' {
668 line += 1;
669 column = 1;
670 } else {
671 column += 1;
672 }
673 }
674 return Some((line, column));
675 }
676
677 let mut line = 1usize;
678 let mut column = 1usize;
679 for (index, ch) in sql.char_indices() {
680 if index == offset {
681 return Some((line, column));
682 }
683 if ch == '\n' {
684 line += 1;
685 column = 1;
686 } else {
687 column += 1;
688 }
689 }
690
691 None
692}
693
694fn relative_location(
695 location: Location,
696 statement_start_line: usize,
697 statement_start_column: usize,
698) -> Option<Location> {
699 let line = location.line as usize;
700 let column = location.column as usize;
701 if line < statement_start_line {
702 return None;
703 }
704
705 if line == statement_start_line {
706 if column < statement_start_column {
707 return None;
708 }
709 return Some(Location::new(
710 1,
711 (column - statement_start_column + 1) as u64,
712 ));
713 }
714
715 Some(Location::new(
716 (line - statement_start_line + 1) as u64,
717 column as u64,
718 ))
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::linter::config::LintConfig;
725 use crate::parser::parse_sql;
726 use crate::types::IssueAutofixApplicability;
727
728 fn run_with_rule(sql: &str, rule: &LayoutCommas) -> Vec<Issue> {
729 let statements = parse_sql(sql).expect("parse");
730 statements
731 .iter()
732 .enumerate()
733 .flat_map(|(index, statement)| {
734 rule.check(
735 statement,
736 &LintContext {
737 sql,
738 statement_range: 0..sql.len(),
739 statement_index: index,
740 },
741 )
742 })
743 .collect()
744 }
745
746 fn run(sql: &str) -> Vec<Issue> {
747 run_with_rule(sql, &LayoutCommas::default())
748 }
749
750 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
751 let autofix = issue.autofix.as_ref()?;
752 let mut out = sql.to_string();
753 let mut edits = autofix.edits.clone();
754 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
755 for edit in edits.into_iter().rev() {
756 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
757 }
758 Some(out)
759 }
760
761 fn apply_raw_edits(sql: &str, edits: &[(usize, usize, String)]) -> String {
762 let mut out = sql.to_string();
763 let mut ordered = edits.to_vec();
764 ordered.sort_by_key(|(start, end, _)| (*start, *end));
765 for (start, end, replacement) in ordered.into_iter().rev() {
766 out.replace_range(start..end, &replacement);
767 }
768 out
769 }
770
771 #[test]
772 fn flags_tight_comma_spacing() {
773 let sql = "SELECT a,b FROM t";
774 let issues = run(sql);
775 assert_eq!(issues.len(), 1);
776 assert_eq!(issues[0].code, issue_codes::LINT_LT_004);
777 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
778 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
779 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
780 assert_eq!(fixed, "SELECT a, b FROM t");
781 }
782
783 #[test]
784 fn does_not_flag_spaced_commas() {
785 assert!(run("SELECT a, b FROM t").is_empty());
786 }
787
788 #[test]
789 fn does_not_flag_comma_in_cast_precision_scale() {
790 assert!(run("SELECT (a / b)::numeric(5,2) FROM t").is_empty());
791 }
792
793 #[test]
794 fn does_not_flag_comma_inside_string_literal() {
795 assert!(run("SELECT 'a,b' AS txt, b FROM t").is_empty());
796 }
797
798 #[test]
799 fn comma_with_inline_comment_gap_is_report_only() {
800 let sql = "SELECT a,/* comment */b FROM t";
801 let issues = run(sql);
802 assert_eq!(issues.len(), 1);
803 assert!(
804 issues[0].autofix.is_none(),
805 "comment-bearing comma spacing remains report-only in conservative LT004 migration"
806 );
807 }
808
809 #[test]
810 fn leading_line_position_flags_trailing_line_comma() {
811 let config = LintConfig {
812 enabled: true,
813 disabled_rules: vec![],
814 rule_configs: std::collections::BTreeMap::from([(
815 "layout.commas".to_string(),
816 serde_json::json!({"line_position": "leading"}),
817 )]),
818 };
819 let issues = run_with_rule("SELECT a,\n b FROM t", &LayoutCommas::from_config(&config));
820 assert_eq!(issues.len(), 1);
821 assert_eq!(issues[0].code, issue_codes::LINT_LT_004);
822 }
823
824 #[test]
825 fn trailing_mode_moves_leading_comma_to_trailing() {
826 let sql = "SELECT\n a\n , b\nFROM c";
827 let issues = run(sql);
828 assert_eq!(issues.len(), 1);
829 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
830 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
831 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
832 assert_eq!(fixed, "SELECT\n a,\n b\nFROM c");
833 }
834
835 #[test]
836 fn leading_mode_moves_trailing_comma_to_leading() {
837 let config = LintConfig {
838 enabled: true,
839 disabled_rules: vec![],
840 rule_configs: std::collections::BTreeMap::from([(
841 "layout.commas".to_string(),
842 serde_json::json!({"line_position": "leading"}),
843 )]),
844 };
845 let sql = "SELECT\n a,\n b\nFROM c";
846 let issues = run_with_rule(sql, &LayoutCommas::from_config(&config));
847 assert_eq!(issues.len(), 1);
848 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
849 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
850 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
851 assert_eq!(fixed, "SELECT\n a\n , b\nFROM c");
852 }
853
854 #[test]
855 fn legacy_comma_style_leading_is_respected() {
856 let config = LintConfig {
857 enabled: true,
858 disabled_rules: vec![],
859 rule_configs: std::collections::BTreeMap::from([(
860 "LINT_LT_004".to_string(),
861 serde_json::json!({"comma_style": "leading"}),
862 )]),
863 };
864 let issues = run_with_rule("SELECT a\n, b FROM t", &LayoutCommas::from_config(&config));
865 assert!(issues.is_empty());
866 }
867
868 #[test]
869 fn trailing_mode_moves_leading_commas_with_inline_comment() {
870 let sql = "SELECT\n a\n , b -- inline comment\n , c\n /* non inline comment */\n , d\nFROM e";
871 let issues = run(sql);
872 assert_eq!(issues.len(), 1);
873 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
874 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
875 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
876 assert_eq!(
877 fixed,
878 "SELECT\n a,\n b, -- inline comment\n c,\n /* non inline comment */\n d\nFROM e"
879 );
880 }
881
882 #[test]
883 fn trailing_mode_moves_leading_comma_with_comment_before() {
884 let sql = "SELECT a--comment\n , b\nFROM t";
885 let issues = run(sql);
886 assert_eq!(issues.len(), 1);
887 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
888 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
889 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
890 assert_eq!(fixed, "SELECT a,--comment\n b\nFROM t");
891 }
892
893 #[test]
894 fn trailing_mode_multiple_leading_commas_fixed() {
895 let sql = "SELECT\n field_1\n , field_2\n ,field_3\nFROM a";
896 let issues = run(sql);
897 assert_eq!(issues.len(), 1);
898 assert!(issues[0].autofix.is_some(), "autofix metadata");
899 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
900 assert_eq!(
901 fixed,
902 "SELECT\n field_1,\n field_2,\n field_3\nFROM a"
903 );
904 }
905
906 #[test]
907 fn leading_mode_templated_column_emits_line_move_edits() {
908 let sql = "SELECT\n c1,\n {{ \"c2\" }} AS days_since\nFROM logs";
909 let ctx = LintContext {
910 sql,
911 statement_range: 0..sql.len(),
912 statement_index: 0,
913 };
914 let violations = comma_spacing_violations(&ctx, CommaLinePosition::Leading);
915 assert_eq!(violations.len(), 1);
916 assert!(
917 !violations[0].1.is_empty(),
918 "expected templated leading-mode violation to produce edits"
919 );
920 let fixed = apply_raw_edits(sql, &violations[0].1);
921 assert_eq!(
922 fixed,
923 "SELECT\n c1\n , {{ \"c2\" }} AS days_since\nFROM logs"
924 );
925 }
926
927 #[test]
928 fn trailing_mode_templated_column_emits_line_move_edits() {
929 let sql = "SELECT\n {{ \"c1\" }}\n , c2 AS days_since\nFROM logs";
930 let ctx = LintContext {
931 sql,
932 statement_range: 0..sql.len(),
933 statement_index: 0,
934 };
935 let violations = comma_spacing_violations(&ctx, CommaLinePosition::Trailing);
936 assert_eq!(violations.len(), 1);
937 assert!(
938 !violations[0].1.is_empty(),
939 "expected templated trailing-mode violation to produce edits"
940 );
941 let fixed = apply_raw_edits(sql, &violations[0].1);
942 assert_eq!(
943 fixed,
944 "SELECT\n {{ \"c1\" }},\n c2 AS days_since\nFROM logs"
945 );
946 }
947}