1use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10
11pub struct LayoutLongLines {
12 max_line_length: Option<usize>,
13 ignore_comment_lines: bool,
14 ignore_comment_clauses: bool,
15 trailing_comments_after: bool,
16}
17
18impl LayoutLongLines {
19 pub fn from_config(config: &LintConfig) -> Self {
20 let max_line_length = if let Some(value) = config
21 .rule_config_object(issue_codes::LINT_LT_005)
22 .and_then(|obj| obj.get("max_line_length"))
23 {
24 value
25 .as_i64()
26 .map(|signed| {
27 if signed <= 0 {
28 None
29 } else {
30 usize::try_from(signed).ok()
31 }
32 })
33 .or_else(|| {
34 value
35 .as_u64()
36 .and_then(|unsigned| usize::try_from(unsigned).ok().map(Some))
37 })
38 .flatten()
39 } else {
40 Some(80)
41 };
42
43 Self {
44 max_line_length,
45 ignore_comment_lines: config
46 .rule_option_bool(issue_codes::LINT_LT_005, "ignore_comment_lines")
47 .unwrap_or(false),
48 ignore_comment_clauses: config
49 .rule_option_bool(issue_codes::LINT_LT_005, "ignore_comment_clauses")
50 .unwrap_or(false),
51 trailing_comments_after: config
52 .section_option_str("indentation", "trailing_comments")
53 .is_some_and(|value| value.eq_ignore_ascii_case("after")),
54 }
55 }
56}
57
58impl Default for LayoutLongLines {
59 fn default() -> Self {
60 Self {
61 max_line_length: Some(80),
62 ignore_comment_lines: false,
63 ignore_comment_clauses: false,
64 trailing_comments_after: false,
65 }
66 }
67}
68
69impl LintRule for LayoutLongLines {
70 fn code(&self) -> &'static str {
71 issue_codes::LINT_LT_005
72 }
73
74 fn name(&self) -> &'static str {
75 "Layout long lines"
76 }
77
78 fn description(&self) -> &'static str {
79 "Line is too long."
80 }
81
82 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
83 let Some(max_line_length) = self.max_line_length else {
84 return Vec::new();
85 };
86
87 if ctx.statement_index != 0 {
88 return Vec::new();
89 }
90
91 let overflow_spans = long_line_overflow_spans_for_context(
92 ctx,
93 max_line_length,
94 self.ignore_comment_lines,
95 self.ignore_comment_clauses,
96 );
97 if overflow_spans.is_empty() {
98 return Vec::new();
99 }
100
101 let mut issues: Vec<Issue> = overflow_spans
102 .into_iter()
103 .map(|(start, end)| {
104 Issue::info(
105 issue_codes::LINT_LT_005,
106 "SQL contains excessively long lines.",
107 )
108 .with_statement(ctx.statement_index)
109 .with_span(Span::new(start, end))
110 })
111 .collect();
112
113 let autofix_edits =
114 long_line_autofix_edits(ctx.sql, max_line_length, self.trailing_comments_after);
115 if let Some(first_issue) = issues.first_mut() {
116 if !autofix_edits.is_empty() {
117 *first_issue = first_issue
118 .clone()
119 .with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
120 }
121 }
122
123 issues
124 }
125}
126
127fn long_line_overflow_spans_for_context(
128 ctx: &LintContext,
129 max_len: usize,
130 ignore_comment_lines: bool,
131 ignore_comment_clauses: bool,
132) -> Vec<(usize, usize)> {
133 let jinja_comment_spans = jinja_comment_spans(ctx.sql);
134 if !jinja_comment_spans.is_empty() {
135 return long_line_overflow_spans(
136 ctx.sql,
137 max_len,
138 ignore_comment_lines,
139 ignore_comment_clauses,
140 ctx.dialect(),
141 );
142 }
143
144 if let Some(tokens) = tokenize_with_offsets_for_context(ctx) {
145 return long_line_overflow_spans_from_tokens(
146 ctx.sql,
147 max_len,
148 ignore_comment_lines,
149 ignore_comment_clauses,
150 &tokens,
151 &jinja_comment_spans,
152 );
153 }
154
155 long_line_overflow_spans(
156 ctx.sql,
157 max_len,
158 ignore_comment_lines,
159 ignore_comment_clauses,
160 ctx.dialect(),
161 )
162}
163
164fn long_line_overflow_spans(
165 sql: &str,
166 max_len: usize,
167 ignore_comment_lines: bool,
168 ignore_comment_clauses: bool,
169 dialect: Dialect,
170) -> Vec<(usize, usize)> {
171 if let Some(spans) = long_line_overflow_spans_tokenized(
172 sql,
173 max_len,
174 ignore_comment_lines,
175 ignore_comment_clauses,
176 dialect,
177 ) {
178 return spans;
179 }
180
181 long_line_overflow_spans_naive(sql, max_len, ignore_comment_lines)
182}
183
184fn long_line_overflow_spans_naive(
185 sql: &str,
186 max_len: usize,
187 ignore_comment_lines: bool,
188) -> Vec<(usize, usize)> {
189 let mut spans = Vec::new();
190 for (line_start, line_end) in line_ranges(sql) {
191 let line = &sql[line_start..line_end];
192 if ignore_comment_lines {
193 let trimmed = line.trim_start();
194 if trimmed.starts_with("--") || trimmed.starts_with("/*") || trimmed.starts_with("{#") {
195 continue;
196 }
197 }
198
199 if line.chars().count() <= max_len {
200 continue;
201 }
202
203 let mut overflow_start = line_end;
204 for (char_idx, (byte_off, _)) in line.char_indices().enumerate() {
205 if char_idx == max_len {
206 overflow_start = line_start + byte_off;
207 break;
208 }
209 }
210
211 if overflow_start < line_end {
212 let overflow_end = sql[overflow_start..line_end]
213 .chars()
214 .next()
215 .map(|ch| overflow_start + ch.len_utf8())
216 .unwrap_or(overflow_start);
217 spans.push((overflow_start, overflow_end));
218 }
219 }
220 spans
221}
222
223#[derive(Clone)]
224struct LocatedToken {
225 token: Token,
226 start: usize,
227 end: usize,
228}
229
230fn long_line_overflow_spans_tokenized(
231 sql: &str,
232 max_len: usize,
233 ignore_comment_lines: bool,
234 ignore_comment_clauses: bool,
235 dialect: Dialect,
236) -> Option<Vec<(usize, usize)>> {
237 let jinja_comment_spans = jinja_comment_spans(sql);
238 let sanitized = sanitize_sql_for_jinja_comments(sql, &jinja_comment_spans);
239 let tokens = tokenize_with_offsets(&sanitized, dialect)?;
240 Some(long_line_overflow_spans_from_tokens(
241 sql,
242 max_len,
243 ignore_comment_lines,
244 ignore_comment_clauses,
245 &tokens,
246 &jinja_comment_spans,
247 ))
248}
249
250fn long_line_overflow_spans_from_tokens(
251 sql: &str,
252 max_len: usize,
253 ignore_comment_lines: bool,
254 ignore_comment_clauses: bool,
255 tokens: &[LocatedToken],
256 jinja_comment_spans: &[std::ops::Range<usize>],
257) -> Vec<(usize, usize)> {
258 let line_ranges = line_ranges(sql);
259 let mut spans = Vec::new();
260
261 for (line_start, line_end) in line_ranges {
262 let line = &sql[line_start..line_end];
263 if ignore_comment_lines
264 && line_is_comment_only_tokenized(
265 line_start,
266 line_end,
267 tokens,
268 line,
269 sql,
270 jinja_comment_spans,
271 )
272 {
273 continue;
274 }
275
276 let effective_end = if ignore_comment_clauses {
277 comment_clause_start_offset_tokenized(line_start, line_end, tokens, jinja_comment_spans)
278 .unwrap_or(line_end)
279 } else {
280 line_end
281 };
282
283 let effective_line = &sql[line_start..effective_end];
284 if effective_line.chars().count() <= max_len {
285 continue;
286 }
287
288 let mut overflow_start = effective_end;
289 for (char_idx, (byte_off, _)) in effective_line.char_indices().enumerate() {
290 if char_idx == max_len {
291 overflow_start = line_start + byte_off;
292 break;
293 }
294 }
295
296 if overflow_start < effective_end {
297 let overflow_end = sql[overflow_start..effective_end]
298 .chars()
299 .next()
300 .map(|ch| overflow_start + ch.len_utf8())
301 .unwrap_or(overflow_start);
302 spans.push((overflow_start, overflow_end));
303 }
304 }
305
306 spans
307}
308
309fn line_ranges(sql: &str) -> Vec<(usize, usize)> {
310 let mut ranges = Vec::new();
311 let mut line_start = 0usize;
312
313 for (idx, ch) in sql.char_indices() {
314 if ch != '\n' {
315 continue;
316 }
317
318 let mut line_end = idx;
319 if line_end > line_start && sql[line_start..line_end].ends_with('\r') {
320 line_end -= 1;
321 }
322 ranges.push((line_start, line_end));
323 line_start = idx + 1;
324 }
325
326 let mut line_end = sql.len();
327 if line_end > line_start && sql[line_start..line_end].ends_with('\r') {
328 line_end -= 1;
329 }
330 ranges.push((line_start, line_end));
331 ranges
332}
333
334const LEGACY_MAX_LINE_LENGTH: usize = 300;
337const LEGACY_LINE_SPLIT_TARGET: usize = 280;
338
339fn legacy_split_long_line(line: &str) -> Option<String> {
340 if line.len() <= LEGACY_MAX_LINE_LENGTH {
341 return None;
342 }
343
344 let mut rewritten = String::new();
345 let mut remaining = line.trim_start();
346 let mut first_segment = true;
347
348 while remaining.len() > LEGACY_MAX_LINE_LENGTH {
349 let probe = remaining
350 .char_indices()
351 .take_while(|(index, _)| *index <= LEGACY_LINE_SPLIT_TARGET)
352 .map(|(index, _)| index)
353 .last()
354 .unwrap_or(LEGACY_LINE_SPLIT_TARGET.min(remaining.len()));
355 let split_at = remaining[..probe].rfind(' ').unwrap_or(probe);
356
357 if !first_segment {
358 rewritten.push('\n');
359 }
360 rewritten.push_str(remaining[..split_at].trim_end());
361 rewritten.push('\n');
362 remaining = remaining[split_at..].trim_start();
363 first_segment = false;
364 }
365
366 rewritten.push_str(remaining);
367 Some(rewritten)
368}
369
370fn long_line_autofix_edits(
380 sql: &str,
381 max_line_length: usize,
382 trailing_comments_after: bool,
383) -> Vec<IssuePatchEdit> {
384 let mut edits = Vec::new();
385
386 for (line_start, line_end) in line_ranges(sql) {
387 let line = &sql[line_start..line_end];
388 if is_comment_only_line(line) {
389 continue;
390 }
391
392 let replacement = if line.len() > LEGACY_MAX_LINE_LENGTH {
393 legacy_split_long_line(line)
394 } else if line.chars().count() > max_line_length {
395 rewrite_lt05_long_line(line, max_line_length, trailing_comments_after)
396 } else {
397 None
398 };
399
400 let Some(replacement) = replacement else {
401 continue;
402 };
403 if replacement == line {
404 continue;
405 }
406
407 edits.push(IssuePatchEdit::new(
408 Span::new(line_start, line_end),
409 replacement,
410 ));
411 }
412
413 edits
414}
415
416fn is_comment_only_line(line: &str) -> bool {
417 let trimmed = line.trim_start();
418 trimmed.starts_with("--")
419 || trimmed.starts_with("/*")
420 || trimmed.starts_with('*')
421 || trimmed.starts_with("*/")
422 || trimmed.starts_with("{#")
423}
424
425fn rewrite_lt05_long_line(
426 line: &str,
427 max_line_length: usize,
428 trailing_comments_after: bool,
429) -> Option<String> {
430 rewrite_inline_comment_line(line, max_line_length, trailing_comments_after)
431 .or_else(|| rewrite_lt05_code_line(line, max_line_length))
432}
433
434fn rewrite_lt05_code_line(line: &str, max_line_length: usize) -> Option<String> {
435 rewrite_window_function_line(line, max_line_length)
436 .or_else(|| rewrite_over_clause_with_tail_line(line, max_line_length))
437 .or_else(|| rewrite_function_alias_line(line, max_line_length))
438 .or_else(|| rewrite_function_equals_line(line, max_line_length))
439 .or_else(|| rewrite_expression_alias_line(line, max_line_length))
440 .or_else(|| rewrite_clause_break_line(line, max_line_length))
441 .or_else(|| rewrite_whitespace_wrap_line(line, max_line_length))
442}
443
444fn rewrite_expression_alias_line(line: &str, max_line_length: usize) -> Option<String> {
445 if line.chars().count() <= max_line_length {
446 return None;
447 }
448
449 let marker = find_last_ascii_case_insensitive(line, " as ")?;
454 if marker == 0 {
455 return None;
456 }
457
458 let left = line[..marker].trim_end();
459 let right = line[marker + 1..].trim_start();
460 if left.is_empty() || right.is_empty() {
461 return None;
462 }
463
464 let continuation = format!("{} ", leading_whitespace_prefix(line));
465 Some(format!("{left}\n{continuation}{right}"))
466}
467
468fn rewrite_inline_comment_line(
469 line: &str,
470 max_line_length: usize,
471 trailing_comments_after: bool,
472) -> Option<String> {
473 let comment_start = find_unquoted_inline_comment_start(line)?;
474 let code_prefix = &line[..comment_start];
475 let code_trimmed = code_prefix.trim_end();
476 if code_trimmed.trim().is_empty() {
477 return None;
478 }
479 if code_trimmed.trim() == "," {
480 return None;
483 }
484
485 let indent = leading_whitespace_prefix(line);
486 let code_body = code_trimmed
487 .strip_prefix(indent)
488 .unwrap_or(code_trimmed)
489 .trim_start();
490 if code_body.is_empty() {
491 return None;
492 }
493
494 let mut code_line = format!("{indent}{code_body}");
495 if code_line.chars().count() > max_line_length {
496 if let Some(rewritten) = rewrite_lt05_code_line(&code_line, max_line_length) {
497 code_line = rewritten;
498 }
499 }
500
501 let comment_line = format!("{indent}{}", line[comment_start..].trim_end());
502 if trailing_comments_after {
503 Some(format!("{code_line}\n{comment_line}"))
504 } else {
505 Some(format!("{comment_line}\n{code_line}"))
506 }
507}
508
509fn rewrite_clause_break_line(line: &str, max_line_length: usize) -> Option<String> {
510 if line.chars().count() <= max_line_length {
511 return None;
512 }
513
514 const CLAUSE_NEEDLES: [&str; 7] = [
515 " from ",
516 " where ",
517 " qualify ",
518 " order by ",
519 " group by ",
520 " having ",
521 " join ",
522 ];
523
524 let split_at = CLAUSE_NEEDLES
525 .iter()
526 .filter_map(|needle| find_ascii_case_insensitive(line, needle))
527 .min()?;
528
529 if split_at == 0 {
530 return None;
531 }
532 let left = line[..split_at].trim_end();
533 let right = line[split_at + 1..].trim_start();
534 if left.is_empty() || right.is_empty() {
535 return None;
536 }
537
538 let indent = leading_whitespace_prefix(line);
539 Some(format!("{left}\n{indent}{right}"))
540}
541
542fn rewrite_function_alias_line(line: &str, max_line_length: usize) -> Option<String> {
543 if line.chars().count() <= max_line_length
544 || find_ascii_case_insensitive(line, " over ").is_some()
545 {
546 return None;
547 }
548
549 let marker = find_ascii_case_insensitive(line, ") as ")?;
550 let split_at = marker + 1;
551 let left = line[..split_at].trim_end();
552 let right = line[split_at..].trim_start();
553 if left.is_empty() || right.is_empty() {
554 return None;
555 }
556
557 let continuation = format!("{} ", leading_whitespace_prefix(line));
558 Some(format!("{left}\n{continuation}{right}"))
559}
560
561fn rewrite_function_equals_line(line: &str, max_line_length: usize) -> Option<String> {
562 if line.chars().count() <= max_line_length {
563 return None;
564 }
565
566 let marker = find_ascii_case_insensitive(line, ") = ")?;
567 let split_at = marker + 1;
568 let left = line[..split_at].trim_end();
569 let right = line[split_at..].trim_start();
570 if left.is_empty() || right.is_empty() {
571 return None;
572 }
573
574 let indent = leading_whitespace_prefix(line);
575 Some(format!("{left}\n{indent}{right}"))
576}
577
578fn find_last_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
579 if needle.is_empty() || haystack.len() < needle.len() {
580 return None;
581 }
582
583 let haystack_bytes = haystack.as_bytes();
584 let needle_bytes = needle.as_bytes();
585
586 (0..=haystack_bytes.len() - needle_bytes.len())
587 .rev()
588 .find(|&start| {
589 haystack_bytes[start..start + needle_bytes.len()]
590 .iter()
591 .zip(needle_bytes.iter())
592 .all(|(left, right)| left.eq_ignore_ascii_case(right))
593 })
594}
595
596fn rewrite_over_clause_with_tail_line(line: &str, max_line_length: usize) -> Option<String> {
597 if line.chars().count() <= max_line_length {
598 return None;
599 }
600
601 let over_start = find_ascii_case_insensitive(line, " over (")?;
602 let over_open = line[over_start..]
603 .find('(')
604 .map(|offset| over_start + offset)?;
605 let over_close = matching_close_paren(line, over_open)?;
606
607 let tail = line[over_close + 1..].trim_start();
608 if !contains_ascii_case_insensitive(tail, "as ") {
609 return None;
610 }
611
612 let indent = leading_whitespace_prefix(line);
613 let continuation = format!("{indent} ");
614 let inner_indent = format!("{indent} ");
615 let prefix = line[..over_start].trim_end();
616 if prefix.is_empty() {
617 return None;
618 }
619 let over_kw = line[over_start..over_open].trim();
620 let inside = line[over_open + 1..over_close].trim();
621 if inside.is_empty() {
622 return None;
623 }
624
625 let mut lines = vec![prefix.to_string(), format!("{continuation}{over_kw} (")];
626 if let Some(order_idx) = find_ascii_case_insensitive(inside, " order by ") {
627 let partition = inside[..order_idx].trim();
628 let order_by = inside[order_idx + 1..].trim_start();
629 if !partition.is_empty() {
630 lines.push(format!("{inner_indent}{partition}"));
631 }
632 if !order_by.is_empty() {
633 lines.push(format!("{inner_indent}{order_by}"));
634 }
635 } else {
636 lines.push(format!("{inner_indent}{inside}"));
637 }
638 lines.push(format!("{continuation})"));
639 lines.push(format!("{continuation}{tail}"));
640 Some(lines.join("\n"))
641}
642
643fn rewrite_window_function_line(line: &str, max_line_length: usize) -> Option<String> {
644 if line.chars().count() <= max_line_length {
645 return None;
646 }
647
648 let over_start = find_ascii_case_insensitive(line, " over (")?;
649 let modifier_start = rfind_ascii_case_insensitive_before(line, " ignore nulls", over_start)
650 .or_else(|| rfind_ascii_case_insensitive_before(line, " respect nulls", over_start))?;
651
652 let function_part = line[..modifier_start].trim_end();
653 let modifier = line[modifier_start..over_start].trim();
654 let over_part = line[over_start + 1..].trim_start();
655 if function_part.is_empty() || modifier.is_empty() || over_part.is_empty() {
656 return None;
657 }
658
659 let indent = leading_whitespace_prefix(line);
660 let continuation = format!("{indent} ");
661
662 let mut lines = Vec::new();
663 if let Some((head, inner)) = outer_call_head_and_inner(function_part) {
664 if inner.contains('(') && inner.contains(')') {
665 lines.push(format!("{head}("));
666 lines.push(format!("{continuation}{inner}"));
667 lines.push(format!("{indent}) {modifier}"));
668 } else {
669 lines.push(format!("{} {modifier}", function_part.trim_end()));
670 }
671 } else {
672 lines.push(format!("{} {modifier}", function_part.trim_end()));
673 }
674 lines.push(format!("{continuation}{over_part}"));
675 Some(lines.join("\n"))
676}
677
678fn outer_call_head_and_inner(function_part: &str) -> Option<(&str, &str)> {
679 let trimmed = function_part.trim_end();
680 if !trimmed.ends_with(')') {
681 return None;
682 }
683 let open = trimmed.find('(')?;
684 let close = matching_close_paren(trimmed, open)?;
685 if close + 1 != trimmed.len() {
686 return None;
687 }
688 let head = trimmed[..open].trim_end();
689 let inner = trimmed[open + 1..close].trim();
690 if head.is_empty() || inner.is_empty() {
691 return None;
692 }
693 Some((head, inner))
694}
695
696fn leading_whitespace_prefix(line: &str) -> &str {
697 let width = line
698 .bytes()
699 .take_while(|byte| matches!(*byte, b' ' | b'\t'))
700 .count();
701 &line[..width]
702}
703
704fn find_unquoted_inline_comment_start(line: &str) -> Option<usize> {
705 let bytes = line.as_bytes();
706 let mut index = 0usize;
707 let mut in_single = false;
708 let mut in_double = false;
709
710 while index + 1 < bytes.len() {
711 let byte = bytes[index];
712
713 if in_single {
714 if byte == b'\'' {
715 if index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
716 index += 2;
717 continue;
718 }
719 in_single = false;
720 }
721 index += 1;
722 continue;
723 }
724
725 if in_double {
726 if byte == b'"' {
727 if index + 1 < bytes.len() && bytes[index + 1] == b'"' {
728 index += 2;
729 continue;
730 }
731 in_double = false;
732 }
733 index += 1;
734 continue;
735 }
736
737 if byte == b'\'' {
738 in_single = true;
739 index += 1;
740 continue;
741 }
742 if byte == b'"' {
743 in_double = true;
744 index += 1;
745 continue;
746 }
747 if byte == b'-' && bytes[index + 1] == b'-' {
748 return Some(index);
749 }
750 index += 1;
751 }
752
753 None
754}
755
756fn matching_close_paren(input: &str, open_index: usize) -> Option<usize> {
757 if !matches!(input.as_bytes().get(open_index), Some(b'(')) {
758 return None;
759 }
760
761 let mut depth = 0usize;
762 for (index, ch) in input
763 .char_indices()
764 .skip_while(|(idx, _)| *idx < open_index)
765 {
766 match ch {
767 '(' => depth += 1,
768 ')' => {
769 depth = depth.saturating_sub(1);
770 if depth == 0 {
771 return Some(index);
772 }
773 }
774 _ => {}
775 }
776 }
777
778 None
779}
780
781fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
782 haystack
783 .to_ascii_lowercase()
784 .find(&needle.to_ascii_lowercase())
785}
786
787fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
788 find_ascii_case_insensitive(haystack, needle).is_some()
789}
790
791fn rfind_ascii_case_insensitive_before(haystack: &str, needle: &str, end: usize) -> Option<usize> {
792 haystack[..end.min(haystack.len())]
793 .to_ascii_lowercase()
794 .rfind(&needle.to_ascii_lowercase())
795}
796
797fn rewrite_whitespace_wrap_line(line: &str, max_line_length: usize) -> Option<String> {
798 if line.chars().count() <= max_line_length {
799 return None;
800 }
801 if line.contains("--") || line.contains("/*") || line.contains("*/") {
802 return None;
803 }
804
805 let indent = leading_whitespace_prefix(line);
806 let indent_chars = indent.chars().count();
807 let continuation_indent = format!("{indent} ");
808 let continuation_chars = continuation_indent.chars().count();
809 let mut remaining = line[indent.len()..].trim_end().to_string();
810 if remaining.is_empty() {
811 return None;
812 }
813
814 let mut wrapped = Vec::new();
815 let mut first = true;
816 loop {
817 let limit = if first {
818 max_line_length.saturating_sub(indent_chars)
819 } else {
820 max_line_length.saturating_sub(continuation_chars)
821 };
822 if limit < 8 || remaining.chars().count() <= limit {
823 break;
824 }
825
826 let split_at = wrap_split_index(&remaining, limit)?;
827 let head = remaining[..split_at].trim_end();
828 let tail = remaining[split_at..].trim_start();
829 if head.is_empty() || tail.is_empty() {
830 return None;
831 }
832
833 if first {
834 wrapped.push(format!("{indent}{head}"));
835 first = false;
836 } else {
837 wrapped.push(format!("{continuation_indent}{head}"));
838 }
839 remaining = tail.to_string();
840 }
841
842 if wrapped.is_empty() {
843 return None;
844 }
845
846 if first {
847 wrapped.push(format!("{indent}{remaining}"));
848 } else {
849 wrapped.push(format!("{continuation_indent}{remaining}"));
850 }
851 Some(wrapped.join("\n"))
852}
853
854fn wrap_split_index(content: &str, char_limit: usize) -> Option<usize> {
855 if char_limit == 0 {
856 return None;
857 }
858
859 #[derive(Clone, Copy)]
860 enum ScanMode {
861 Outside,
862 SingleQuote,
863 DoubleQuote,
864 BacktickQuote,
865 }
866
867 let mut split_at = None;
868 let mut mode = ScanMode::Outside;
869 let mut iter = content.char_indices().enumerate().peekable();
870 while let Some((char_idx, (byte_idx, ch))) = iter.next() {
871 if char_idx >= char_limit {
872 break;
873 }
874
875 match mode {
876 ScanMode::Outside => {
877 if ch.is_whitespace() {
878 split_at = Some(byte_idx);
879 continue;
880 }
881 mode = match ch {
882 '\'' => ScanMode::SingleQuote,
883 '"' => ScanMode::DoubleQuote,
884 '`' => ScanMode::BacktickQuote,
885 _ => ScanMode::Outside,
886 };
887 }
888 ScanMode::SingleQuote => {
889 if ch == '\'' {
890 if iter
891 .peek()
892 .is_some_and(|(_, (_, next_ch))| *next_ch == '\'')
893 {
894 let _ = iter.next();
895 } else {
896 mode = ScanMode::Outside;
897 }
898 }
899 }
900 ScanMode::DoubleQuote => {
901 if ch == '"' {
902 if iter.peek().is_some_and(|(_, (_, next_ch))| *next_ch == '"') {
903 let _ = iter.next();
904 } else {
905 mode = ScanMode::Outside;
906 }
907 }
908 }
909 ScanMode::BacktickQuote => {
910 if ch == '`' {
911 if iter.peek().is_some_and(|(_, (_, next_ch))| *next_ch == '`') {
912 let _ = iter.next();
913 } else {
914 mode = ScanMode::Outside;
915 }
916 }
917 }
918 }
919 }
920
921 split_at.filter(|byte_idx| *byte_idx > 0)
922}
923
924fn line_is_comment_only_tokenized(
925 line_start: usize,
926 line_end: usize,
927 tokens: &[LocatedToken],
928 line_text: &str,
929 sql: &str,
930 jinja_comment_spans: &[std::ops::Range<usize>],
931) -> bool {
932 if line_is_jinja_comment_only(line_start, line_end, sql, jinja_comment_spans) {
933 return true;
934 }
935
936 let line_tokens = tokens_on_line(tokens, line_start, line_end);
937 if line_tokens.is_empty() {
938 return false;
939 }
940
941 let mut non_spacing = line_tokens
942 .into_iter()
943 .filter(|token| !is_spacing_whitespace(&token.token))
944 .peekable();
945
946 let Some(first) = non_spacing.peek() else {
947 return false;
948 };
949
950 let mut saw_comment = false;
951 if matches!(first.token, Token::Comma)
952 && line_prefix_before_token_is_spacing(line_text, line_start, first.start)
953 {
954 let _ = non_spacing.next();
955 }
956
957 for token in non_spacing {
958 if is_comment_token(&token.token) {
959 saw_comment = true;
960 continue;
961 }
962 return false;
963 }
964
965 saw_comment
966}
967
968fn comment_clause_start_offset_tokenized(
969 line_start: usize,
970 line_end: usize,
971 tokens: &[LocatedToken],
972 jinja_comment_spans: &[std::ops::Range<usize>],
973) -> Option<usize> {
974 let jinja_start = first_jinja_comment_start_on_line(line_start, line_end, jinja_comment_spans);
975 let line_tokens = tokens_on_line(tokens, line_start, line_end);
976 let significant: Vec<&LocatedToken> = line_tokens
977 .iter()
978 .copied()
979 .filter(|token| !is_spacing_whitespace(&token.token))
980 .collect();
981
982 let mut earliest = jinja_start;
983
984 for (index, token) in significant.iter().enumerate() {
985 if let Token::Word(word) = &token.token {
986 if word.value.eq_ignore_ascii_case("comment") {
987 let candidate = token.start.max(line_start);
988 earliest = Some(earliest.map_or(candidate, |current| current.min(candidate)));
989 break;
990 }
991 }
992
993 if matches!(
994 token.token,
995 Token::Whitespace(Whitespace::SingleLineComment { .. })
996 ) {
997 let candidate = token.start.max(line_start);
998 earliest = Some(earliest.map_or(candidate, |current| current.min(candidate)));
999 break;
1000 }
1001
1002 if matches!(
1003 token.token,
1004 Token::Whitespace(Whitespace::MultiLineComment(_))
1005 ) && significant[index + 1..]
1006 .iter()
1007 .all(|next| is_spacing_whitespace(&next.token))
1008 {
1009 let candidate = token.start.max(line_start);
1010 earliest = Some(earliest.map_or(candidate, |current| current.min(candidate)));
1011 break;
1012 }
1013 }
1014
1015 earliest
1016}
1017
1018fn tokens_on_line(
1019 tokens: &[LocatedToken],
1020 line_start: usize,
1021 line_end: usize,
1022) -> Vec<&LocatedToken> {
1023 tokens
1024 .iter()
1025 .filter(|token| token.start < line_end && token.end > line_start)
1026 .collect()
1027}
1028
1029fn line_prefix_before_token_is_spacing(
1030 line_text: &str,
1031 line_start: usize,
1032 token_start: usize,
1033) -> bool {
1034 if token_start < line_start {
1035 return false;
1036 }
1037
1038 line_text[..token_start - line_start]
1039 .chars()
1040 .all(char::is_whitespace)
1041}
1042
1043fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
1044 let dialect = dialect.to_sqlparser_dialect();
1045 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
1046 let tokens = tokenizer.tokenize_with_location().ok()?;
1047
1048 let mut out = Vec::with_capacity(tokens.len());
1049 for token in tokens {
1050 let start = line_col_to_offset(
1051 sql,
1052 token.span.start.line as usize,
1053 token.span.start.column as usize,
1054 )?;
1055 let end = line_col_to_offset(
1056 sql,
1057 token.span.end.line as usize,
1058 token.span.end.column as usize,
1059 )?;
1060 out.push(LocatedToken {
1061 token: token.token,
1062 start,
1063 end,
1064 });
1065 }
1066
1067 Some(out)
1068}
1069
1070fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
1071 ctx.with_document_tokens(|tokens| {
1072 if tokens.is_empty() {
1073 return None;
1074 }
1075
1076 Some(
1077 tokens
1078 .iter()
1079 .filter_map(|token| {
1080 token_with_span_offsets(ctx.sql, token).map(|(start, end)| LocatedToken {
1081 token: token.token.clone(),
1082 start,
1083 end,
1084 })
1085 })
1086 .collect::<Vec<_>>(),
1087 )
1088 })
1089}
1090
1091fn jinja_comment_spans(sql: &str) -> Vec<std::ops::Range<usize>> {
1092 let mut spans = Vec::new();
1093 let mut cursor = 0usize;
1094
1095 while cursor < sql.len() {
1096 let Some(open_rel) = sql[cursor..].find("{#") else {
1097 break;
1098 };
1099 let start = cursor + open_rel;
1100 let content_start = start + 2;
1101 if let Some(close_rel) = sql[content_start..].find("#}") {
1102 let end = content_start + close_rel + 2;
1103 spans.push(start..end);
1104 cursor = end;
1105 } else {
1106 spans.push(start..sql.len());
1107 break;
1108 }
1109 }
1110
1111 spans
1112}
1113
1114fn sanitize_sql_for_jinja_comments(sql: &str, spans: &[std::ops::Range<usize>]) -> String {
1115 if spans.is_empty() {
1116 return sql.to_string();
1117 }
1118
1119 let mut bytes = sql.as_bytes().to_vec();
1120 for span in spans {
1121 for idx in span.start..span.end.min(bytes.len()) {
1122 if bytes[idx] != b'\n' {
1123 bytes[idx] = b' ';
1124 }
1125 }
1126 }
1127
1128 String::from_utf8(bytes).expect("sanitized SQL should remain valid UTF-8")
1129}
1130
1131fn first_jinja_comment_start_on_line(
1132 line_start: usize,
1133 line_end: usize,
1134 spans: &[std::ops::Range<usize>],
1135) -> Option<usize> {
1136 spans
1137 .iter()
1138 .filter_map(|span| {
1139 if span.start >= line_end || span.end <= line_start {
1140 return None;
1141 }
1142 Some(span.start.max(line_start))
1143 })
1144 .min()
1145}
1146
1147fn line_is_jinja_comment_only(
1148 line_start: usize,
1149 line_end: usize,
1150 sql: &str,
1151 spans: &[std::ops::Range<usize>],
1152) -> bool {
1153 let mut in_prefix = true;
1154 let mut saw_comment = false;
1155
1156 for (rel, ch) in sql[line_start..line_end].char_indices() {
1157 if in_prefix {
1158 if ch.is_whitespace() || ch == ',' {
1159 continue;
1160 }
1161 in_prefix = false;
1162 }
1163
1164 if ch.is_whitespace() {
1165 continue;
1166 }
1167
1168 let abs = line_start + rel;
1169 if !offset_in_any_span(abs, spans) {
1170 return false;
1171 }
1172 saw_comment = true;
1173 }
1174
1175 saw_comment
1176}
1177
1178fn offset_in_any_span(offset: usize, spans: &[std::ops::Range<usize>]) -> bool {
1179 spans
1180 .iter()
1181 .any(|span| offset >= span.start && offset < span.end)
1182}
1183
1184fn is_comment_token(token: &Token) -> bool {
1185 matches!(
1186 token,
1187 Token::Whitespace(Whitespace::SingleLineComment { .. })
1188 | Token::Whitespace(Whitespace::MultiLineComment(_))
1189 )
1190}
1191
1192fn is_spacing_whitespace(token: &Token) -> bool {
1193 matches!(
1194 token,
1195 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
1196 )
1197}
1198
1199fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
1200 if line == 0 || column == 0 {
1201 return None;
1202 }
1203
1204 let mut current_line = 1usize;
1205 let mut current_col = 1usize;
1206
1207 for (offset, ch) in sql.char_indices() {
1208 if current_line == line && current_col == column {
1209 return Some(offset);
1210 }
1211
1212 if ch == '\n' {
1213 current_line += 1;
1214 current_col = 1;
1215 } else {
1216 current_col += 1;
1217 }
1218 }
1219
1220 if current_line == line && current_col == column {
1221 return Some(sql.len());
1222 }
1223
1224 None
1225}
1226
1227fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
1228 let start = line_col_to_offset(
1229 sql,
1230 token.span.start.line as usize,
1231 token.span.start.column as usize,
1232 )?;
1233 let end = line_col_to_offset(
1234 sql,
1235 token.span.end.line as usize,
1236 token.span.end.column as usize,
1237 )?;
1238 Some((start, end))
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::parser::parse_sql;
1245 use crate::types::IssueAutofixApplicability;
1246
1247 fn run_with_rule(sql: &str, rule: &LayoutLongLines) -> Vec<Issue> {
1248 let statements = parse_sql(sql).expect("parse");
1249 statements
1250 .iter()
1251 .enumerate()
1252 .flat_map(|(index, statement)| {
1253 rule.check(
1254 statement,
1255 &LintContext {
1256 sql,
1257 statement_range: 0..sql.len(),
1258 statement_index: index,
1259 },
1260 )
1261 })
1262 .collect()
1263 }
1264
1265 fn run(sql: &str) -> Vec<Issue> {
1266 run_with_rule(sql, &LayoutLongLines::default())
1267 }
1268
1269 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
1270 let autofix = issue.autofix.as_ref()?;
1271 let mut edits = autofix.edits.clone();
1272 Some(apply_patch_edits(sql, &mut edits))
1273 }
1274
1275 fn apply_patch_edits(sql: &str, edits: &mut [IssuePatchEdit]) -> String {
1276 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
1277 let mut rewritten = sql.to_string();
1278 for edit in edits.iter().rev() {
1279 rewritten.replace_range(edit.span.start..edit.span.end, &edit.replacement);
1280 }
1281 rewritten
1282 }
1283
1284 #[test]
1285 fn flags_single_long_line() {
1286 let long_line = format!("SELECT {} FROM t", "x".repeat(320));
1287 let issues = run(&long_line);
1288 assert_eq!(issues.len(), 1);
1289 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1290 }
1291
1292 #[test]
1293 fn does_not_flag_short_line() {
1294 assert!(run("SELECT x FROM t").is_empty());
1295 }
1296
1297 #[test]
1298 fn flags_each_overflowing_line_once() {
1299 let sql = format!(
1300 "SELECT {} AS a,\n {} AS b FROM t",
1301 "x".repeat(90),
1302 "y".repeat(90)
1303 );
1304 let issues = run(&sql);
1305 assert_eq!(
1306 issues
1307 .iter()
1308 .filter(|issue| issue.code == issue_codes::LINT_LT_005)
1309 .count(),
1310 2,
1311 );
1312 }
1313
1314 #[test]
1315 fn configured_max_line_length_is_respected() {
1316 let config = LintConfig {
1317 enabled: true,
1318 disabled_rules: vec![],
1319 rule_configs: std::collections::BTreeMap::from([(
1320 "layout.long_lines".to_string(),
1321 serde_json::json!({"max_line_length": 20}),
1322 )]),
1323 };
1324 let rule = LayoutLongLines::from_config(&config);
1325 let sql = "SELECT this_line_is_long FROM t";
1326 let statements = parse_sql(sql).expect("parse");
1327 let issues = rule.check(
1328 &statements[0],
1329 &LintContext {
1330 sql,
1331 statement_range: 0..sql.len(),
1332 statement_index: 0,
1333 },
1334 );
1335 assert_eq!(issues.len(), 1);
1336 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1337 }
1338
1339 #[test]
1340 fn ignore_comment_lines_skips_long_comment_only_lines() {
1341 let config = LintConfig {
1342 enabled: true,
1343 disabled_rules: vec![],
1344 rule_configs: std::collections::BTreeMap::from([(
1345 "layout.long_lines".to_string(),
1346 serde_json::json!({
1347 "max_line_length": 20,
1348 "ignore_comment_lines": true
1349 }),
1350 )]),
1351 };
1352 let sql = format!("SELECT 1;\n-- {}\nSELECT 2", "x".repeat(120));
1353 let issues = run_with_rule(&sql, &LayoutLongLines::from_config(&config));
1354 assert!(
1355 issues.is_empty(),
1356 "ignore_comment_lines should suppress long comment-only lines: {issues:?}",
1357 );
1358 }
1359
1360 #[test]
1361 fn ignore_comment_lines_skips_comma_prefixed_comment_lines() {
1362 let config = LintConfig {
1363 enabled: true,
1364 disabled_rules: vec![],
1365 rule_configs: std::collections::BTreeMap::from([(
1366 "layout.long_lines".to_string(),
1367 serde_json::json!({
1368 "max_line_length": 30,
1369 "ignore_comment_lines": true
1370 }),
1371 )]),
1372 };
1373 let sql = "SELECT\nc1\n,-- this is a very long comment line that should be ignored\nc2\n";
1374 let issues = run_with_rule(sql, &LayoutLongLines::from_config(&config));
1375 assert!(issues.is_empty());
1376 }
1377
1378 #[test]
1379 fn ignore_comment_lines_skips_jinja_comment_lines() {
1380 let sql =
1381 "SELECT *\n{# this is a very long jinja comment line that should be ignored #}\nFROM t";
1382 let spans = long_line_overflow_spans(sql, 30, true, false, Dialect::Generic);
1383 assert!(spans.is_empty());
1384 }
1385
1386 #[test]
1387 fn ignore_comment_clauses_skips_long_trailing_comment_text() {
1388 let config = LintConfig {
1389 enabled: true,
1390 disabled_rules: vec![],
1391 rule_configs: std::collections::BTreeMap::from([(
1392 "layout.long_lines".to_string(),
1393 serde_json::json!({
1394 "max_line_length": 20,
1395 "ignore_comment_clauses": true
1396 }),
1397 )]),
1398 };
1399 let sql = format!("SELECT 1 -- {}", "x".repeat(120));
1400 let issues = run_with_rule(&sql, &LayoutLongLines::from_config(&config));
1401 assert!(
1402 issues.is_empty(),
1403 "ignore_comment_clauses should suppress trailing-comment overflow: {issues:?}",
1404 );
1405 }
1406
1407 #[test]
1408 fn ignore_comment_clauses_still_flags_long_sql_prefix() {
1409 let config = LintConfig {
1410 enabled: true,
1411 disabled_rules: vec![],
1412 rule_configs: std::collections::BTreeMap::from([(
1413 "LINT_LT_005".to_string(),
1414 serde_json::json!({
1415 "max_line_length": 20,
1416 "ignore_comment_clauses": true
1417 }),
1418 )]),
1419 };
1420 let sql = format!("SELECT {} -- short", "x".repeat(40));
1421 let issues = run_with_rule(&sql, &LayoutLongLines::from_config(&config));
1422 assert_eq!(issues.len(), 1);
1423 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1424 }
1425
1426 #[test]
1427 fn ignore_comment_clauses_skips_sql_comment_clause_lines() {
1428 let config = LintConfig {
1429 enabled: true,
1430 disabled_rules: vec![],
1431 rule_configs: std::collections::BTreeMap::from([(
1432 "layout.long_lines".to_string(),
1433 serde_json::json!({
1434 "max_line_length": 40,
1435 "ignore_comment_clauses": true
1436 }),
1437 )]),
1438 };
1439 let sql = "CREATE TABLE t (\n c1 INT COMMENT 'this is a very very very very very very very very long comment'\n)";
1440 let issues = run_with_rule(sql, &LayoutLongLines::from_config(&config));
1441 assert!(issues.is_empty());
1442 }
1443
1444 #[test]
1445 fn non_positive_max_line_length_disables_rule() {
1446 let config = LintConfig {
1447 enabled: true,
1448 disabled_rules: vec![],
1449 rule_configs: std::collections::BTreeMap::from([(
1450 "layout.long_lines".to_string(),
1451 serde_json::json!({"max_line_length": -1}),
1452 )]),
1453 };
1454 let sql = "SELECT this_is_a_very_long_column_name_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx FROM t";
1455 let issues = run_with_rule(sql, &LayoutLongLines::from_config(&config));
1456 assert!(issues.is_empty());
1457 }
1458
1459 #[test]
1460 fn statementless_fallback_flags_long_jinja_config_line() {
1461 let sql = "{{ config (schema='bronze', materialized='view', sort =['id','number'], dist = 'all', tags =['longlonglonglonglong']) }} \n\nselect 1\n";
1462 let synthetic = parse_sql("SELECT 1").expect("parse");
1463 let rule = LayoutLongLines::default();
1464 let issues = rule.check(
1465 &synthetic[0],
1466 &LintContext {
1467 sql,
1468 statement_range: 0..sql.len(),
1469 statement_index: 0,
1470 },
1471 );
1472 assert!(
1473 !issues.is_empty(),
1474 "expected LT05 to flag long templated config line in statementless mode"
1475 );
1476 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1477 }
1478
1479 #[test]
1480 fn emits_safe_autofix_patch_for_very_long_line() {
1481 let projections = (0..120)
1482 .map(|index| format!("col_{index}"))
1483 .collect::<Vec<_>>()
1484 .join(", ");
1485 let sql = format!("SELECT {projections} FROM t");
1486 let issues = run(&sql);
1487 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1488 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
1489 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1490
1491 let fixed = apply_issue_autofix(&sql, &issues[0]).expect("apply autofix");
1492 let expected = legacy_split_long_line(&sql).expect("legacy split result");
1493 assert_eq!(fixed, expected);
1494 assert_ne!(fixed, sql);
1495 }
1496
1497 #[test]
1498 fn does_not_emit_autofix_when_line_is_below_legacy_split_threshold() {
1499 let sql = format!("SELECT {} FROM t", "x".repeat(120));
1500 let issues = run(&sql);
1501 assert_eq!(issues[0].code, issue_codes::LINT_LT_005);
1502 let fixed = apply_issue_autofix(&sql, &issues[0]).expect("apply autofix");
1503 assert!(fixed.contains('\n'));
1504 assert!(fixed.contains("\nFROM t"));
1505 }
1506
1507 #[test]
1508 fn autofix_moves_inline_comment_before_code_when_overflowing() {
1509 let sql = "SELECT 1 -- Some Comment\n";
1510 let mut edits = long_line_autofix_edits(sql, 18, false);
1511 let fixed = apply_patch_edits(sql, &mut edits);
1512 assert_eq!(fixed, "-- Some Comment\nSELECT 1\n");
1513 }
1514
1515 #[test]
1516 fn autofix_moves_inline_comment_after_code_when_configured() {
1517 let sql = "SELECT 1 -- Some Comment\n";
1518 let mut edits = long_line_autofix_edits(sql, 18, true);
1519 let fixed = apply_patch_edits(sql, &mut edits);
1520 assert_eq!(fixed, "SELECT 1\n-- Some Comment\n");
1521 }
1522
1523 #[test]
1524 fn autofix_moves_comment_and_rebreaks_select_from_line() {
1525 let sql = "SELECT COUNT(*) FROM tbl -- Some Comment\n";
1526 let mut edits = long_line_autofix_edits(sql, 18, false);
1527 let fixed = apply_patch_edits(sql, &mut edits);
1528 assert_eq!(fixed, "-- Some Comment\nSELECT COUNT(*)\nFROM tbl\n");
1529 }
1530
1531 #[test]
1532 fn autofix_does_not_split_comment_only_long_line() {
1533 let sql =
1534 "-- Aggregate page performance events from the last 24 hours into hourly summaries.\n";
1535 let mut edits = long_line_autofix_edits(sql, 80, false);
1536 let fixed = apply_patch_edits(sql, &mut edits);
1537 assert_eq!(fixed, sql);
1538 }
1539
1540 #[test]
1541 fn autofix_moves_mid_query_inline_comment() {
1542 let sql = "select\n my_long_long_line as foo -- with some comment\nfrom foo\n";
1543 let mut edits = long_line_autofix_edits(sql, 40, false);
1544 let fixed = apply_patch_edits(sql, &mut edits);
1545 assert_eq!(
1546 fixed,
1547 "select\n -- with some comment\n my_long_long_line as foo\nfrom foo\n"
1548 );
1549 }
1550
1551 #[test]
1552 fn autofix_rebreaks_window_function_lines() {
1553 let sql = "select *\nfrom t\nqualify a = coalesce(\n first_value(iff(b = 'none', null, a)) ignore nulls over (partition by c order by d desc),\n first_value(a) respect nulls over (partition by c order by d desc)\n)\n";
1554 let mut edits = long_line_autofix_edits(sql, 50, false);
1555 let fixed = apply_patch_edits(sql, &mut edits);
1556 assert_eq!(
1557 fixed,
1558 "select *\nfrom t\nqualify a = coalesce(\n first_value(\n iff(b = 'none', null, a)\n ) ignore nulls\n over (partition by c order by d desc),\n first_value(a) respect nulls\n over (partition by c order by d desc)\n)\n"
1559 );
1560 }
1561
1562 #[test]
1563 fn autofix_rebreaks_long_functions_and_aliases() {
1564 let sql = "SELECT\n my_function(col1 + col2, arg2, arg3) over (partition by col3, col4 order by col5 rows between unbounded preceding and current row) as my_relatively_long_alias,\n my_other_function(col6, col7 + col8, arg4) as my_other_relatively_long_alias,\n my_expression_function(col6, col7 + col8, arg4) = col9 + col10 as another_relatively_long_alias\nFROM my_table\n";
1565 let mut edits = long_line_autofix_edits(sql, 80, false);
1566 let fixed = apply_patch_edits(sql, &mut edits);
1567 assert_eq!(
1568 fixed,
1569 "SELECT\n my_function(col1 + col2, arg2, arg3)\n over (\n partition by col3, col4\n order by col5 rows between unbounded preceding and current row\n )\n as my_relatively_long_alias,\n my_other_function(col6, col7 + col8, arg4)\n as my_other_relatively_long_alias,\n my_expression_function(col6, col7 + col8, arg4)\n = col9 + col10 as another_relatively_long_alias\nFROM my_table\n"
1570 );
1571 }
1572
1573 #[test]
1574 fn autofix_splits_long_expression_alias_line() {
1575 let sql =
1576 " percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms)::int AS p50_ms,\n";
1577 let mut edits = long_line_autofix_edits(sql, 80, false);
1578 let fixed = apply_patch_edits(sql, &mut edits);
1579 assert_eq!(
1580 fixed,
1581 " percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms)::int\n AS p50_ms,\n"
1582 );
1583 }
1584
1585 #[test]
1586 fn autofix_wraps_generic_long_predicate_line() {
1587 let sql = " WHEN uli.usage_start_time >= params.as_of_date - MAKE_INTERVAL(days => params.window_days) AND uli.usage_start_time < params.as_of_date\n";
1588 let mut edits = long_line_autofix_edits(sql, 80, false);
1589 let fixed = apply_patch_edits(sql, &mut edits);
1590
1591 assert_ne!(fixed, sql);
1592 for line in fixed.lines() {
1593 assert!(
1594 line.chars().count() <= 80,
1595 "expected wrapped line <= 80 chars, got {}: {line}",
1596 line.chars().count()
1597 );
1598 }
1599 }
1600
1601 #[test]
1602 fn generic_wrap_keeps_quoted_literals_intact() {
1603 let sql = "SELECT CONCAT('hello world this is a long literal', col1, col2, col3, col4, col5, col6) FROM t\n";
1604 let mut edits = long_line_autofix_edits(sql, 60, false);
1605 let fixed = apply_patch_edits(sql, &mut edits);
1606
1607 assert_ne!(fixed, sql);
1608 assert!(fixed.contains("'hello world this is a long literal'"));
1609 }
1610}