Skip to main content

flowscope_core/linter/rules/
lt_005.rs

1//! LINT_LT_005: Layout long lines.
2//!
3//! SQLFluff LT05 parity (current scope): flag overflow beyond 80 columns.
4
5use 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
334/// Legacy LT005 rewrite parity:
335/// split only extremely long lines (>300 bytes) around the 280-byte target.
336const 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
370/// Generate autofix edits for long lines.
371///
372/// For very long lines (>300 bytes), preserve the legacy splitter behavior.
373/// For shorter overflows, apply a narrow set of patch-based rewrites used by
374/// LT05 fixture parity:
375/// - move inline trailing comments to their own line
376/// - break single-clause overflows (e.g. `SELECT ... FROM ...`)
377/// - break long `... over (...)` and `... as ...` lines around boundaries
378/// - break Snowflake-style `... ignore/respect nulls over (...)` lines
379fn 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    // Handle long expression aliases such as:
450    //   percentile_cont(...)::int AS p95
451    //   CASE ... END AS status
452    //   SUM(...) AS total
453    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        // Keep comma-prefixed comment lines unchanged; rewriting these can
481        // create endless fix cycles in LT05 edge cases.
482        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}