Skip to main content

flowscope_core/linter/rules/
lt_004.rs

1//! LINT_LT_004: Layout commas.
2//!
3//! SQLFluff LT04 parity (current scope): detect compact or leading-space comma
4//! patterns.
5
6use 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        // SQLFluff legacy compatibility (`trailing`/`leading`).
28        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    // Merge all violation edits into a single issue anchored at the first
111    // comma, so the fix engine can apply them in one pass.
112    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    // Prefer direct tokenization of the statement slice. Document-token spans
144    // can come from parser-recovery fragments and occasionally lose reliable
145    // line-shape information around template markers.
146    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        // PostgreSQL cast precision/scale commas (e.g. `::numeric(5,2)`) are
189        // part of a data-type declaration, not list separators.
190        if is_postgres_cast_precision_scale_comma(&tokens, index, prev_sig_idx, next_sig_idx) {
191            continue;
192        }
193
194        // Use byte-gaps rather than token line metadata. In parser-recovery and
195        // template-heavy inputs, token line numbers can drift while offsets
196        // remain reliable.
197        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        // Inline comma cases should have no pre-comma spacing.
223        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        // Inline comma cases should have spacing after comma.
239        // Skip when the comma is already in the preferred line position (e.g.
240        // leading-mode comma at line start: `\n    ,b` is correctly positioned;
241        // adding a space would contradict line-position intent).
242        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
310/// Generate autofix edits to move a comma across a line break.
311///
312/// For `Trailing` mode (comma should be trailing): input is `a\n  , b` → `a,\n  b`.
313/// For `Leading` mode (comma should be leading): input is `a,\n  b` → `a\n  , b`.
314///
315/// Edits are split so they do not span comment tokens, which the fix engine
316/// treats as protected ranges.
317fn 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    // Template delimiters are tokenized as punctuation, so "previous
334    // significant token" can land inside a tag. Fall back to raw line-based
335    // movement for leading-comma→trailing-comma rewrites near templates.
336    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        // Simple case: no comments in either gap.
349        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    // Comment-aware comma move: produce surgical edits that avoid comment spans.
366    //
367    // Strategy: delete the comma + whitespace at its old position, then insert
368    // the comma + adjusted whitespace at the new position. Comments stay
369    // untouched.
370    let indent = line_indent_at(sql, next_start);
371
372    match line_position {
373        CommaLinePosition::Trailing => {
374            // Currently leading: comma is on the next line.
375            // Example: `a\n    , b -- comment` → `a,\n    b -- comment`
376            // Example: `a--comment\n    , b` → `a,--comment\n    b`
377            let mut edits = Vec::new();
378
379            // 1) Insert comma right after previous significant token.
380            //    If a comment starts exactly at prev_end (no gap), extend the
381            //    edit one byte into the preceding token so it becomes a
382            //    replacement rather than a zero-width insert touching the
383            //    comment's protected range.
384            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            // 2) Delete the comma and surrounding whitespace on its line,
393            //    taking care not to touch any comment.
394            //    The region to clean is the whitespace-only portion at the
395            //    start of the comma's line up to (and including) the comma,
396            //    plus any trailing whitespace after the comma on the same line
397            //    (but not if there's a comment after it on that line).
398            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            // Currently trailing: comma is at the end of the previous line.
406            // Example: `a,\n    b` → `a\n    , b`
407            // Example: `a.baz,\n    -- comment\n     a.bar` → `a.baz\n    -- comment\n     , a.bar`
408            let mut edits = Vec::new();
409
410            // 1) Delete the comma (and any whitespace between prev token end
411            //    and comma if on the same line).
412            let delete_start = whitespace_before_on_same_line(sql, comma_start, prev_end);
413            edits.push((delete_start, comma_end, String::new()));
414
415            // 2) Insert `, ` before the next significant token.
416            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; // newline between previous/current lines
460            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
476/// Check if a gap string contains a comment.
477fn 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
494/// Return the position right after the last newline before `offset`, i.e. the
495/// start of the line containing `offset`.
496fn line_start_after_newline(sql: &str, offset: usize) -> usize {
497    sql[..offset].rfind('\n').map(|pos| pos + 1).unwrap_or(0)
498}
499
500/// Skip inline whitespace (spaces and tabs) starting at `offset`.
501fn 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
510/// Walk backwards from `offset` over spaces/tabs on the same line, stopping at
511/// `floor` or a newline.
512fn 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
521/// Extract the leading whitespace (indent) for the line containing `offset`.
522fn 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}