Skip to main content

flowscope_core/linter/rules/
lt_003.rs

1//! LINT_LT_003: Layout operators.
2//!
3//! SQLFluff LT03 parity (current scope): flag trailing operators at end of line.
4
5use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
8use sqlparser::ast::Statement;
9use sqlparser::keywords::Keyword;
10use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer, Whitespace};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13enum OperatorLinePosition {
14    Leading,
15    Trailing,
16}
17
18impl OperatorLinePosition {
19    fn from_config(config: &LintConfig) -> Self {
20        if let Some(value) = config.rule_option_str(issue_codes::LINT_LT_003, "line_position") {
21            return match value.to_ascii_lowercase().as_str() {
22                "trailing" => Self::Trailing,
23                _ => Self::Leading,
24            };
25        }
26
27        // SQLFluff legacy compatibility (`before`/`after`).
28        match config
29            .rule_option_str(issue_codes::LINT_LT_003, "operator_new_lines")
30            .unwrap_or("after")
31            .to_ascii_lowercase()
32            .as_str()
33        {
34            "before" => Self::Trailing,
35            _ => Self::Leading,
36        }
37    }
38}
39
40pub struct LayoutOperators {
41    line_position: OperatorLinePosition,
42}
43
44impl LayoutOperators {
45    pub fn from_config(config: &LintConfig) -> Self {
46        Self {
47            line_position: OperatorLinePosition::from_config(config),
48        }
49    }
50}
51
52impl Default for LayoutOperators {
53    fn default() -> Self {
54        Self {
55            line_position: OperatorLinePosition::Leading,
56        }
57    }
58}
59
60impl LintRule for LayoutOperators {
61    fn code(&self) -> &'static str {
62        issue_codes::LINT_LT_003
63    }
64
65    fn name(&self) -> &'static str {
66        "Layout operators"
67    }
68
69    fn description(&self) -> &'static str {
70        "Operators should follow a standard for being before/after newlines."
71    }
72
73    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
74        let violations = operator_layout_violations(ctx, self.line_position);
75
76        violations
77            .into_iter()
78            .map(|((start, end), edits)| {
79                let mut issue = Issue::info(
80                    issue_codes::LINT_LT_003,
81                    "Operator line placement appears inconsistent.",
82                )
83                .with_statement(ctx.statement_index)
84                .with_span(ctx.span_from_statement_offset(start, end));
85                if !edits.is_empty() {
86                    let patch_edits = edits
87                        .into_iter()
88                        .map(|(edit_start, edit_end, replacement)| {
89                            IssuePatchEdit::new(
90                                ctx.span_from_statement_offset(edit_start, edit_end),
91                                &replacement,
92                            )
93                        })
94                        .collect();
95                    issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, patch_edits);
96                }
97                issue
98            })
99            .collect()
100    }
101}
102
103type Lt03Span = (usize, usize);
104type Lt03AutofixEdit = (usize, usize, String);
105type Lt03Violation = (Lt03Span, Vec<Lt03AutofixEdit>);
106
107fn operator_layout_violations(
108    ctx: &LintContext,
109    line_position: OperatorLinePosition,
110) -> Vec<Lt03Violation> {
111    let tokens =
112        tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
113    let Some(tokens) = tokens else {
114        return operator_layout_violations_template_fallback(ctx.statement_sql(), line_position);
115    };
116    let sql = ctx.statement_sql();
117    let token_offsets: Vec<Option<(usize, usize)>> = tokens
118        .iter()
119        .map(|token| token_with_span_offsets(sql, token))
120        .collect();
121    let non_trivia_neighbors = build_non_trivia_neighbors(&tokens);
122    let mut violations = Vec::new();
123
124    for (index, token) in tokens.iter().enumerate() {
125        if !is_layout_operator(&token.token) {
126            continue;
127        }
128
129        let current_line = token.span.start.line;
130        let Some(prev_idx) = non_trivia_neighbors.prev[index] else {
131            continue;
132        };
133        let Some(next_idx) = non_trivia_neighbors.next[index] else {
134            continue;
135        };
136        let prev_token = &tokens[prev_idx];
137        let next_token = &tokens[next_idx];
138
139        let line_break_before = prev_token.span.end.line < current_line;
140        let line_break_after = next_token.span.start.line > current_line;
141
142        let has_violation = match line_position {
143            OperatorLinePosition::Leading => line_break_after && !line_break_before,
144            OperatorLinePosition::Trailing => line_break_before && !line_break_after,
145        };
146        if has_violation {
147            let Some((start, end)) = token_offsets[index] else {
148                continue;
149            };
150            let prefer_inline_join =
151                matches!(token.token, Token::Mul) && is_interval_keyword_token(&next_token.token);
152            let edits = safe_operator_autofix_edits(
153                sql,
154                index,
155                line_position,
156                line_break_before,
157                line_break_after,
158                prefer_inline_join,
159                &token_offsets,
160                prev_idx,
161                next_idx,
162            )
163            .unwrap_or_default();
164            violations.push(((start, end), edits));
165        }
166    }
167
168    violations
169}
170
171fn operator_layout_violations_template_fallback(
172    sql: &str,
173    line_position: OperatorLinePosition,
174) -> Vec<Lt03Violation> {
175    if !contains_template_marker(sql) {
176        return Vec::new();
177    }
178
179    if !matches!(line_position, OperatorLinePosition::Leading) {
180        return Vec::new();
181    }
182
183    let mut violations = Vec::new();
184    let line_ranges = line_ranges(sql);
185
186    for (index, (line_start, line_end)) in line_ranges.iter().copied().enumerate() {
187        let line = &sql[line_start..line_end];
188        let trimmed = line.trim_end();
189        let Some((op_start, op_end)) = trailing_operator_span_in_line(line, trimmed) else {
190            continue;
191        };
192
193        let Some(next_non_empty) = line_ranges
194            .iter()
195            .copied()
196            .skip(index + 1)
197            .find(|(start, end)| !sql[*start..*end].trim().is_empty())
198        else {
199            continue;
200        };
201        let next_line = sql[next_non_empty.0..next_non_empty.1].trim_start();
202        if !next_line.starts_with("{{")
203            && !next_line.starts_with("{%")
204            && !next_line.starts_with("{#")
205        {
206            continue;
207        }
208
209        violations.push(((line_start + op_start, line_start + op_end), Vec::new()));
210    }
211
212    violations
213}
214
215fn trailing_operator_span_in_line(line: &str, trimmed: &str) -> Option<(usize, usize)> {
216    if trimmed.is_empty() {
217        return None;
218    }
219
220    let candidate = [
221        "AND", "OR", "||", ">=", "<=", "!=", "<>", "=", "+", "-", "*", "/", "<", ">",
222    ];
223    for op in candidate {
224        if let Some(start) = trimmed.rfind(op) {
225            let end = start + op.len();
226            let suffix = &trimmed[end..];
227            if !suffix.chars().all(char::is_whitespace) {
228                continue;
229            }
230            if op.chars().all(|ch| ch.is_ascii_alphabetic()) {
231                let left_ok = start == 0
232                    || !trimmed[..start]
233                        .chars()
234                        .next_back()
235                        .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
236                let right_ok = end >= trimmed.len()
237                    || !trimmed[end..]
238                        .chars()
239                        .next()
240                        .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
241                if !left_ok || !right_ok {
242                    continue;
243                }
244            }
245            if line[start..].trim_end().len() == op.len() {
246                return Some((start, end));
247            }
248        }
249    }
250
251    None
252}
253
254fn contains_template_marker(sql: &str) -> bool {
255    sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
256}
257
258fn is_interval_keyword_token(token: &Token) -> bool {
259    matches!(
260        token,
261        Token::Word(word)
262            if word.keyword == Keyword::INTERVAL || word.value.eq_ignore_ascii_case("INTERVAL")
263    )
264}
265
266fn line_ranges(sql: &str) -> Vec<(usize, usize)> {
267    let mut ranges = Vec::new();
268    let mut start = 0usize;
269    for (idx, ch) in sql.char_indices() {
270        if ch == '\n' {
271            let mut end = idx;
272            if end > start && sql[start..end].ends_with('\r') {
273                end -= 1;
274            }
275            ranges.push((start, end));
276            start = idx + 1;
277        }
278    }
279    let mut end = sql.len();
280    if end > start && sql[start..end].ends_with('\r') {
281        end -= 1;
282    }
283    ranges.push((start, end));
284    ranges
285}
286
287#[allow(clippy::too_many_arguments)]
288fn safe_operator_autofix_edits(
289    sql: &str,
290    operator_idx: usize,
291    line_position: OperatorLinePosition,
292    line_break_before: bool,
293    line_break_after: bool,
294    prefer_inline_join: bool,
295    token_offsets: &[Option<(usize, usize)>],
296    prev_idx: usize,
297    next_idx: usize,
298) -> Option<Vec<Lt03AutofixEdit>> {
299    match line_position {
300        OperatorLinePosition::Leading if !line_break_before && line_break_after => {
301            // Trailing operator → move to leading: "a +\n  b" → "a\n+ b"
302            safe_operator_move_edits(
303                sql,
304                operator_idx,
305                true,
306                prefer_inline_join,
307                token_offsets,
308                prev_idx,
309                next_idx,
310            )
311        }
312        OperatorLinePosition::Trailing if line_break_before && !line_break_after => {
313            // Leading operator → move to trailing: "a\n+ b" → "a +\n  b"
314            safe_operator_move_edits(
315                sql,
316                operator_idx,
317                false,
318                false,
319                token_offsets,
320                prev_idx,
321                next_idx,
322            )
323        }
324        _ => None,
325    }
326}
327
328/// Move an operator across a line break.
329///
330/// When `to_leading` is true, the operator currently trails on the previous line
331/// and should be moved to lead on the next line.
332/// When `to_leading` is false, the operator currently leads on the next line and
333/// should be moved to trail on the previous line.
334///
335/// Edits are split to avoid spanning comment protected ranges.
336fn safe_operator_move_edits(
337    sql: &str,
338    operator_idx: usize,
339    to_leading: bool,
340    prefer_inline_join: bool,
341    token_offsets: &[Option<(usize, usize)>],
342    prev_idx: usize,
343    next_idx: usize,
344) -> Option<Vec<Lt03AutofixEdit>> {
345    let (_, prev_end) = token_offsets.get(prev_idx).copied().flatten()?;
346    let (op_start, op_end) = token_offsets.get(operator_idx).copied().flatten()?;
347    let (next_start, _) = token_offsets.get(next_idx).copied().flatten()?;
348
349    if prev_end > op_start || op_end > next_start || next_start > sql.len() {
350        return None;
351    }
352
353    let before_gap = &sql[prev_end..op_start];
354    let after_gap = &sql[op_end..next_start];
355    let has_comments = gap_has_comment(before_gap) || gap_has_comment(after_gap);
356    let op_text = &sql[op_start..op_end];
357
358    if !has_comments {
359        // Simple case: no comments.
360        if to_leading {
361            if !before_gap.chars().all(char::is_whitespace)
362                || before_gap.contains('\n')
363                || before_gap.contains('\r')
364            {
365                return None;
366            }
367            if !after_gap.chars().all(char::is_whitespace)
368                || (!after_gap.contains('\n') && !after_gap.contains('\r'))
369            {
370                return None;
371            }
372            if prefer_inline_join {
373                // PostgreSQL interval arithmetic often appears as:
374                // "... *\n    interval '1 day'". Moving `*` to leading style can
375                // trigger LT02 indentation cascades; join this break inline.
376                return Some(vec![(op_end, next_start, " ".to_string())]);
377            }
378            // Preserve existing indentation on the next line to avoid LT02
379            // regressions when moving the operator.
380            let delete_start = whitespace_before_on_same_line(sql, op_start, prev_end);
381            return Some(vec![
382                (delete_start, op_end, String::new()),
383                (next_start, next_start, format!("{op_text} ")),
384            ]);
385        } else {
386            if !before_gap.chars().all(char::is_whitespace)
387                || (!before_gap.contains('\n') && !before_gap.contains('\r'))
388            {
389                return None;
390            }
391            if !after_gap.chars().all(char::is_whitespace)
392                || after_gap.contains('\n')
393                || after_gap.contains('\r')
394            {
395                return None;
396            }
397            // Preserve existing indentation on the following line.
398            let delete_end = skip_inline_whitespace(sql, op_end);
399            return Some(vec![
400                (prev_end, prev_end, format!(" {op_text}")),
401                (op_start, delete_end, String::new()),
402            ]);
403        }
404    }
405
406    // Comment-aware operator move: surgical edits that avoid comment spans.
407    if to_leading {
408        // Trailing → leading: "a +\n  b" → "a\n  + b"
409        // Also handles: "a + -- foo\n  b" → "a -- foo\n  + b"
410        // Also handles: "a AND\n  -- c1\n  -- c2\n  b" → "a\n  -- c1\n  -- c2\n  AND b"
411        let mut edits = Vec::new();
412
413        // 1) Delete operator and whitespace before it on the same line.
414        let delete_start = whitespace_before_on_same_line(sql, op_start, prev_end);
415        edits.push((delete_start, op_end, String::new()));
416
417        // 2) Insert operator before the next significant token.
418        //    Insert right at next_start to avoid spanning over any block comments
419        //    on the same line.
420        edits.push((next_start, next_start, format!("{op_text} ")));
421
422        Some(edits)
423    } else {
424        // Leading → trailing: "a\n  + b" → "a +\n  b"
425        // Also handles: "a -- foo\n  + b" → "a + -- foo\n  b"
426        // Also handles: "a\n  -- c1\n  -- c2\n  + b" → "a +\n  -- c1\n  -- c2\n  b"
427        let mut edits = Vec::new();
428
429        // 1) Insert operator after prev token.
430        //    Use the same trick as LT04: if a comment starts immediately at
431        //    prev_end, extend one byte into the prev token to avoid touching
432        //    the comment protected range.
433        if prev_end > 0 && gap_has_comment(&sql[prev_end..op_start]) {
434            let anchor = prev_end - 1;
435            let ch = &sql[anchor..prev_end];
436            edits.push((anchor, prev_end, format!("{ch} {op_text}")));
437        } else {
438            edits.push((prev_end, prev_end, format!(" {op_text}")));
439        }
440
441        // 2) Delete operator and trailing whitespace from its current position.
442        let delete_end = skip_inline_whitespace(sql, op_end);
443        edits.push((op_start, delete_end, String::new()));
444
445        Some(edits)
446    }
447}
448
449fn gap_has_comment(gap: &str) -> bool {
450    gap.contains("--") || gap.contains("/*")
451}
452
453fn skip_inline_whitespace(sql: &str, offset: usize) -> usize {
454    let mut pos = offset;
455    let bytes = sql.as_bytes();
456    while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
457        pos += 1;
458    }
459    pos
460}
461
462fn whitespace_before_on_same_line(sql: &str, offset: usize, floor: usize) -> usize {
463    let mut pos = offset;
464    let bytes = sql.as_bytes();
465    while pos > floor && (bytes[pos - 1] == b' ' || bytes[pos - 1] == b'\t') {
466        pos -= 1;
467    }
468    pos
469}
470
471fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
472    let dialect = dialect.to_sqlparser_dialect();
473    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
474    tokenizer.tokenize_with_location().ok()
475}
476
477fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<TokenWithSpan>> {
478    let (statement_start_line, statement_start_column) =
479        offset_to_line_col(ctx.sql, ctx.statement_range.start)?;
480
481    ctx.with_document_tokens(|tokens| {
482        if tokens.is_empty() {
483            return None;
484        }
485
486        let mut out = Vec::new();
487        for token in tokens {
488            let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
489                continue;
490            };
491            if start < ctx.statement_range.start || end > ctx.statement_range.end {
492                continue;
493            }
494
495            let Some(start_loc) = relative_location(
496                token.span.start,
497                statement_start_line,
498                statement_start_column,
499            ) else {
500                continue;
501            };
502            let Some(end_loc) =
503                relative_location(token.span.end, statement_start_line, statement_start_column)
504            else {
505                continue;
506            };
507
508            out.push(TokenWithSpan::new(
509                token.token.clone(),
510                Span::new(start_loc, end_loc),
511            ));
512        }
513
514        if out.is_empty() {
515            None
516        } else {
517            Some(out)
518        }
519    })
520}
521
522fn is_layout_operator(token: &Token) -> bool {
523    matches!(
524        token,
525        Token::Plus
526            | Token::Minus
527            | Token::Mul
528            | Token::Div
529            | Token::Mod
530            | Token::StringConcat
531            | Token::Pipe
532            | Token::Caret
533            | Token::ShiftLeft
534            | Token::ShiftRight
535            | Token::Eq
536            | Token::Neq
537            | Token::Lt
538            | Token::Gt
539            | Token::LtEq
540            | Token::GtEq
541            | Token::Spaceship
542            | Token::DoubleEq
543            | Token::Arrow
544            | Token::LongArrow
545            | Token::HashArrow
546            | Token::AtArrow
547            | Token::ArrowAt
548    ) || matches!(
549        token,
550        Token::Word(word) if matches!(word.keyword, Keyword::AND | Keyword::OR)
551    )
552}
553
554fn is_trivia_token(token: &Token) -> bool {
555    matches!(
556        token,
557        Token::Whitespace(Whitespace::Space | Whitespace::Newline | Whitespace::Tab)
558            | Token::Whitespace(Whitespace::SingleLineComment { .. })
559            | Token::Whitespace(Whitespace::MultiLineComment(_))
560    )
561}
562
563struct NonTriviaNeighbors {
564    prev: Vec<Option<usize>>,
565    next: Vec<Option<usize>>,
566}
567
568fn build_non_trivia_neighbors(tokens: &[TokenWithSpan]) -> NonTriviaNeighbors {
569    let mut prev = vec![None; tokens.len()];
570    let mut next = vec![None; tokens.len()];
571
572    let mut prev_non_trivia = None;
573    for (idx, token) in tokens.iter().enumerate() {
574        prev[idx] = prev_non_trivia;
575        if !is_trivia_token(&token.token) {
576            prev_non_trivia = Some(idx);
577        }
578    }
579
580    let mut next_non_trivia = None;
581    for idx in (0..tokens.len()).rev() {
582        next[idx] = next_non_trivia;
583        if !is_trivia_token(&tokens[idx].token) {
584            next_non_trivia = Some(idx);
585        }
586    }
587
588    NonTriviaNeighbors { prev, next }
589}
590
591fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
592    if line == 0 || column == 0 {
593        return None;
594    }
595
596    let mut current_line = 1usize;
597    let mut current_col = 1usize;
598
599    for (offset, ch) in sql.char_indices() {
600        if current_line == line && current_col == column {
601            return Some(offset);
602        }
603
604        if ch == '\n' {
605            current_line += 1;
606            current_col = 1;
607        } else {
608            current_col += 1;
609        }
610    }
611
612    if current_line == line && current_col == column {
613        return Some(sql.len());
614    }
615
616    None
617}
618
619fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
620    let start = line_col_to_offset(
621        sql,
622        token.span.start.line as usize,
623        token.span.start.column as usize,
624    )?;
625    let end = line_col_to_offset(
626        sql,
627        token.span.end.line as usize,
628        token.span.end.column as usize,
629    )?;
630    Some((start, end))
631}
632
633fn offset_to_line_col(sql: &str, offset: usize) -> Option<(usize, usize)> {
634    if offset > sql.len() {
635        return None;
636    }
637    if offset == sql.len() {
638        let mut line = 1usize;
639        let mut column = 1usize;
640        for ch in sql.chars() {
641            if ch == '\n' {
642                line += 1;
643                column = 1;
644            } else {
645                column += 1;
646            }
647        }
648        return Some((line, column));
649    }
650
651    let mut line = 1usize;
652    let mut column = 1usize;
653    for (index, ch) in sql.char_indices() {
654        if index == offset {
655            return Some((line, column));
656        }
657        if ch == '\n' {
658            line += 1;
659            column = 1;
660        } else {
661            column += 1;
662        }
663    }
664
665    None
666}
667
668fn relative_location(
669    location: Location,
670    statement_start_line: usize,
671    statement_start_column: usize,
672) -> Option<Location> {
673    let line = location.line as usize;
674    let column = location.column as usize;
675    if line < statement_start_line {
676        return None;
677    }
678
679    if line == statement_start_line {
680        if column < statement_start_column {
681            return None;
682        }
683        return Some(Location::new(
684            1,
685            (column - statement_start_column + 1) as u64,
686        ));
687    }
688
689    Some(Location::new(
690        (line - statement_start_line + 1) as u64,
691        column as u64,
692    ))
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::linter::config::LintConfig;
699    use crate::parser::parse_sql;
700    use crate::types::IssueAutofixApplicability;
701
702    fn run_with_rule(sql: &str, rule: &LayoutOperators) -> Vec<Issue> {
703        let statements = parse_sql(sql).expect("parse");
704        statements
705            .iter()
706            .enumerate()
707            .flat_map(|(index, statement)| {
708                rule.check(
709                    statement,
710                    &LintContext {
711                        sql,
712                        statement_range: 0..sql.len(),
713                        statement_index: index,
714                    },
715                )
716            })
717            .collect()
718    }
719
720    fn run(sql: &str) -> Vec<Issue> {
721        run_with_rule(sql, &LayoutOperators::default())
722    }
723
724    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
725        let autofix = issue.autofix.as_ref()?;
726        let mut out = sql.to_string();
727        let mut edits = autofix.edits.clone();
728        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
729        for edit in edits.into_iter().rev() {
730            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
731        }
732        Some(out)
733    }
734
735    #[test]
736    fn flags_trailing_operator() {
737        let sql = "SELECT a +\n b FROM t";
738        let issues = run(sql);
739        assert_eq!(issues.len(), 1);
740        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
741        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
742        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
743        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
744        assert_eq!(fixed, "SELECT a\n + b FROM t");
745    }
746
747    #[test]
748    fn does_not_flag_leading_operator() {
749        assert!(run("SELECT a\n + b FROM t").is_empty());
750    }
751
752    #[test]
753    fn does_not_flag_operator_like_text_in_string() {
754        assert!(run("SELECT 'a +\n b' AS txt").is_empty());
755    }
756
757    #[test]
758    fn trailing_line_position_flags_leading_operator() {
759        let config = LintConfig {
760            enabled: true,
761            disabled_rules: vec![],
762            rule_configs: std::collections::BTreeMap::from([(
763                "layout.operators".to_string(),
764                serde_json::json!({"line_position": "trailing"}),
765            )]),
766        };
767        let sql = "SELECT a\n + b FROM t";
768        let issues = run_with_rule(sql, &LayoutOperators::from_config(&config));
769        assert_eq!(issues.len(), 1);
770        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
771        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
772        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
773        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
774        assert_eq!(fixed, "SELECT a +\n b FROM t");
775    }
776
777    #[test]
778    fn flags_trailing_and_operator() {
779        let sql = "SELECT * FROM t WHERE a AND\nb";
780        let issues = run(sql);
781        assert_eq!(issues.len(), 1);
782        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
783        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
784        assert_eq!(fixed, "SELECT * FROM t WHERE a\nAND b");
785    }
786
787    #[test]
788    fn flags_trailing_or_operator() {
789        let sql = "SELECT * FROM t WHERE a OR\nb";
790        let issues = run(sql);
791        assert_eq!(issues.len(), 1);
792        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
793        assert_eq!(fixed, "SELECT * FROM t WHERE a\nOR b");
794    }
795
796    #[test]
797    fn does_not_flag_leading_and_operator() {
798        assert!(run("SELECT * FROM t WHERE a\nAND b").is_empty());
799    }
800
801    #[test]
802    fn trailing_config_flags_leading_operator_with_comments() {
803        // SQLFluff: fails_on_before_override_with_comment_order
804        let config = LintConfig {
805            enabled: true,
806            disabled_rules: vec![],
807            rule_configs: std::collections::BTreeMap::from([(
808                "layout.operators".to_string(),
809                serde_json::json!({"line_position": "trailing"}),
810            )]),
811        };
812        let sql =
813            "select\n    a -- comment1!\n    -- comment2!\n    -- comment3!\n    + b\nfrom foo";
814        let issues = run_with_rule(sql, &LayoutOperators::from_config(&config));
815        assert_eq!(issues.len(), 1);
816        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
817    }
818
819    #[test]
820    fn trailing_config_allows_trailing_operator() {
821        // SQLFluff: passes_on_after_override
822        let config = LintConfig {
823            enabled: true,
824            disabled_rules: vec![],
825            rule_configs: std::collections::BTreeMap::from([(
826                "layout.operators".to_string(),
827                serde_json::json!({"line_position": "trailing"}),
828            )]),
829        };
830        let sql = "select\n    a +\n    b\nfrom foo";
831        let issues = run_with_rule(sql, &LayoutOperators::from_config(&config));
832        assert!(issues.is_empty());
833    }
834
835    #[test]
836    fn trailing_config_flags_leading_operator() {
837        // SQLFluff: fails_on_before_override
838        let config = LintConfig {
839            enabled: true,
840            disabled_rules: vec![],
841            rule_configs: std::collections::BTreeMap::from([(
842                "layout.operators".to_string(),
843                serde_json::json!({"line_position": "trailing"}),
844            )]),
845        };
846        let sql = "select\n    a\n    + b\nfrom foo";
847        let issues = run_with_rule(sql, &LayoutOperators::from_config(&config));
848        assert_eq!(issues.len(), 1);
849        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
850    }
851
852    #[test]
853    fn leading_mode_moves_trailing_and_with_comments() {
854        // SQLFluff: fails_on_after_with_comment_order_preserved
855        let sql = "select\n    a AND\n    -- comment1!\n    -- comment2!\n    b\nfrom foo";
856        let issues = run(sql);
857        assert_eq!(issues.len(), 1);
858        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
859        assert_eq!(
860            fixed,
861            "select\n    a\n    -- comment1!\n    -- comment2!\n    AND b\nfrom foo"
862        );
863    }
864
865    #[test]
866    fn trailing_mode_moves_leading_plus_with_comments() {
867        // SQLFluff: fails_on_before_override_with_comment_order
868        let config = LintConfig {
869            enabled: true,
870            disabled_rules: vec![],
871            rule_configs: std::collections::BTreeMap::from([(
872                "layout.operators".to_string(),
873                serde_json::json!({"line_position": "trailing"}),
874            )]),
875        };
876        let sql =
877            "select\n    a -- comment1!\n    -- comment2!\n    -- comment3!\n    + b\nfrom foo";
878        let issues = run_with_rule(sql, &LayoutOperators::from_config(&config));
879        assert_eq!(issues.len(), 1);
880        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
881        assert_eq!(
882            fixed,
883            "select\n    a + -- comment1!\n    -- comment2!\n    -- comment3!\n    b\nfrom foo"
884        );
885    }
886
887    #[test]
888    fn leading_mode_moves_trailing_plus_with_inline_comment() {
889        // SQLFluff: fails_on_after_override_with_comment_order
890        let sql =
891            "select\n    a + -- comment1!\n    -- comment2!\n    -- comment3!\n    b\nfrom foo";
892        let issues = run(sql);
893        assert_eq!(issues.len(), 1);
894        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
895        assert_eq!(
896            fixed,
897            "select\n    a -- comment1!\n    -- comment2!\n    -- comment3!\n    + b\nfrom foo"
898        );
899    }
900
901    #[test]
902    fn legacy_operator_new_lines_before_maps_to_trailing_style() {
903        let config = LintConfig {
904            enabled: true,
905            disabled_rules: vec![],
906            rule_configs: std::collections::BTreeMap::from([(
907                "LINT_LT_003".to_string(),
908                serde_json::json!({"operator_new_lines": "before"}),
909            )]),
910        };
911        let issues = run_with_rule(
912            "SELECT a +\n b FROM t",
913            &LayoutOperators::from_config(&config),
914        );
915        assert!(issues.is_empty());
916    }
917
918    #[test]
919    fn statementless_template_line_break_after_operator_is_flagged() {
920        let sql = "{% macro binary_literal(expression) %}\n  X'{{ expression }}'\n{% endmacro %}\n\nselect\n    *\nfrom my_table\nwhere\n    a =\n        {{ binary_literal(\"0000\") }}\n";
921        let synthetic = parse_sql("SELECT 1").expect("parse");
922        let rule = LayoutOperators::default();
923        let issues = rule.check(
924            &synthetic[0],
925            &LintContext {
926                sql,
927                statement_range: 0..sql.len(),
928                statement_index: 0,
929            },
930        );
931        assert_eq!(issues.len(), 1);
932        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
933    }
934
935    #[test]
936    fn emits_one_issue_per_trailing_operator() {
937        let sql = "SELECT a /\n b -\n c FROM t";
938        let issues = run(sql);
939        assert_eq!(issues.len(), 2);
940        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
941        assert_eq!(issues[1].code, issue_codes::LINT_LT_003);
942    }
943
944    #[test]
945    fn flags_trailing_comparison_operator() {
946        let sql = "SELECT * FROM t WHERE a >=\n b";
947        let issues = run(sql);
948        assert_eq!(issues.len(), 1);
949        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
950    }
951
952    #[test]
953    fn flags_trailing_json_operator() {
954        let sql = "SELECT usage_metadata ->>\n 'endpoint_id' FROM t";
955        let issues = run(sql);
956        assert_eq!(issues.len(), 1);
957        assert_eq!(issues[0].code, issue_codes::LINT_LT_003);
958    }
959}