1use 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 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 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 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
328fn 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 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 return Some(vec![(op_end, next_start, " ".to_string())]);
377 }
378 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 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 if to_leading {
408 let mut edits = Vec::new();
412
413 let delete_start = whitespace_before_on_same_line(sql, op_start, prev_end);
415 edits.push((delete_start, op_end, String::new()));
416
417 edits.push((next_start, next_start, format!("{op_text} ")));
421
422 Some(edits)
423 } else {
424 let mut edits = Vec::new();
428
429 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 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 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 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 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 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 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 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}