Skip to main content

flowscope_core/linter/rules/
lt_002.rs

1//! LINT_LT_002: Layout indent.
2//!
3//! SQLFluff LT02 parity: flag structural indentation violations (clause
4//! contents not indented under their parent keyword), odd indentation widths,
5//! mixed tab/space indentation, and wrong indent style.
6//!
7//! ## Module layout
8//!
9//! This module is large (~5 000 lines) because the PostgreSQL structural
10//! indentation engine (`postgres_keyword_break_and_indent_edits` and
11//! `postgres_lt02_extra_issue_spans`) shares ~40 helper functions with the
12//! generic indentation detection.  A future submodule split into
13//! `lt_002/{mod, postgres}.rs` is tracked but deferred until the shared
14//! helpers can be cleanly separated.
15//!
16//! See `docs/plans/2026-02-18-lt02-indentation-engine-parity.md` for the
17//! parity design doc.
18
19use crate::linter::config::LintConfig;
20use crate::linter::rule::{LintContext, LintRule};
21use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
22use sqlparser::ast::Statement;
23use sqlparser::keywords::Keyword;
24use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
25use std::collections::{BTreeMap, HashMap, HashSet};
26
27pub struct LayoutIndent {
28    indent_unit: usize,
29    tab_space_size: usize,
30    indent_style: IndentStyle,
31    indented_joins: bool,
32    indented_using_on: bool,
33    indented_on_contents: bool,
34    ignore_comment_lines: bool,
35    indented_ctes: bool,
36    indented_then: bool,
37    indented_then_contents: bool,
38    implicit_indents: ImplicitIndentsMode,
39    ignore_templated_areas: bool,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum IndentStyle {
44    Spaces,
45    Tabs,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum ImplicitIndentsMode {
50    Forbid,
51    Allow,
52    Require,
53}
54
55impl LayoutIndent {
56    pub fn from_config(config: &LintConfig) -> Self {
57        let option_bool = |key: &str| {
58            config
59                .rule_option_bool(issue_codes::LINT_LT_002, key)
60                .or_else(|| config.section_option_bool("indentation", key))
61                .or_else(|| config.section_option_bool("rules", key))
62        };
63        let option_str = |key: &str| {
64            config
65                .rule_option_str(issue_codes::LINT_LT_002, key)
66                .or_else(|| config.section_option_str("indentation", key))
67                .or_else(|| config.section_option_str("rules", key))
68        };
69
70        let tab_space_size = config
71            .rule_option_usize(issue_codes::LINT_LT_002, "tab_space_size")
72            .or_else(|| config.section_option_usize("indentation", "tab_space_size"))
73            .or_else(|| config.section_option_usize("rules", "tab_space_size"))
74            .unwrap_or(4)
75            .max(1);
76
77        let indent_style = match config
78            .rule_option_str(issue_codes::LINT_LT_002, "indent_unit")
79            .or_else(|| config.section_option_str("indentation", "indent_unit"))
80            .or_else(|| config.section_option_str("rules", "indent_unit"))
81        {
82            Some(value) if value.eq_ignore_ascii_case("tab") => IndentStyle::Tabs,
83            _ => IndentStyle::Spaces,
84        };
85
86        let indent_unit_numeric = config
87            .rule_option_usize(issue_codes::LINT_LT_002, "indent_unit")
88            .or_else(|| config.section_option_usize("indentation", "indent_unit"))
89            .or_else(|| config.section_option_usize("rules", "indent_unit"));
90        let indent_unit = match indent_style {
91            IndentStyle::Spaces => indent_unit_numeric.unwrap_or(tab_space_size).max(1),
92            IndentStyle::Tabs => indent_unit_numeric.unwrap_or(tab_space_size).max(1),
93        };
94        let implicit_indents = match option_str("implicit_indents")
95            .unwrap_or("forbid")
96            .to_ascii_lowercase()
97            .as_str()
98        {
99            "allow" => ImplicitIndentsMode::Allow,
100            "require" => ImplicitIndentsMode::Require,
101            _ => ImplicitIndentsMode::Forbid,
102        };
103
104        Self {
105            indent_unit,
106            tab_space_size,
107            indent_style,
108            indented_joins: option_bool("indented_joins").unwrap_or(false),
109            indented_using_on: option_bool("indented_using_on").unwrap_or(true),
110            indented_on_contents: option_bool("indented_on_contents").unwrap_or(true),
111            ignore_comment_lines: option_bool("ignore_comment_lines").unwrap_or(false),
112            indented_ctes: option_bool("indented_ctes").unwrap_or(false),
113            indented_then: option_bool("indented_then").unwrap_or(true),
114            indented_then_contents: option_bool("indented_then_contents").unwrap_or(true),
115            implicit_indents,
116            ignore_templated_areas: config
117                .core_option_bool("ignore_templated_areas")
118                .unwrap_or(true),
119        }
120    }
121}
122
123impl Default for LayoutIndent {
124    fn default() -> Self {
125        Self {
126            indent_unit: 4,
127            tab_space_size: 4,
128            indent_style: IndentStyle::Spaces,
129            indented_joins: false,
130            indented_using_on: true,
131            indented_on_contents: true,
132            ignore_comment_lines: false,
133            indented_ctes: false,
134            indented_then: true,
135            indented_then_contents: true,
136            implicit_indents: ImplicitIndentsMode::Forbid,
137            ignore_templated_areas: true,
138        }
139    }
140}
141
142impl LintRule for LayoutIndent {
143    fn code(&self) -> &'static str {
144        issue_codes::LINT_LT_002
145    }
146
147    fn name(&self) -> &'static str {
148        "Layout indent"
149    }
150
151    fn description(&self) -> &'static str {
152        "Incorrect Indentation."
153    }
154
155    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
156        let statement_sql = ctx.statement_sql();
157        let statement_lines: Vec<&str> = statement_sql.lines().collect();
158        let template_only_lines = template_only_line_flags(&statement_lines);
159        let first_line_template_fragment = first_line_is_template_fragment(ctx);
160        let snapshots = line_indent_snapshots(ctx, self.tab_space_size);
161        let mut has_syntactic_violation = !first_line_template_fragment
162            && !ignore_first_line_indent_for_fragmented_statement(ctx)
163            && first_line_is_indented(ctx);
164        let mut has_violation = has_syntactic_violation;
165
166        // Syntactic checks: odd width, mixed chars, wrong style.
167        for snapshot in &snapshots {
168            if template_only_lines
169                .get(snapshot.line_index)
170                .copied()
171                .unwrap_or(false)
172            {
173                continue;
174            }
175            if let Some(line) = statement_lines.get(snapshot.line_index) {
176                let trimmed = line.trim_start();
177                if self.ignore_comment_lines && is_comment_line(trimmed) {
178                    continue;
179                }
180                if self.ignore_templated_areas && contains_template_marker(trimmed) {
181                    continue;
182                }
183            }
184
185            let indent = snapshot.indent;
186
187            if snapshot.line_index == 0 && indent.width > 0 {
188                if first_line_template_fragment {
189                    continue;
190                }
191                has_syntactic_violation = true;
192                has_violation = true;
193                break;
194            }
195
196            if indent.has_mixed_indent_chars {
197                has_syntactic_violation = true;
198                has_violation = true;
199                break;
200            }
201
202            if matches!(self.indent_style, IndentStyle::Spaces) && indent.tab_count > 0 {
203                has_syntactic_violation = true;
204                has_violation = true;
205                break;
206            }
207
208            if matches!(self.indent_style, IndentStyle::Tabs) && indent.space_count > 0 {
209                has_syntactic_violation = true;
210                has_violation = true;
211                break;
212            }
213
214            if indent.width > 0 && indent.width % self.indent_unit != 0 {
215                has_syntactic_violation = true;
216                has_violation = true;
217                break;
218            }
219        }
220
221        // Structural check: clause contents must be indented under their
222        // parent keyword (e.g., table name under UPDATE, column list under
223        // SELECT, condition under WHERE, etc.).
224        let structural_edits = if !has_violation {
225            let edits = structural_indent_edits(
226                ctx,
227                self.indent_unit,
228                self.tab_space_size,
229                self.indent_style,
230                self,
231            );
232            if !edits.is_empty() {
233                has_violation = true;
234            }
235            edits
236        } else {
237            Vec::new()
238        };
239
240        let detection_only_violation = if !has_violation {
241            detect_additional_indentation_violation(
242                statement_sql,
243                self.indent_unit,
244                self.tab_space_size,
245                self,
246                ctx.dialect(),
247            )
248        } else {
249            false
250        };
251        let tsql_else_if_successive_violation = if !has_violation {
252            ctx.dialect() == Dialect::Mssql
253                && ctx.statement_index == 0
254                && ctx.statement_range.end < ctx.sql.len()
255                && detect_tsql_else_if_successive_violation(ctx.sql, self.tab_space_size)
256        } else {
257            false
258        };
259        let postgres_structural_edits = if ctx.dialect() == Dialect::Postgres {
260            let edits = postgres_keyword_break_and_indent_edits(
261                statement_sql,
262                self.indent_unit,
263                self.tab_space_size,
264                self.indent_style,
265            );
266            if !has_violation && !edits.is_empty() {
267                has_violation = true;
268            }
269            edits
270        } else {
271            Vec::new()
272        };
273        if detection_only_violation {
274            if contains_template_marker(statement_sql)
275                && !has_syntactic_violation
276                && structural_edits.is_empty()
277                && !templated_detection_confident(
278                    statement_sql,
279                    self.indent_unit,
280                    self.tab_space_size,
281                )
282            {
283                // Template-heavy fragments can produce parser-split artifacts
284                // that are not actionable indentation violations.
285            } else {
286                has_violation = true;
287            }
288        }
289        if tsql_else_if_successive_violation {
290            has_violation = true;
291        }
292        if !has_violation {
293            return Vec::new();
294        }
295
296        let mut issue = Issue::info(
297            issue_codes::LINT_LT_002,
298            "Indentation appears inconsistent.",
299        )
300        .with_statement(ctx.statement_index);
301
302        let mut autofix_edits = Vec::new();
303        if has_syntactic_violation || !structural_edits.is_empty() {
304            autofix_edits = indentation_autofix_edits(
305                statement_sql,
306                &snapshots,
307                self.indent_unit,
308                self.tab_space_size,
309                self.indent_style,
310            );
311        }
312        if ctx.dialect() == Dialect::Postgres && !postgres_structural_edits.is_empty() {
313            // PostgreSQL structural passes (WHERE/WHEN/JOIN/SET shaping) are
314            // parity-critical and should win when they target the same line
315            // start as generic indentation normalization.
316            let postgres_starts: HashSet<usize> = postgres_structural_edits
317                .iter()
318                .map(|edit| edit.start)
319                .collect();
320            autofix_edits.retain(|edit| !postgres_starts.contains(&edit.start));
321        }
322        autofix_edits.extend(postgres_structural_edits.clone());
323
324        // Merge structural edits (e.g., adding indentation to content lines
325        // under clause keywords). Only add structural edits for lines not
326        // already covered by syntactic edits.
327        if !structural_edits.is_empty() {
328            let covered_starts: HashSet<usize> = autofix_edits.iter().map(|e| e.start).collect();
329            for edit in structural_edits.iter().cloned() {
330                if !covered_starts.contains(&edit.start) {
331                    autofix_edits.push(edit);
332                }
333            }
334        }
335        autofix_edits.sort_by(|left, right| {
336            (left.start, left.end, left.replacement.as_str()).cmp(&(
337                right.start,
338                right.end,
339                right.replacement.as_str(),
340            ))
341        });
342        autofix_edits.dedup_by(|left, right| {
343            left.start == right.start
344                && left.end == right.end
345                && left.replacement == right.replacement
346        });
347        // Multiple LT02 strategies can emit competing edits at the same byte
348        // start. Collapse these upfront so we do not over-report duplicate
349        // locations and do not feed conflicting same-location candidates into
350        // the fix planner.
351        autofix_edits = collapse_lt02_autofix_edits_by_start(autofix_edits);
352
353        // SQLFluff parity for PostgreSQL-heavy corpora: LT02 reports and
354        // fixes per indentation edit location rather than one statement-level
355        // aggregate.
356        let postgres_issue_edits: Vec<Lt02AutofixEdit> = if ctx.dialect() == Dialect::Postgres {
357            autofix_edits.clone()
358        } else {
359            Vec::new()
360        };
361        let postgres_line_infos = if ctx.dialect() == Dialect::Postgres {
362            statement_line_infos(statement_sql)
363        } else {
364            Vec::new()
365        };
366        let covered_starts: HashSet<usize> =
367            postgres_issue_edits.iter().map(|edit| edit.start).collect();
368        let covered_starts_by_line: HashMap<usize, Vec<usize>> =
369            if ctx.dialect() == Dialect::Postgres {
370                postgres_issue_edits
371                    .iter()
372                    .filter_map(|edit| {
373                        statement_line_index_for_offset(&postgres_line_infos, edit.start)
374                            .map(|line_idx| (line_idx, edit.start))
375                    })
376                    .fold(HashMap::new(), |mut acc, (line_idx, start)| {
377                        acc.entry(line_idx).or_default().push(start);
378                        acc
379                    })
380            } else {
381                HashMap::new()
382            };
383        let postgres_extra_issue_spans: Vec<(usize, usize)> = if ctx.dialect() == Dialect::Postgres
384        {
385            postgres_lt02_extra_issue_spans(statement_sql, self.indent_unit, self.tab_space_size)
386                .into_iter()
387                .filter(|(start, _end)| {
388                    if covered_starts.contains(start) {
389                        return false;
390                    }
391                    statement_line_index_for_offset(&postgres_line_infos, *start)
392                        .and_then(|line_idx| covered_starts_by_line.get(&line_idx))
393                        .is_none_or(|line_starts| {
394                            !line_starts
395                                .iter()
396                                .any(|line_start| line_start.abs_diff(*start) <= 1)
397                        })
398                })
399                .collect()
400        } else {
401            Vec::new()
402        };
403        if ctx.dialect() == Dialect::Postgres
404            && detection_only_violation
405            && !has_syntactic_violation
406            && structural_edits.is_empty()
407            && postgres_issue_edits.is_empty()
408            && postgres_extra_issue_spans.is_empty()
409        {
410            return Vec::new();
411        }
412        if !postgres_issue_edits.is_empty() || !postgres_extra_issue_spans.is_empty() {
413            let mut issues: Vec<Issue> = Vec::new();
414            let mut patches_by_start: BTreeMap<usize, IssuePatchEdit> = BTreeMap::new();
415            for edit in postgres_issue_edits {
416                let patch = IssuePatchEdit::new(
417                    ctx.span_from_statement_offset(edit.start, edit.end),
418                    edit.replacement,
419                );
420                match patches_by_start.entry(patch.span.start) {
421                    std::collections::btree_map::Entry::Vacant(entry) => {
422                        entry.insert(patch);
423                    }
424                    std::collections::btree_map::Entry::Occupied(mut entry) => {
425                        if should_prefer_lt02_patch(&patch, entry.get()) {
426                            entry.insert(patch);
427                        }
428                    }
429                }
430            }
431
432            let mut issue_starts = HashSet::new();
433            for patch in patches_by_start.into_values() {
434                let span = Span::new(patch.span.start, patch.span.end);
435                issue_starts.insert(span.start);
436                issues.push(
437                    Issue::info(
438                        issue_codes::LINT_LT_002,
439                        "Indentation appears inconsistent.",
440                    )
441                    .with_statement(ctx.statement_index)
442                    .with_span(span)
443                    .with_autofix_edits(IssueAutofixApplicability::Safe, vec![patch]),
444                );
445            }
446
447            for (start, end) in postgres_extra_issue_spans {
448                if !issue_starts.insert(start) {
449                    continue;
450                }
451                let span = ctx.span_from_statement_offset(start, end);
452                issues.push(
453                    Issue::info(
454                        issue_codes::LINT_LT_002,
455                        "Indentation appears inconsistent.",
456                    )
457                    .with_statement(ctx.statement_index)
458                    .with_span(span),
459                );
460            }
461
462            return issues;
463        }
464
465        let autofix_edits: Vec<_> = autofix_edits
466            .into_iter()
467            .map(|edit| {
468                IssuePatchEdit::new(
469                    ctx.span_from_statement_offset(edit.start, edit.end),
470                    edit.replacement,
471                )
472            })
473            .collect();
474
475        if !autofix_edits.is_empty() {
476            issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
477        }
478
479        vec![issue]
480    }
481}
482
483// ---------------------------------------------------------------------------
484// PostgreSQL-specific structural indentation
485//
486// The two entry points below (`postgres_lt02_extra_issue_spans` and
487// `postgres_keyword_break_and_indent_edits`) implement keyword-break and
488// clause-shaping rules for Postgres SQL.  They share ~40 helpers with the
489// generic indentation engine defined further down in this file.
490// ---------------------------------------------------------------------------
491
492fn postgres_lt02_extra_issue_spans(
493    statement_sql: &str,
494    indent_unit: usize,
495    tab_space_size: usize,
496) -> Vec<(usize, usize)> {
497    let indent_unit = indent_unit.max(1);
498    let lines: Vec<&str> = statement_sql.lines().collect();
499    if lines.is_empty() {
500        return Vec::new();
501    }
502
503    let line_infos = statement_line_infos(statement_sql);
504    if line_infos.is_empty() {
505        return Vec::new();
506    }
507
508    let mut scans: Vec<ScanLine<'_>> = lines
509        .iter()
510        .map(|line| {
511            let trimmed = line.trim_start();
512            let is_blank = trimmed.trim().is_empty();
513            let words = if is_blank {
514                Vec::new()
515            } else {
516                split_upper_words(trimmed)
517            };
518            ScanLine {
519                trimmed,
520                indent: leading_indent_from_prefix(line, tab_space_size).width,
521                words,
522                is_blank,
523                is_comment_only: is_comment_line(trimmed),
524                inline_case_offset: inline_case_keyword_offset(trimmed),
525                prev_significant: None,
526                next_significant: None,
527            }
528        })
529        .collect();
530    link_significant_lines(&mut scans);
531    let case_anchor_cache = build_case_anchor_cache(&scans);
532
533    let mut issue_spans: Vec<(usize, usize)> = Vec::new();
534    let mut set_block_expected_indent: Option<usize> = None;
535    let sql_len = statement_sql.len();
536
537    for idx in 0..scans.len() {
538        let line = &scans[idx];
539        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
540            continue;
541        }
542
543        if let Some(expected_set_indent) = set_block_expected_indent {
544            if starts_with_assignment(line.trimmed) {
545                if line.indent != expected_set_indent {
546                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
547                }
548            } else {
549                let first = line.words.first().map(String::as_str);
550                if !matches!(first, Some("SET"))
551                    && (is_clause_boundary(first, line.trimmed) || line.trimmed.starts_with(';'))
552                {
553                    set_block_expected_indent = None;
554                }
555            }
556        }
557
558        let first = line.words.first().map(String::as_str);
559        let second = line.words.get(1).map(String::as_str);
560        let upper = line.trimmed.to_ascii_uppercase();
561
562        if matches!(first, Some("WHERE")) && line.words.len() > 1 {
563            if let Some(next_idx) = next_significant_line(&scans, idx) {
564                let next_line = &scans[next_idx];
565                let next_first = next_line.words.first().map(String::as_str);
566                let needs_break = matches!(next_first, Some("AND" | "OR"))
567                    || starts_with_operator_continuation(next_line.trimmed);
568                if needs_break {
569                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "WHERE") {
570                        push_trimmed_offset_issue_span(
571                            &mut issue_spans,
572                            &line_infos,
573                            idx,
574                            rel,
575                            sql_len,
576                        );
577                    }
578                }
579            }
580        }
581
582        if matches!(first, Some("WHEN")) && line.words.len() > 1 {
583            if let Some(next_idx) = next_significant_line(&scans, idx) {
584                let next_line = &scans[next_idx];
585                let next_first = scans[next_idx].words.first().map(String::as_str);
586                if matches!(next_first, Some("AND" | "OR"))
587                    || starts_with_operator_continuation(next_line.trimmed)
588                {
589                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "WHEN") {
590                        push_trimmed_offset_issue_span(
591                            &mut issue_spans,
592                            &line_infos,
593                            idx,
594                            rel,
595                            sql_len,
596                        );
597                    }
598                }
599                if matches!(next_first, Some("AND" | "OR")) {
600                    let expected_indent = line.indent + indent_unit;
601                    if scans[next_idx].indent != expected_indent {
602                        push_line_start_issue_span(
603                            &mut issue_spans,
604                            &line_infos,
605                            next_idx,
606                            sql_len,
607                        );
608                    }
609                }
610            }
611        }
612
613        if matches!(first, Some("SET")) && line.words.len() > 1 {
614            let mut has_assignment_continuation = false;
615            if let Some(next_idx) = next_significant_line(&scans, idx) {
616                if starts_with_assignment(scans[next_idx].trimmed) {
617                    has_assignment_continuation = true;
618                }
619            }
620
621            let mut expected_set_indent = line.indent;
622            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
623                let prev_upper = scans[prev_idx].trimmed.to_ascii_uppercase();
624                if prev_upper.contains(" DO UPDATE") || prev_upper.starts_with("ON CONFLICT") {
625                    expected_set_indent =
626                        rounded_indent_width(scans[prev_idx].indent, indent_unit) + indent_unit;
627                }
628            }
629
630            let suppress_set_content_span = line.indent != expected_set_indent
631                || line.trimmed.to_ascii_uppercase().starts_with("SET STATUS ");
632            if has_assignment_continuation && !suppress_set_content_span {
633                if let Some(rel) = content_offset_after_keyword(line.trimmed, "SET") {
634                    push_trimmed_offset_issue_span(
635                        &mut issue_spans,
636                        &line_infos,
637                        idx,
638                        rel,
639                        sql_len,
640                    );
641                }
642            }
643
644            if line.indent != expected_set_indent {
645                push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
646            }
647            set_block_expected_indent = Some(expected_set_indent + indent_unit);
648        }
649
650        if is_join_clause(first, second)
651            && should_break_inline_join_on(&scans, idx, first, second, &upper)
652        {
653            if let Some(on_offset) = inline_join_on_offset(line.trimmed) {
654                push_trimmed_offset_issue_span(
655                    &mut issue_spans,
656                    &line_infos,
657                    idx,
658                    on_offset,
659                    sql_len,
660                );
661            }
662            push_join_on_block_indent_spans(
663                &mut issue_spans,
664                &line_infos,
665                &scans,
666                idx,
667                indent_unit,
668                sql_len,
669            );
670        }
671
672        if matches!(first, Some("SELECT")) {
673            if let Some(next_idx) = next_significant_line(&scans, idx) {
674                let next_line = &scans[next_idx];
675                let next_first = next_line.words.first().map(String::as_str);
676                let has_select_continuation = !is_clause_boundary(next_first, next_line.trimmed);
677
678                if line.trimmed.contains(',')
679                    && !upper.starts_with("SELECT DISTINCT ON")
680                    && has_select_continuation
681                {
682                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
683                        push_trimmed_offset_issue_span(
684                            &mut issue_spans,
685                            &line_infos,
686                            idx,
687                            rel,
688                            sql_len,
689                        );
690                    }
691                }
692
693                if upper.starts_with("SELECT *")
694                    && !upper.contains(" FROM ")
695                    && has_select_continuation
696                {
697                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
698                        push_trimmed_offset_issue_span(
699                            &mut issue_spans,
700                            &line_infos,
701                            idx,
702                            rel,
703                            sql_len,
704                        );
705                    }
706                }
707            }
708
709            if line.trimmed.contains(',') {
710                if let Some(prev_idx) = previous_significant_line(&scans, idx) {
711                    let prev_first = scans[prev_idx].words.first().map(String::as_str);
712                    if matches!(prev_first, Some("UNION" | "INTERSECT" | "EXCEPT")) {
713                        if let Some(rel) = content_offset_after_keyword(line.trimmed, "SELECT") {
714                            push_trimmed_offset_issue_span(
715                                &mut issue_spans,
716                                &line_infos,
717                                idx,
718                                rel,
719                                sql_len,
720                            );
721                        }
722                    }
723                }
724            }
725        }
726
727        if matches!(first, Some("HAVING")) && line.words.len() > 1 {
728            if let Some(next_idx) = next_significant_line(&scans, idx) {
729                let next_first = scans[next_idx].words.first().map(String::as_str);
730                if matches!(next_first, Some("AND" | "OR")) {
731                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "HAVING") {
732                        push_trimmed_offset_issue_span(
733                            &mut issue_spans,
734                            &line_infos,
735                            idx,
736                            rel,
737                            sql_len,
738                        );
739                    }
740                }
741            }
742        }
743
744        if line.trimmed.contains(',') {
745            if let Some(partition_idx) = upper.find("PARTITION BY") {
746                let partition_tail = &upper[partition_idx..];
747                if !partition_tail.contains(')') {
748                    if let Some(next_idx) = next_significant_line(&scans, idx) {
749                        let next_first = scans[next_idx].words.first().map(String::as_str);
750                        if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
751                            let mut rel = partition_idx + "PARTITION BY".len();
752                            while let Some(ch) = line.trimmed[rel..].chars().next() {
753                                if ch.is_whitespace() {
754                                    rel += ch.len_utf8();
755                                } else {
756                                    break;
757                                }
758                            }
759                            if rel < line.trimmed.len() {
760                                push_trimmed_offset_issue_span(
761                                    &mut issue_spans,
762                                    &line_infos,
763                                    idx,
764                                    rel,
765                                    sql_len,
766                                );
767                            }
768                        }
769                    }
770                }
771            }
772        }
773
774        if let Some(select_idx) = upper.find("(SELECT ") {
775            if let Some(next_idx) = next_significant_line(&scans, idx) {
776                let next_first = scans[next_idx].words.first().map(String::as_str);
777                if matches!(next_first, Some("WHERE" | "LIMIT")) {
778                    push_trimmed_offset_issue_span(
779                        &mut issue_spans,
780                        &line_infos,
781                        idx,
782                        select_idx + 1,
783                        sql_len,
784                    );
785                }
786            }
787        }
788
789        if matches!(first, Some("CASE")) {
790            let upper = line.trimmed.to_ascii_uppercase();
791            if let Some(rel) = upper.find(" WHEN ").map(|offset| offset + 1) {
792                push_trimmed_offset_issue_span(&mut issue_spans, &line_infos, idx, rel, sql_len);
793            }
794        }
795
796        if !matches!(first, Some("CASE")) {
797            if let Some(case_rel) = line.inline_case_offset {
798                if let Some(next_idx) = next_significant_line(&scans, idx) {
799                    let next_first = scans[next_idx].words.first().map(String::as_str);
800                    if matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END")) {
801                        push_trimmed_offset_issue_span(
802                            &mut issue_spans,
803                            &line_infos,
804                            idx,
805                            case_rel,
806                            sql_len,
807                        );
808                    }
809                }
810            }
811        }
812
813        if matches!(first, Some("ON")) && line.words.len() > 1 {
814            if let Some(next_idx) = next_significant_line(&scans, idx) {
815                let next_first = scans[next_idx].words.first().map(String::as_str);
816                if matches!(next_first, Some("AND" | "OR")) {
817                    if let Some(rel) = content_offset_after_keyword(line.trimmed, "ON") {
818                        push_trimmed_offset_issue_span(
819                            &mut issue_spans,
820                            &line_infos,
821                            idx,
822                            rel,
823                            sql_len,
824                        );
825                    }
826                }
827            }
828        }
829
830        if matches!(first, Some("AND" | "OR")) {
831            if let Some(anchor_idx) = find_andor_anchor(&scans, idx) {
832                let base_indent =
833                    rounded_indent_width(scans[anchor_idx].indent, indent_unit) + indent_unit;
834                let depth = paren_depth_between(&scans, anchor_idx, idx);
835                let anchor_is_when = scans[anchor_idx]
836                    .words
837                    .first()
838                    .is_some_and(|word| word == "WHEN");
839                if depth > 0 || anchor_is_when {
840                    let anchor_has_open_paren = scans[anchor_idx].trimmed.trim_end().ends_with('(');
841                    let adjusted_depth = if anchor_has_open_paren {
842                        depth.saturating_sub(1)
843                    } else {
844                        depth
845                    };
846                    let expected_indent = base_indent + adjusted_depth * indent_unit;
847                    if line.indent != expected_indent {
848                        push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
849                    }
850                }
851            }
852        }
853
854        if matches!(first, Some("WHEN")) {
855            if let Some(expected_indent) =
856                expected_case_when_indent(&scans, &case_anchor_cache, idx, indent_unit)
857            {
858                if line.indent != expected_indent {
859                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
860                }
861            }
862        }
863
864        if matches!(first, Some("THEN")) {
865            if let Some(expected_indent) =
866                expected_then_indent(&scans, &case_anchor_cache, idx, indent_unit)
867            {
868                if line.indent != expected_indent {
869                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
870                }
871            }
872        }
873
874        if matches!(first, Some("WHEN" | "THEN")) && line.words.len() > 1 {
875            if let Some(next_idx) = next_significant_line(&scans, idx) {
876                let next_first = scans[next_idx].words.first().map(String::as_str);
877                let has_trailing_continuation = line.trimmed.trim_end().ends_with('/')
878                    || line.trimmed.trim_end().ends_with(',');
879                if has_trailing_continuation
880                    && !is_clause_boundary(next_first, scans[next_idx].trimmed)
881                {
882                    let keyword = first.unwrap_or_default();
883                    if let Some(rel) = content_offset_after_keyword(line.trimmed, keyword) {
884                        push_trimmed_offset_issue_span(
885                            &mut issue_spans,
886                            &line_infos,
887                            idx,
888                            rel,
889                            sql_len,
890                        );
891                    }
892                }
893            }
894        }
895
896        if matches!(first, Some("ELSE")) {
897            if let Some(expected_indent) =
898                expected_else_indent(&scans, &case_anchor_cache, idx, indent_unit)
899            {
900                if line.indent != expected_indent {
901                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
902                }
903            }
904        }
905
906        if matches!(first, Some("END")) {
907            if let Some(expected_indent) =
908                expected_end_indent(&scans, &case_anchor_cache, idx, indent_unit)
909            {
910                if line.indent != expected_indent {
911                    push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
912                }
913            }
914        }
915
916        if let Some(arg_rel) = make_interval_inline_arg_offset(line.trimmed) {
917            if let Some(next_idx) = next_significant_line(&scans, idx) {
918                let next_line = &scans[next_idx];
919                if next_line.trimmed.starts_with("=>") {
920                    push_trimmed_offset_issue_span(
921                        &mut issue_spans,
922                        &line_infos,
923                        idx,
924                        arg_rel,
925                        sql_len,
926                    );
927
928                    let expected_next_indent = line.indent + indent_unit * 2;
929                    if next_line.indent != expected_next_indent {
930                        push_line_start_issue_span(
931                            &mut issue_spans,
932                            &line_infos,
933                            next_idx,
934                            sql_len,
935                        );
936                    }
937
938                    if let Some(close_rel) = inline_close_paren_offset(next_line.trimmed) {
939                        push_trimmed_offset_issue_span(
940                            &mut issue_spans,
941                            &line_infos,
942                            next_idx,
943                            close_rel,
944                            sql_len,
945                        );
946                    }
947                }
948            }
949        }
950
951        if line.trimmed.starts_with(')') {
952            let tail = line.trimmed[1..].trim_start();
953            let simple_close_tail =
954                if tail.is_empty() || tail.starts_with(';') || tail.starts_with("--") {
955                    true
956                } else if let Some(after_comma) = tail.strip_prefix(',') {
957                    let after_comma = after_comma.trim_start();
958                    after_comma.is_empty() || after_comma.starts_with("--")
959                } else {
960                    false
961                };
962            if !simple_close_tail {
963                continue;
964            }
965
966            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
967                let prev_first = scans[prev_idx].words.first().map(String::as_str);
968                if matches!(prev_first, Some("AND" | "OR")) {
969                    if let Some(anchor_idx) = find_andor_anchor(&scans, idx) {
970                        let base_indent =
971                            rounded_indent_width(scans[anchor_idx].indent, indent_unit)
972                                + indent_unit;
973                        let depth = paren_depth_between(&scans, anchor_idx, idx);
974                        if depth == 0 {
975                            continue;
976                        }
977                        let anchor_has_open_paren =
978                            scans[anchor_idx].trimmed.trim_end().ends_with('(');
979                        let expected_indent = if anchor_has_open_paren {
980                            if depth == 1 {
981                                base_indent.saturating_sub(indent_unit)
982                            } else {
983                                base_indent + depth.saturating_sub(2) * indent_unit
984                            }
985                        } else {
986                            base_indent + depth.saturating_sub(1) * indent_unit
987                        };
988                        if line.indent != expected_indent {
989                            push_line_start_issue_span(&mut issue_spans, &line_infos, idx, sql_len);
990                        }
991                    }
992                }
993            }
994        }
995    }
996
997    issue_spans.sort_unstable();
998    issue_spans.dedup();
999    issue_spans
1000}
1001
1002fn postgres_keyword_break_and_indent_edits(
1003    statement_sql: &str,
1004    indent_unit: usize,
1005    tab_space_size: usize,
1006    indent_style: IndentStyle,
1007) -> Vec<Lt02AutofixEdit> {
1008    let indent_unit = indent_unit.max(1);
1009    let lines: Vec<&str> = statement_sql.lines().collect();
1010    if lines.is_empty() {
1011        return Vec::new();
1012    }
1013
1014    let line_infos = statement_line_infos(statement_sql);
1015    if line_infos.is_empty() {
1016        return Vec::new();
1017    }
1018
1019    let mut scans: Vec<ScanLine<'_>> = lines
1020        .iter()
1021        .map(|line| {
1022            let trimmed = line.trim_start();
1023            let is_blank = trimmed.trim().is_empty();
1024            let words = if is_blank {
1025                Vec::new()
1026            } else {
1027                split_upper_words(trimmed)
1028            };
1029            ScanLine {
1030                trimmed,
1031                indent: leading_indent_from_prefix(line, tab_space_size).width,
1032                words,
1033                is_blank,
1034                is_comment_only: is_comment_line(trimmed),
1035                inline_case_offset: inline_case_keyword_offset(trimmed),
1036                prev_significant: None,
1037                next_significant: None,
1038            }
1039        })
1040        .collect();
1041    link_significant_lines(&mut scans);
1042    let case_anchor_cache = build_case_anchor_cache(&scans);
1043
1044    let mut edits = Vec::new();
1045
1046    for idx in 0..scans.len() {
1047        let line = &scans[idx];
1048        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
1049            continue;
1050        }
1051
1052        let first = line.words.first().map(String::as_str);
1053        let second = line.words.get(1).map(String::as_str);
1054        let upper = line.trimmed.to_ascii_uppercase();
1055
1056        if matches!(first, Some("CASE")) {
1057            if let Some(when_rel) = upper.find(" WHEN ").map(|offset| offset + 1) {
1058                let mut emitted_multiline_case_when_edit = false;
1059                if let Some(next_idx) = next_significant_line(&scans, idx) {
1060                    let next_first = scans[next_idx].words.first().map(String::as_str);
1061                    let continues_case_condition =
1062                        !is_clause_boundary(next_first, scans[next_idx].trimmed)
1063                            && !matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END"));
1064                    if continues_case_condition {
1065                        let when_tail = &line.trimmed[when_rel..];
1066                        if let Some(when_content_rel) =
1067                            content_offset_after_keyword(when_tail, "WHEN")
1068                        {
1069                            let content_rel = when_rel + when_content_rel;
1070                            push_case_when_multiline_break_edit(
1071                                &mut edits,
1072                                statement_sql,
1073                                &line_infos,
1074                                idx,
1075                                "CASE".len(),
1076                                content_rel,
1077                                line.indent + indent_unit,
1078                                line.indent + indent_unit * 2,
1079                                indent_unit,
1080                                tab_space_size,
1081                                indent_style,
1082                            );
1083                            emitted_multiline_case_when_edit = true;
1084                        }
1085                    }
1086                }
1087                if !emitted_multiline_case_when_edit {
1088                    push_trimmed_offset_break_edit(
1089                        &mut edits,
1090                        statement_sql,
1091                        &line_infos,
1092                        idx,
1093                        "CASE".len(),
1094                        when_rel,
1095                        line.indent + indent_unit,
1096                        indent_unit,
1097                        tab_space_size,
1098                        indent_style,
1099                    );
1100                }
1101            }
1102        }
1103
1104        if !matches!(first, Some("CASE")) {
1105            if let Some(case_rel) = line.inline_case_offset {
1106                if let Some(next_idx) = next_significant_line(&scans, idx) {
1107                    let next_first = scans[next_idx].words.first().map(String::as_str);
1108                    if matches!(next_first, Some("WHEN" | "THEN" | "ELSE" | "END")) {
1109                        push_inline_case_break_edit(
1110                            &mut edits,
1111                            statement_sql,
1112                            &line_infos,
1113                            idx,
1114                            case_rel,
1115                            line.indent + indent_unit,
1116                            indent_unit,
1117                            tab_space_size,
1118                            indent_style,
1119                        );
1120                    }
1121                }
1122            }
1123        }
1124
1125        if matches!(first, Some("WHERE")) {
1126            if let Some(next_idx) = next_significant_line(&scans, idx) {
1127                let next_line = &scans[next_idx];
1128                let next_first = next_line.words.first().map(String::as_str);
1129                let needs_break = matches!(next_first, Some("AND" | "OR"))
1130                    || starts_with_operator_continuation(next_line.trimmed);
1131                let where_parent_indent =
1132                    previous_significant_line(&scans, idx).and_then(|prev_idx| {
1133                        let prev_first = scans[prev_idx].words.first().map(String::as_str);
1134                        let prev_second = scans[prev_idx].words.get(1).map(String::as_str);
1135                        (is_join_clause(prev_first, prev_second)
1136                            || matches!(prev_first, Some("FROM" | "WHERE" | "HAVING" | "ON")))
1137                        .then_some(scans[prev_idx].indent)
1138                    });
1139                let where_clause_indent = where_parent_indent
1140                    .unwrap_or_else(|| ceil_indent_width(line.indent, indent_unit));
1141                if let Some(parent_indent) = where_parent_indent {
1142                    push_leading_indent_edit(
1143                        &mut edits,
1144                        statement_sql,
1145                        &line_infos,
1146                        idx,
1147                        line.indent,
1148                        parent_indent,
1149                        indent_unit,
1150                        tab_space_size,
1151                        indent_style,
1152                    );
1153                }
1154                let where_content_indent = where_clause_indent + indent_unit;
1155                if line.words.len() > 1 && needs_break {
1156                    push_keyword_break_edit(
1157                        &mut edits,
1158                        statement_sql,
1159                        &line_infos,
1160                        &scans,
1161                        idx,
1162                        "WHERE",
1163                        where_content_indent,
1164                        indent_unit,
1165                        tab_space_size,
1166                        indent_style,
1167                    );
1168                    push_on_condition_block_indent_edits(
1169                        &mut edits,
1170                        statement_sql,
1171                        &line_infos,
1172                        &scans,
1173                        next_idx,
1174                        where_content_indent,
1175                        indent_unit,
1176                        tab_space_size,
1177                        indent_style,
1178                    );
1179                }
1180
1181                if line.words.len() == 1 {
1182                    let starts_condition_block = !is_clause_boundary(next_first, next_line.trimmed)
1183                        || matches!(next_first, Some("AND" | "OR" | "NOT" | "EXISTS"))
1184                        || starts_with_operator_continuation(next_line.trimmed);
1185                    if starts_condition_block {
1186                        push_on_condition_block_indent_edits(
1187                            &mut edits,
1188                            statement_sql,
1189                            &line_infos,
1190                            &scans,
1191                            next_idx,
1192                            where_content_indent,
1193                            indent_unit,
1194                            tab_space_size,
1195                            indent_style,
1196                        );
1197                    }
1198                }
1199            }
1200        }
1201
1202        if matches!(first, Some("HAVING")) {
1203            if let Some(next_idx) = next_significant_line(&scans, idx) {
1204                let next_line = &scans[next_idx];
1205                let next_first = next_line.words.first().map(String::as_str);
1206                let having_content_indent = line.indent + indent_unit;
1207
1208                if line.words.len() > 1 && matches!(next_first, Some("AND" | "OR")) {
1209                    push_keyword_break_edit(
1210                        &mut edits,
1211                        statement_sql,
1212                        &line_infos,
1213                        &scans,
1214                        idx,
1215                        "HAVING",
1216                        having_content_indent,
1217                        indent_unit,
1218                        tab_space_size,
1219                        indent_style,
1220                    );
1221                    push_on_condition_block_indent_edits(
1222                        &mut edits,
1223                        statement_sql,
1224                        &line_infos,
1225                        &scans,
1226                        next_idx,
1227                        having_content_indent,
1228                        indent_unit,
1229                        tab_space_size,
1230                        indent_style,
1231                    );
1232                }
1233
1234                if line.words.len() == 1 {
1235                    let starts_condition_block = !is_clause_boundary(next_first, next_line.trimmed)
1236                        || matches!(next_first, Some("AND" | "OR" | "NOT" | "EXISTS"))
1237                        || starts_with_operator_continuation(next_line.trimmed);
1238                    if starts_condition_block {
1239                        push_on_condition_block_indent_edits(
1240                            &mut edits,
1241                            statement_sql,
1242                            &line_infos,
1243                            &scans,
1244                            next_idx,
1245                            having_content_indent,
1246                            indent_unit,
1247                            tab_space_size,
1248                            indent_style,
1249                        );
1250                    }
1251                }
1252            }
1253        }
1254
1255        if matches!(first, Some("WHEN")) && line.words.len() > 1 {
1256            if let Some(next_idx) = next_significant_line(&scans, idx) {
1257                let next_line = &scans[next_idx];
1258                let next_first = next_line.words.first().map(String::as_str);
1259                let needs_break = matches!(next_first, Some("AND" | "OR"))
1260                    || starts_with_operator_continuation(next_line.trimmed);
1261                if needs_break {
1262                    push_keyword_break_edit(
1263                        &mut edits,
1264                        statement_sql,
1265                        &line_infos,
1266                        &scans,
1267                        idx,
1268                        "WHEN",
1269                        line.indent + indent_unit,
1270                        indent_unit,
1271                        tab_space_size,
1272                        indent_style,
1273                    );
1274                }
1275                if matches!(next_first, Some("AND" | "OR")) {
1276                    push_on_condition_block_indent_edits(
1277                        &mut edits,
1278                        statement_sql,
1279                        &line_infos,
1280                        &scans,
1281                        next_idx,
1282                        line.indent + indent_unit,
1283                        indent_unit,
1284                        tab_space_size,
1285                        indent_style,
1286                    );
1287                }
1288            }
1289        }
1290
1291        if matches!(first, Some("WHEN" | "THEN")) && line.words.len() > 1 {
1292            if let Some(next_idx) = next_significant_line(&scans, idx) {
1293                let next_first = scans[next_idx].words.first().map(String::as_str);
1294                let has_trailing_continuation = line.trimmed.trim_end().ends_with('/')
1295                    || line.trimmed.trim_end().ends_with(',');
1296                if has_trailing_continuation
1297                    && !is_clause_boundary(next_first, scans[next_idx].trimmed)
1298                {
1299                    let keyword = first.unwrap_or_default();
1300                    push_keyword_break_edit(
1301                        &mut edits,
1302                        statement_sql,
1303                        &line_infos,
1304                        &scans,
1305                        idx,
1306                        keyword,
1307                        line.indent + indent_unit,
1308                        indent_unit,
1309                        tab_space_size,
1310                        indent_style,
1311                    );
1312                }
1313            }
1314        }
1315
1316        if matches!(first, Some("WHEN")) {
1317            if let Some(expected_indent) =
1318                expected_case_when_indent(&scans, &case_anchor_cache, idx, indent_unit)
1319            {
1320                push_leading_indent_edit(
1321                    &mut edits,
1322                    statement_sql,
1323                    &line_infos,
1324                    idx,
1325                    line.indent,
1326                    expected_indent,
1327                    indent_unit,
1328                    tab_space_size,
1329                    indent_style,
1330                );
1331            }
1332        }
1333
1334        if matches!(first, Some("ON")) && !matches!(second, Some("CONFLICT")) {
1335            let on_content_indent = rounded_indent_width(line.indent, indent_unit) + indent_unit;
1336            if let Some(parent_indent) = previous_line_indent_matching(&scans, idx, |f, s| {
1337                is_join_clause(f, s) || matches!(f, Some("USING"))
1338            }) {
1339                push_leading_indent_edit(
1340                    &mut edits,
1341                    statement_sql,
1342                    &line_infos,
1343                    idx,
1344                    line.indent,
1345                    parent_indent + indent_unit,
1346                    indent_unit,
1347                    tab_space_size,
1348                    indent_style,
1349                );
1350            }
1351            if line.words.len() > 1 {
1352                if let Some(next_idx) = next_significant_line(&scans, idx) {
1353                    let next_first = scans[next_idx].words.first().map(String::as_str);
1354                    if matches!(next_first, Some("AND" | "OR")) {
1355                        push_keyword_break_edit(
1356                            &mut edits,
1357                            statement_sql,
1358                            &line_infos,
1359                            &scans,
1360                            idx,
1361                            "ON",
1362                            on_content_indent,
1363                            indent_unit,
1364                            tab_space_size,
1365                            indent_style,
1366                        );
1367                        push_on_condition_block_indent_edits(
1368                            &mut edits,
1369                            statement_sql,
1370                            &line_infos,
1371                            &scans,
1372                            next_idx,
1373                            on_content_indent,
1374                            indent_unit,
1375                            tab_space_size,
1376                            indent_style,
1377                        );
1378                    }
1379                }
1380            }
1381            if line.words.len() == 1 {
1382                if let Some(next_idx) = next_significant_line(&scans, idx) {
1383                    push_on_condition_block_indent_edits(
1384                        &mut edits,
1385                        statement_sql,
1386                        &line_infos,
1387                        &scans,
1388                        next_idx,
1389                        on_content_indent,
1390                        indent_unit,
1391                        tab_space_size,
1392                        indent_style,
1393                    );
1394                }
1395            }
1396        }
1397
1398        if matches!(first, Some("SET")) {
1399            let mut expected_set_indent = line.indent;
1400            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
1401                let prev_upper = scans[prev_idx].trimmed.to_ascii_uppercase();
1402                if prev_upper.contains(" DO UPDATE") || prev_upper.starts_with("ON CONFLICT") {
1403                    expected_set_indent =
1404                        rounded_indent_width(scans[prev_idx].indent, indent_unit) + indent_unit;
1405                }
1406            }
1407
1408            push_leading_indent_edit(
1409                &mut edits,
1410                statement_sql,
1411                &line_infos,
1412                idx,
1413                line.indent,
1414                expected_set_indent,
1415                indent_unit,
1416                tab_space_size,
1417                indent_style,
1418            );
1419
1420            let assignment_indent = expected_set_indent + indent_unit;
1421            if line.words.len() > 1 {
1422                push_keyword_break_edit(
1423                    &mut edits,
1424                    statement_sql,
1425                    &line_infos,
1426                    &scans,
1427                    idx,
1428                    "SET",
1429                    assignment_indent,
1430                    indent_unit,
1431                    tab_space_size,
1432                    indent_style,
1433                );
1434            }
1435
1436            if let Some(next_idx) = next_significant_line(&scans, idx) {
1437                if starts_with_assignment(scans[next_idx].trimmed)
1438                    || scans[idx].trimmed.trim_end().ends_with(',')
1439                {
1440                    push_assignment_block_indent_edits(
1441                        &mut edits,
1442                        statement_sql,
1443                        &line_infos,
1444                        &scans,
1445                        next_idx,
1446                        assignment_indent,
1447                        indent_unit,
1448                        tab_space_size,
1449                        indent_style,
1450                    );
1451                }
1452            }
1453        }
1454
1455        if matches!(first, Some("SELECT")) && line.words.len() > 1 && line.trimmed.contains(',') {
1456            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
1457                let prev_first = scans[prev_idx].words.first().map(String::as_str);
1458                if matches!(prev_first, Some("UNION" | "INTERSECT" | "EXCEPT")) {
1459                    push_keyword_break_edit(
1460                        &mut edits,
1461                        statement_sql,
1462                        &line_infos,
1463                        &scans,
1464                        idx,
1465                        "SELECT",
1466                        line.indent + indent_unit,
1467                        indent_unit,
1468                        tab_space_size,
1469                        indent_style,
1470                    );
1471                }
1472            }
1473        }
1474
1475        if matches!(first, Some("SELECT"))
1476            && upper.starts_with("SELECT *")
1477            && !upper.contains(" FROM ")
1478        {
1479            if let Some(next_idx) = next_significant_line(&scans, idx) {
1480                let next_first = scans[next_idx].words.first().map(String::as_str);
1481                if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
1482                    push_keyword_break_edit(
1483                        &mut edits,
1484                        statement_sql,
1485                        &line_infos,
1486                        &scans,
1487                        idx,
1488                        "SELECT",
1489                        line.indent + indent_unit,
1490                        indent_unit,
1491                        tab_space_size,
1492                        indent_style,
1493                    );
1494                }
1495            }
1496        }
1497
1498        if line.trimmed.contains(',') {
1499            if let Some(partition_idx) = upper.find("PARTITION BY") {
1500                let partition_tail = &upper[partition_idx..];
1501                if !partition_tail.contains(')') {
1502                    if let Some(next_idx) = next_significant_line(&scans, idx) {
1503                        let next_first = scans[next_idx].words.first().map(String::as_str);
1504                        if !is_clause_boundary(next_first, scans[next_idx].trimmed) {
1505                            let break_rel = partition_idx + "PARTITION BY".len();
1506                            let mut content_rel = break_rel;
1507                            while let Some(ch) = line.trimmed[content_rel..].chars().next() {
1508                                if ch.is_whitespace() {
1509                                    content_rel += ch.len_utf8();
1510                                } else {
1511                                    break;
1512                                }
1513                            }
1514                            if content_rel < line.trimmed.len() {
1515                                push_trimmed_offset_break_edit(
1516                                    &mut edits,
1517                                    statement_sql,
1518                                    &line_infos,
1519                                    idx,
1520                                    break_rel,
1521                                    content_rel,
1522                                    line.indent + indent_unit,
1523                                    indent_unit,
1524                                    tab_space_size,
1525                                    indent_style,
1526                                );
1527                            }
1528                        }
1529                    }
1530                }
1531            }
1532        }
1533
1534        if matches!(first, Some("THEN")) {
1535            if let Some(expected_indent) =
1536                expected_then_indent(&scans, &case_anchor_cache, idx, indent_unit)
1537            {
1538                push_leading_indent_edit(
1539                    &mut edits,
1540                    statement_sql,
1541                    &line_infos,
1542                    idx,
1543                    line.indent,
1544                    expected_indent,
1545                    indent_unit,
1546                    tab_space_size,
1547                    indent_style,
1548                );
1549            }
1550        }
1551
1552        if matches!(first, Some("ELSE")) {
1553            if let Some(expected_indent) =
1554                expected_else_indent(&scans, &case_anchor_cache, idx, indent_unit)
1555            {
1556                push_leading_indent_edit(
1557                    &mut edits,
1558                    statement_sql,
1559                    &line_infos,
1560                    idx,
1561                    line.indent,
1562                    expected_indent,
1563                    indent_unit,
1564                    tab_space_size,
1565                    indent_style,
1566                );
1567            }
1568        }
1569
1570        if matches!(first, Some("END")) {
1571            if let Some(expected_indent) =
1572                expected_end_indent(&scans, &case_anchor_cache, idx, indent_unit)
1573            {
1574                push_leading_indent_edit(
1575                    &mut edits,
1576                    statement_sql,
1577                    &line_infos,
1578                    idx,
1579                    line.indent,
1580                    expected_indent,
1581                    indent_unit,
1582                    tab_space_size,
1583                    indent_style,
1584                );
1585            }
1586        }
1587
1588        if let Some(as_rel) = trailing_as_offset(line.trimmed) {
1589            if let Some(next_idx) = next_significant_line(&scans, idx) {
1590                let next_line = &scans[next_idx];
1591                if is_simple_alias_identifier(next_line.trimmed) {
1592                    if let Some(after_next_idx) = next_significant_line(&scans, next_idx) {
1593                        let after_next_first =
1594                            scans[after_next_idx].words.first().map(String::as_str);
1595                        if matches!(after_next_first, Some("FROM")) {
1596                            push_trailing_as_alias_break_edit(
1597                                &mut edits,
1598                                statement_sql,
1599                                &line_infos,
1600                                idx,
1601                                next_idx,
1602                                as_rel,
1603                                line.indent + indent_unit,
1604                                indent_unit,
1605                                tab_space_size,
1606                                indent_style,
1607                            );
1608                        }
1609                    }
1610                }
1611            }
1612        }
1613
1614        if let Some((arg_open_rel, arg_rel)) = make_interval_inline_arg_offsets(line.trimmed) {
1615            if let Some(next_idx) = next_significant_line(&scans, idx) {
1616                let next_line = &scans[next_idx];
1617                if next_line.trimmed.starts_with("=>") {
1618                    push_make_interval_arg_break_edit(
1619                        &mut edits,
1620                        statement_sql,
1621                        &line_infos,
1622                        idx,
1623                        arg_open_rel,
1624                        arg_rel,
1625                        line.indent + indent_unit,
1626                        indent_unit,
1627                        tab_space_size,
1628                        indent_style,
1629                    );
1630
1631                    push_leading_indent_edit(
1632                        &mut edits,
1633                        statement_sql,
1634                        &line_infos,
1635                        next_idx,
1636                        next_line.indent,
1637                        line.indent + indent_unit * 2,
1638                        indent_unit,
1639                        tab_space_size,
1640                        indent_style,
1641                    );
1642
1643                    if let Some(close_rel) = inline_close_paren_offset(next_line.trimmed) {
1644                        push_close_paren_break_edit(
1645                            &mut edits,
1646                            statement_sql,
1647                            &line_infos,
1648                            next_idx,
1649                            close_rel,
1650                            line.indent + indent_unit,
1651                            indent_unit,
1652                            tab_space_size,
1653                            indent_style,
1654                        );
1655                    }
1656                }
1657            }
1658        }
1659
1660        if is_join_clause(first, second) {
1661            if should_break_inline_join_on(&scans, idx, first, second, &upper) {
1662                let Some(on_offset) = inline_join_on_offset(line.trimmed) else {
1663                    continue;
1664                };
1665                push_inline_join_on_break_edit(
1666                    &mut edits,
1667                    statement_sql,
1668                    &line_infos,
1669                    idx,
1670                    on_offset,
1671                    line.indent + indent_unit,
1672                    indent_unit,
1673                    tab_space_size,
1674                    indent_style,
1675                );
1676                if let Some(next_idx) = next_significant_line(&scans, idx) {
1677                    push_on_condition_block_indent_edits(
1678                        &mut edits,
1679                        statement_sql,
1680                        &line_infos,
1681                        &scans,
1682                        next_idx,
1683                        line.indent + indent_unit * 2,
1684                        indent_unit,
1685                        tab_space_size,
1686                        indent_style,
1687                    );
1688                }
1689            }
1690            if let Some(next_idx) = next_significant_line(&scans, idx) {
1691                let next_first = scans[next_idx].words.first().map(String::as_str);
1692                let next_second = scans[next_idx].words.get(1).map(String::as_str);
1693                if matches!(next_first, Some("ON")) && !matches!(next_second, Some("CONFLICT")) {
1694                    push_leading_indent_edit(
1695                        &mut edits,
1696                        statement_sql,
1697                        &line_infos,
1698                        next_idx,
1699                        scans[next_idx].indent,
1700                        line.indent + indent_unit,
1701                        indent_unit,
1702                        tab_space_size,
1703                        indent_style,
1704                    );
1705                }
1706            }
1707        }
1708    }
1709
1710    edits
1711}
1712
1713#[allow(clippy::too_many_arguments)]
1714fn push_keyword_break_edit(
1715    edits: &mut Vec<Lt02AutofixEdit>,
1716    statement_sql: &str,
1717    line_infos: &[StatementLineInfo],
1718    scans: &[ScanLine<'_>],
1719    line_index: usize,
1720    keyword: &str,
1721    expected_indent: usize,
1722    indent_unit: usize,
1723    tab_space_size: usize,
1724    indent_style: IndentStyle,
1725) {
1726    let Some(line) = scans.get(line_index) else {
1727        return;
1728    };
1729    let Some(line_info) = line_infos.get(line_index) else {
1730        return;
1731    };
1732    let Some(content_rel) = content_offset_after_keyword(line.trimmed, keyword) else {
1733        return;
1734    };
1735
1736    let keyword_len = keyword.len();
1737    if content_rel <= keyword_len {
1738        return;
1739    }
1740
1741    let start = line_info
1742        .start
1743        .saturating_add(line_info.indent_end)
1744        .saturating_add(keyword_len);
1745    let end = line_info
1746        .start
1747        .saturating_add(line_info.indent_end)
1748        .saturating_add(content_rel);
1749    if start >= end || end > statement_sql.len() {
1750        return;
1751    }
1752
1753    let replacement = format!(
1754        "\n{}",
1755        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
1756    );
1757    if statement_sql[start..end] != replacement {
1758        edits.push(Lt02AutofixEdit {
1759            start,
1760            end,
1761            replacement,
1762        });
1763    }
1764}
1765
1766#[allow(clippy::too_many_arguments)]
1767fn push_trimmed_offset_break_edit(
1768    edits: &mut Vec<Lt02AutofixEdit>,
1769    statement_sql: &str,
1770    line_infos: &[StatementLineInfo],
1771    line_index: usize,
1772    break_rel: usize,
1773    content_rel: usize,
1774    expected_indent: usize,
1775    indent_unit: usize,
1776    tab_space_size: usize,
1777    indent_style: IndentStyle,
1778) {
1779    if content_rel <= break_rel {
1780        return;
1781    }
1782    let Some(line_info) = line_infos.get(line_index) else {
1783        return;
1784    };
1785
1786    let start = line_info
1787        .start
1788        .saturating_add(line_info.indent_end)
1789        .saturating_add(break_rel);
1790    let end = line_info
1791        .start
1792        .saturating_add(line_info.indent_end)
1793        .saturating_add(content_rel);
1794    if start >= end || end > statement_sql.len() {
1795        return;
1796    }
1797
1798    let replacement = format!(
1799        "\n{}",
1800        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
1801    );
1802    if statement_sql[start..end] != replacement {
1803        edits.push(Lt02AutofixEdit {
1804            start,
1805            end,
1806            replacement,
1807        });
1808    }
1809}
1810
1811#[allow(clippy::too_many_arguments)]
1812fn push_case_when_multiline_break_edit(
1813    edits: &mut Vec<Lt02AutofixEdit>,
1814    statement_sql: &str,
1815    line_infos: &[StatementLineInfo],
1816    line_index: usize,
1817    case_break_rel: usize,
1818    content_rel: usize,
1819    when_indent: usize,
1820    content_indent: usize,
1821    indent_unit: usize,
1822    tab_space_size: usize,
1823    indent_style: IndentStyle,
1824) {
1825    if content_rel <= case_break_rel {
1826        return;
1827    }
1828    let Some(line_info) = line_infos.get(line_index) else {
1829        return;
1830    };
1831
1832    let start = line_info
1833        .start
1834        .saturating_add(line_info.indent_end)
1835        .saturating_add(case_break_rel);
1836    let end = line_info
1837        .start
1838        .saturating_add(line_info.indent_end)
1839        .saturating_add(content_rel);
1840    if start >= end || end > statement_sql.len() {
1841        return;
1842    }
1843
1844    let replacement = format!(
1845        "\n{}WHEN\n{}",
1846        make_indent(when_indent, indent_unit, tab_space_size, indent_style),
1847        make_indent(content_indent, indent_unit, tab_space_size, indent_style)
1848    );
1849    if statement_sql[start..end] != replacement {
1850        edits.push(Lt02AutofixEdit {
1851            start,
1852            end,
1853            replacement,
1854        });
1855    }
1856}
1857
1858#[allow(clippy::too_many_arguments)]
1859fn push_inline_case_break_edit(
1860    edits: &mut Vec<Lt02AutofixEdit>,
1861    statement_sql: &str,
1862    line_infos: &[StatementLineInfo],
1863    line_index: usize,
1864    case_rel: usize,
1865    expected_indent: usize,
1866    indent_unit: usize,
1867    tab_space_size: usize,
1868    indent_style: IndentStyle,
1869) {
1870    let Some(line_info) = line_infos.get(line_index) else {
1871        return;
1872    };
1873
1874    let start = line_info
1875        .start
1876        .saturating_add(line_info.indent_end)
1877        .saturating_add(case_rel.saturating_sub(1));
1878    let end = start.saturating_add(1);
1879    if start >= end || end > statement_sql.len() {
1880        return;
1881    }
1882
1883    let replacement = format!(
1884        "\n{}",
1885        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
1886    );
1887    if statement_sql[start..end] != replacement {
1888        edits.push(Lt02AutofixEdit {
1889            start,
1890            end,
1891            replacement,
1892        });
1893    }
1894}
1895
1896#[allow(clippy::too_many_arguments)]
1897fn push_make_interval_arg_break_edit(
1898    edits: &mut Vec<Lt02AutofixEdit>,
1899    statement_sql: &str,
1900    line_infos: &[StatementLineInfo],
1901    line_index: usize,
1902    arg_open_rel: usize,
1903    arg_rel: usize,
1904    expected_indent: usize,
1905    indent_unit: usize,
1906    tab_space_size: usize,
1907    indent_style: IndentStyle,
1908) {
1909    let Some(line_info) = line_infos.get(line_index) else {
1910        return;
1911    };
1912    let line_start = line_info.start + line_info.indent_end;
1913    let start = line_start + arg_open_rel;
1914    let end = line_start + arg_rel;
1915    if start > end || end > statement_sql.len() {
1916        return;
1917    }
1918
1919    let replacement = format!(
1920        "\n{}",
1921        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
1922    );
1923    if statement_sql[start..end] != replacement {
1924        edits.push(Lt02AutofixEdit {
1925            start,
1926            end,
1927            replacement,
1928        });
1929    }
1930}
1931
1932#[allow(clippy::too_many_arguments)]
1933fn push_trailing_as_alias_break_edit(
1934    edits: &mut Vec<Lt02AutofixEdit>,
1935    statement_sql: &str,
1936    line_infos: &[StatementLineInfo],
1937    line_index: usize,
1938    next_line_index: usize,
1939    as_rel: usize,
1940    expected_indent: usize,
1941    indent_unit: usize,
1942    tab_space_size: usize,
1943    indent_style: IndentStyle,
1944) {
1945    let Some(line_info) = line_infos.get(line_index) else {
1946        return;
1947    };
1948    let Some(next_info) = line_infos.get(next_line_index) else {
1949        return;
1950    };
1951    let start = line_info
1952        .start
1953        .saturating_add(line_info.indent_end)
1954        .saturating_add(as_rel);
1955    let end = next_info.start.saturating_add(next_info.indent_end);
1956    if start >= end || end > statement_sql.len() {
1957        return;
1958    }
1959
1960    let indent = make_indent(expected_indent, indent_unit, tab_space_size, indent_style);
1961    let replacement = format!("\n{indent}AS\n{indent}");
1962    if statement_sql[start..end] != replacement {
1963        edits.push(Lt02AutofixEdit {
1964            start,
1965            end,
1966            replacement,
1967        });
1968    }
1969}
1970
1971#[allow(clippy::too_many_arguments)]
1972fn push_close_paren_break_edit(
1973    edits: &mut Vec<Lt02AutofixEdit>,
1974    statement_sql: &str,
1975    line_infos: &[StatementLineInfo],
1976    line_index: usize,
1977    close_rel: usize,
1978    expected_indent: usize,
1979    indent_unit: usize,
1980    tab_space_size: usize,
1981    indent_style: IndentStyle,
1982) {
1983    let Some(line_info) = line_infos.get(line_index) else {
1984        return;
1985    };
1986    let line_start = line_info.start + line_info.indent_end;
1987    let start = line_start + close_rel;
1988    if start > statement_sql.len() {
1989        return;
1990    }
1991
1992    let replacement = format!(
1993        "\n{}",
1994        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
1995    );
1996    edits.push(Lt02AutofixEdit {
1997        start,
1998        end: start,
1999        replacement,
2000    });
2001}
2002
2003#[allow(clippy::too_many_arguments)]
2004fn push_inline_join_on_break_edit(
2005    edits: &mut Vec<Lt02AutofixEdit>,
2006    statement_sql: &str,
2007    line_infos: &[StatementLineInfo],
2008    line_index: usize,
2009    on_keyword_rel: usize,
2010    expected_indent: usize,
2011    indent_unit: usize,
2012    tab_space_size: usize,
2013    indent_style: IndentStyle,
2014) {
2015    let Some(line_info) = line_infos.get(line_index) else {
2016        return;
2017    };
2018    let line_start = line_info.start + line_info.indent_end;
2019    let line_end = statement_sql[line_start..]
2020        .find('\n')
2021        .map(|relative| line_start + relative)
2022        .unwrap_or(statement_sql.len());
2023    if line_end <= line_start || line_end > statement_sql.len() {
2024        return;
2025    }
2026    if on_keyword_rel == 0 {
2027        return;
2028    }
2029
2030    let start = line_start + on_keyword_rel - 1;
2031    let end = start + 1;
2032    if end > statement_sql.len() || start >= end {
2033        return;
2034    }
2035    let replacement = format!(
2036        "\n{}",
2037        make_indent(expected_indent, indent_unit, tab_space_size, indent_style)
2038    );
2039    if statement_sql[start..end] != replacement {
2040        edits.push(Lt02AutofixEdit {
2041            start,
2042            end,
2043            replacement,
2044        });
2045    }
2046}
2047
2048#[allow(clippy::too_many_arguments)]
2049fn push_leading_indent_edit(
2050    edits: &mut Vec<Lt02AutofixEdit>,
2051    statement_sql: &str,
2052    line_infos: &[StatementLineInfo],
2053    line_index: usize,
2054    current_indent: usize,
2055    expected_indent: usize,
2056    indent_unit: usize,
2057    tab_space_size: usize,
2058    indent_style: IndentStyle,
2059) {
2060    if current_indent == expected_indent {
2061        return;
2062    }
2063    let Some(line_info) = line_infos.get(line_index) else {
2064        return;
2065    };
2066    let start = line_info.start;
2067    let end = line_info.start + line_info.indent_end;
2068    if end > statement_sql.len() || start > end {
2069        return;
2070    }
2071
2072    let replacement = make_indent(expected_indent, indent_unit, tab_space_size, indent_style);
2073    if statement_sql[start..end] != replacement {
2074        edits.push(Lt02AutofixEdit {
2075            start,
2076            end,
2077            replacement,
2078        });
2079    }
2080}
2081
2082#[allow(clippy::too_many_arguments)]
2083fn push_on_condition_block_indent_edits(
2084    edits: &mut Vec<Lt02AutofixEdit>,
2085    statement_sql: &str,
2086    line_infos: &[StatementLineInfo],
2087    scans: &[ScanLine<'_>],
2088    start_idx: usize,
2089    base_indent: usize,
2090    indent_unit: usize,
2091    tab_space_size: usize,
2092    indent_style: IndentStyle,
2093) {
2094    let mut depth = 0isize;
2095    let mut suspended_nested_clause_depth: Option<isize> = None;
2096    let mut idx = start_idx;
2097    while idx < scans.len() {
2098        let line = &scans[idx];
2099        if line.is_blank || contains_template_marker(line.trimmed) {
2100            idx += 1;
2101            continue;
2102        }
2103
2104        let first = line.words.first().map(String::as_str);
2105
2106        if let Some(suspended_depth) = suspended_nested_clause_depth {
2107            let at_resume_boundary =
2108                depth == suspended_depth && is_clause_boundary(first, line.trimmed);
2109            if !at_resume_boundary {
2110                if !line.is_comment_only {
2111                    depth += paren_delta_simple(line.trimmed);
2112                    if depth < 0 {
2113                        depth = 0;
2114                    }
2115                }
2116                idx += 1;
2117                continue;
2118            }
2119            suspended_nested_clause_depth = None;
2120        }
2121
2122        if depth > 0 && matches!(first, Some("WHERE" | "HAVING")) && line.words.len() == 1 {
2123            // Let nested WHERE/HAVING blocks compute their own child indentation.
2124            suspended_nested_clause_depth = Some(depth);
2125            if !line.is_comment_only {
2126                depth += paren_delta_simple(line.trimmed);
2127                if depth < 0 {
2128                    depth = 0;
2129                }
2130            }
2131            idx += 1;
2132            continue;
2133        }
2134
2135        let starts_with_close_paren = line.trimmed.starts_with(')');
2136        let starts_with_open_paren = line.trimmed.starts_with('(');
2137        let is_continuation_line = matches!(first, Some("AND" | "OR" | "NOT" | "EXISTS"))
2138            || starts_with_operator_continuation(line.trimmed)
2139            || (starts_with_close_paren && depth > 0)
2140            || (starts_with_open_paren && depth > 0)
2141            || line.is_comment_only;
2142        if depth <= 0 && !is_continuation_line && is_clause_boundary(first, line.trimmed) {
2143            break;
2144        }
2145
2146        let logical_depth = depth.max(0) as usize;
2147        let expected_indent = if line.trimmed.starts_with(')') {
2148            if logical_depth > 0 {
2149                base_indent + ((logical_depth - 1) * indent_unit)
2150            } else {
2151                base_indent
2152            }
2153        } else {
2154            base_indent + (logical_depth * indent_unit)
2155        };
2156
2157        push_leading_indent_edit(
2158            edits,
2159            statement_sql,
2160            line_infos,
2161            idx,
2162            line.indent,
2163            expected_indent,
2164            indent_unit,
2165            tab_space_size,
2166            indent_style,
2167        );
2168
2169        if !line.is_comment_only {
2170            depth += paren_delta_simple(line.trimmed);
2171            if depth < 0 {
2172                depth = 0;
2173            }
2174        }
2175        idx += 1;
2176    }
2177}
2178
2179#[allow(clippy::too_many_arguments)]
2180fn push_assignment_block_indent_edits(
2181    edits: &mut Vec<Lt02AutofixEdit>,
2182    statement_sql: &str,
2183    line_infos: &[StatementLineInfo],
2184    scans: &[ScanLine<'_>],
2185    start_idx: usize,
2186    expected_indent: usize,
2187    indent_unit: usize,
2188    tab_space_size: usize,
2189    indent_style: IndentStyle,
2190) {
2191    let mut idx = start_idx;
2192    while idx < scans.len() {
2193        let line = &scans[idx];
2194        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
2195            idx += 1;
2196            continue;
2197        }
2198
2199        let first = line.words.first().map(String::as_str);
2200        if starts_with_assignment(line.trimmed) || matches!(first, Some("AND" | "OR")) {
2201            push_leading_indent_edit(
2202                edits,
2203                statement_sql,
2204                line_infos,
2205                idx,
2206                line.indent,
2207                expected_indent,
2208                indent_unit,
2209                tab_space_size,
2210                indent_style,
2211            );
2212            idx += 1;
2213            continue;
2214        }
2215
2216        if is_clause_boundary(first, line.trimmed) || line.trimmed.starts_with(';') {
2217            break;
2218        }
2219
2220        let previous_ended_comma = previous_significant_line(scans, idx)
2221            .is_some_and(|prev_idx| scans[prev_idx].trimmed.trim_end().ends_with(','));
2222        if previous_ended_comma {
2223            push_leading_indent_edit(
2224                edits,
2225                statement_sql,
2226                line_infos,
2227                idx,
2228                line.indent,
2229                expected_indent,
2230                indent_unit,
2231                tab_space_size,
2232                indent_style,
2233            );
2234            idx += 1;
2235            continue;
2236        }
2237
2238        break;
2239    }
2240}
2241
2242fn push_line_start_issue_span(
2243    issue_spans: &mut Vec<(usize, usize)>,
2244    line_infos: &[StatementLineInfo],
2245    line_index: usize,
2246    sql_len: usize,
2247) {
2248    let Some(line_info) = line_infos.get(line_index) else {
2249        return;
2250    };
2251    let start = line_info.start.min(sql_len);
2252    let end = (start + 1).min(sql_len);
2253    issue_spans.push((start, end.max(start)));
2254}
2255
2256fn push_trimmed_offset_issue_span(
2257    issue_spans: &mut Vec<(usize, usize)>,
2258    line_infos: &[StatementLineInfo],
2259    line_index: usize,
2260    trimmed_offset: usize,
2261    sql_len: usize,
2262) {
2263    let Some(line_info) = line_infos.get(line_index) else {
2264        return;
2265    };
2266
2267    let start = line_info
2268        .start
2269        .saturating_add(line_info.indent_end)
2270        .saturating_add(trimmed_offset)
2271        .min(sql_len);
2272    let end = (start + 1).min(sql_len);
2273    issue_spans.push((start, end.max(start)));
2274}
2275
2276fn starts_with_assignment(trimmed: &str) -> bool {
2277    let bytes = trimmed.as_bytes();
2278    if bytes.is_empty() || !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
2279        return false;
2280    }
2281
2282    let mut index = 1usize;
2283    while index < bytes.len() && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') {
2284        index += 1;
2285    }
2286    while index < bytes.len() && bytes[index].is_ascii_whitespace() {
2287        index += 1;
2288    }
2289
2290    index < bytes.len() && bytes[index] == b'='
2291}
2292
2293fn starts_with_operator_continuation(trimmed: &str) -> bool {
2294    let trimmed = trimmed.trim_start();
2295    trimmed.starts_with('=')
2296        || trimmed.starts_with('+')
2297        || trimmed.starts_with('*')
2298        || trimmed.starts_with('/')
2299        || trimmed.starts_with('%')
2300        || trimmed.starts_with("||")
2301        || trimmed.starts_with("->")
2302        || trimmed.starts_with("->>")
2303        || (trimmed.starts_with('-')
2304            && !trimmed
2305                .chars()
2306                .nth(1)
2307                .is_some_and(|ch| ch.is_ascii_alphanumeric()))
2308}
2309
2310fn inline_join_on_offset(trimmed: &str) -> Option<usize> {
2311    let upper = trimmed.to_ascii_uppercase();
2312    if let Some(space_before_on) = upper.find(" ON ") {
2313        return Some(space_before_on + 1);
2314    }
2315    if upper.ends_with(" ON") || upper.ends_with(" ON (") {
2316        return upper
2317            .rfind(" ON")
2318            .map(|space_before_on| space_before_on + 1);
2319    }
2320    None
2321}
2322
2323fn inline_case_keyword_offset(trimmed: &str) -> Option<usize> {
2324    let upper = trimmed.to_ascii_uppercase();
2325    upper
2326        .find(" CASE")
2327        .map(|space_before_case| space_before_case + 1)
2328}
2329
2330fn should_break_inline_join_on(
2331    scans: &[ScanLine<'_>],
2332    line_index: usize,
2333    first_word: Option<&str>,
2334    second_word: Option<&str>,
2335    upper_trimmed: &str,
2336) -> bool {
2337    if upper_trimmed.ends_with(" ON") || upper_trimmed.ends_with(" ON (") {
2338        return true;
2339    }
2340
2341    if !matches!(first_word, Some("JOIN")) {
2342        return false;
2343    }
2344    if !is_join_clause(first_word, second_word) {
2345        return false;
2346    }
2347    if inline_join_on_offset(scans[line_index].trimmed).is_none() {
2348        return false;
2349    }
2350
2351    previous_significant_line(scans, line_index).is_some_and(|prev_idx| {
2352        let prev_first = scans[prev_idx].words.first().map(String::as_str);
2353        let prev_second = scans[prev_idx].words.get(1).map(String::as_str);
2354        matches!(
2355            prev_first,
2356            Some("LEFT" | "RIGHT" | "FULL" | "INNER" | "OUTER" | "CROSS" | "NATURAL")
2357        ) && !matches!(prev_second, Some("JOIN" | "APPLY"))
2358    })
2359}
2360
2361fn content_offset_after_keyword(trimmed: &str, keyword: &str) -> Option<usize> {
2362    if trimmed.len() < keyword.len()
2363        || !trimmed
2364            .get(..keyword.len())
2365            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword))
2366    {
2367        return None;
2368    }
2369
2370    let mut index = keyword.len();
2371    let first_after = trimmed[index..].chars().next()?;
2372    if !first_after.is_whitespace() {
2373        return None;
2374    }
2375
2376    while let Some(ch) = trimmed[index..].chars().next() {
2377        if ch.is_whitespace() {
2378            index += ch.len_utf8();
2379        } else {
2380            break;
2381        }
2382    }
2383
2384    (index < trimmed.len()).then_some(index)
2385}
2386
2387fn trailing_as_offset(trimmed: &str) -> Option<usize> {
2388    let upper = trimmed.to_ascii_uppercase();
2389    let as_rel = upper.rfind(" AS")?;
2390    (as_rel > 0 && as_rel + " AS".len() == trimmed.len()).then_some(as_rel)
2391}
2392
2393fn is_simple_alias_identifier(trimmed: &str) -> bool {
2394    if trimmed.is_empty() {
2395        return false;
2396    }
2397    let bytes = trimmed.as_bytes();
2398    if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
2399        return false;
2400    }
2401    bytes[1..]
2402        .iter()
2403        .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
2404}
2405
2406fn make_interval_inline_arg_offsets(trimmed: &str) -> Option<(usize, usize)> {
2407    let upper = trimmed.to_ascii_uppercase();
2408    let open_rel = upper.find("MAKE_INTERVAL(")? + "MAKE_INTERVAL(".len();
2409    if open_rel >= trimmed.len() {
2410        return None;
2411    }
2412
2413    let mut arg_rel = open_rel;
2414    while let Some(ch) = trimmed[arg_rel..].chars().next() {
2415        if ch.is_whitespace() {
2416            arg_rel += ch.len_utf8();
2417        } else {
2418            break;
2419        }
2420    }
2421
2422    (arg_rel < trimmed.len()).then_some((open_rel, arg_rel))
2423}
2424
2425fn make_interval_inline_arg_offset(trimmed: &str) -> Option<usize> {
2426    make_interval_inline_arg_offsets(trimmed).map(|(_, arg_rel)| arg_rel)
2427}
2428
2429fn inline_close_paren_offset(trimmed: &str) -> Option<usize> {
2430    if !trimmed.trim_start().starts_with("=>") {
2431        return None;
2432    }
2433    trimmed.rfind(')')
2434}
2435
2436fn push_join_on_block_indent_spans(
2437    issue_spans: &mut Vec<(usize, usize)>,
2438    line_infos: &[StatementLineInfo],
2439    scans: &[ScanLine<'_>],
2440    join_line_idx: usize,
2441    indent_unit: usize,
2442    sql_len: usize,
2443) {
2444    let Some(join_line) = scans.get(join_line_idx) else {
2445        return;
2446    };
2447    let join_indent = rounded_indent_width(join_line.indent, indent_unit);
2448    let base_indent = join_indent + (indent_unit * 2);
2449    let on_has_open_paren = join_line.trimmed.to_ascii_uppercase().ends_with(" ON (");
2450    let mut depth: isize = if on_has_open_paren { 1 } else { 0 };
2451
2452    let mut idx = join_line_idx + 1;
2453    while idx < scans.len() {
2454        let line = &scans[idx];
2455        if line.is_blank || line.is_comment_only || contains_template_marker(line.trimmed) {
2456            idx += 1;
2457            continue;
2458        }
2459
2460        let first = line.words.first().map(String::as_str);
2461        let starts_with_close_paren = line.trimmed.starts_with(')');
2462        let starts_with_open_paren = line.trimmed.starts_with('(');
2463        let is_continuation_line = matches!(first, Some("AND" | "OR" | "NOT" | "EXISTS"))
2464            || starts_with_operator_continuation(line.trimmed)
2465            || (starts_with_close_paren && depth > 0)
2466            || (starts_with_open_paren && depth > 0);
2467        if depth <= 0 && !is_continuation_line && is_clause_boundary(first, line.trimmed) {
2468            break;
2469        }
2470
2471        let logical_depth = if on_has_open_paren {
2472            depth.saturating_sub(1) as usize
2473        } else {
2474            depth.max(0) as usize
2475        };
2476        let expected_indent = if line.trimmed.starts_with(')') {
2477            if logical_depth > 0 {
2478                base_indent + ((logical_depth - 1) * indent_unit)
2479            } else {
2480                join_indent + indent_unit
2481            }
2482        } else {
2483            base_indent + (logical_depth * indent_unit)
2484        };
2485
2486        if line.indent != expected_indent {
2487            push_line_start_issue_span(issue_spans, line_infos, idx, sql_len);
2488        }
2489
2490        depth += paren_delta_simple(line.trimmed);
2491        if depth < 0 {
2492            depth = 0;
2493        }
2494        idx += 1;
2495    }
2496}
2497
2498fn find_andor_anchor(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
2499    (0..from_idx)
2500        .rev()
2501        .find_map(|idx| {
2502            let line = &scans[idx];
2503            if line.is_blank || line.is_comment_only {
2504                return None;
2505            }
2506            let first = line.words.first().map(String::as_str);
2507            if matches!(first, Some("WHERE" | "ON" | "HAVING" | "WHEN")) {
2508                return Some(idx);
2509            }
2510            if is_clause_boundary(first, line.trimmed) && !matches!(first, Some("AND" | "OR")) {
2511                return Some(usize::MAX);
2512            }
2513            None
2514        })
2515        .and_then(|idx| (idx != usize::MAX).then_some(idx))
2516}
2517
2518fn find_case_when_anchor(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
2519    (0..from_idx)
2520        .rev()
2521        .find_map(|idx| {
2522            let line = &scans[idx];
2523            if line.is_blank || line.is_comment_only {
2524                return None;
2525            }
2526            let first = line.words.first().map(String::as_str);
2527            if matches!(first, Some("WHEN")) {
2528                return Some(idx);
2529            }
2530            if is_clause_boundary(first, line.trimmed)
2531                && !matches!(first, Some("AND" | "OR" | "THEN" | "ELSE"))
2532            {
2533                return Some(usize::MAX);
2534            }
2535            None
2536        })
2537        .and_then(|idx| (idx != usize::MAX).then_some(idx))
2538}
2539
2540fn build_case_anchor_cache(scans: &[ScanLine<'_>]) -> Vec<Option<usize>> {
2541    let mut cache = vec![None; scans.len()];
2542    let mut current_anchor: Option<usize> = None;
2543
2544    for (idx, line) in scans.iter().enumerate() {
2545        // Record the anchor *before* inspecting the current line so that a
2546        // CASE line references its enclosing anchor, while lines that follow
2547        // (WHEN, THEN, ELSE, END) reference back to the CASE itself.
2548        cache[idx] = current_anchor;
2549        if line.is_blank || line.is_comment_only {
2550            continue;
2551        }
2552
2553        let first = line.words.first().map(String::as_str);
2554        if matches!(first, Some("CASE")) || line.inline_case_offset.is_some() {
2555            current_anchor = Some(idx);
2556            continue;
2557        }
2558
2559        if is_clause_boundary(first, line.trimmed)
2560            && !matches!(first, Some("WHEN" | "THEN" | "ELSE" | "END" | "AND" | "OR"))
2561        {
2562            current_anchor = None;
2563        }
2564    }
2565
2566    cache
2567}
2568
2569fn find_case_anchor(case_anchor_cache: &[Option<usize>], from_idx: usize) -> Option<usize> {
2570    case_anchor_cache.get(from_idx).copied().flatten()
2571}
2572
2573fn case_line_indent_for_anchor(
2574    scans: &[ScanLine<'_>],
2575    anchor_idx: usize,
2576    indent_unit: usize,
2577) -> usize {
2578    let anchor = &scans[anchor_idx];
2579    let first = anchor.words.first().map(String::as_str);
2580    if matches!(first, Some("CASE")) {
2581        anchor.indent
2582    } else if anchor.inline_case_offset.is_some() {
2583        anchor.indent + indent_unit
2584    } else {
2585        anchor.indent
2586    }
2587}
2588
2589fn expected_case_when_indent(
2590    scans: &[ScanLine<'_>],
2591    case_anchor_cache: &[Option<usize>],
2592    line_index: usize,
2593    indent_unit: usize,
2594) -> Option<usize> {
2595    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
2596    let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
2597    Some(case_indent + indent_unit)
2598}
2599
2600fn expected_then_indent(
2601    scans: &[ScanLine<'_>],
2602    case_anchor_cache: &[Option<usize>],
2603    line_index: usize,
2604    indent_unit: usize,
2605) -> Option<usize> {
2606    let prev_idx = previous_significant_line(scans, line_index)?;
2607    let prev = scans.get(prev_idx)?;
2608    let prev_first = prev.words.first().map(String::as_str);
2609
2610    if matches!(prev_first, Some("AND" | "OR")) {
2611        return Some(prev.indent + indent_unit);
2612    }
2613    if prev.trimmed.starts_with("=>") {
2614        return Some(prev.indent);
2615    }
2616
2617    if let Some(case_anchor) = find_case_anchor(case_anchor_cache, line_index) {
2618        let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
2619        return Some(case_indent + indent_unit * 2);
2620    }
2621
2622    find_case_when_anchor(scans, line_index)
2623        .map(|when_idx| scans[when_idx].indent + indent_unit * 2)
2624}
2625
2626fn expected_else_indent(
2627    scans: &[ScanLine<'_>],
2628    case_anchor_cache: &[Option<usize>],
2629    line_index: usize,
2630    indent_unit: usize,
2631) -> Option<usize> {
2632    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
2633    let case_indent = case_line_indent_for_anchor(scans, case_anchor, indent_unit);
2634    Some(case_indent + indent_unit)
2635}
2636
2637fn expected_end_indent(
2638    scans: &[ScanLine<'_>],
2639    case_anchor_cache: &[Option<usize>],
2640    line_index: usize,
2641    indent_unit: usize,
2642) -> Option<usize> {
2643    let case_anchor = find_case_anchor(case_anchor_cache, line_index)?;
2644    Some(case_line_indent_for_anchor(scans, case_anchor, indent_unit))
2645}
2646
2647fn paren_depth_between(scans: &[ScanLine<'_>], start_idx: usize, end_idx: usize) -> usize {
2648    if start_idx >= end_idx || end_idx > scans.len() {
2649        return 0;
2650    }
2651
2652    let depth = scans[start_idx..end_idx]
2653        .iter()
2654        .fold(0isize, |acc, line| acc + paren_delta_simple(line.trimmed));
2655
2656    depth.max(0) as usize
2657}
2658
2659fn paren_delta_simple(text: &str) -> isize {
2660    text.chars().fold(0isize, |acc, ch| match ch {
2661        '(' => acc + 1,
2662        ')' => acc - 1,
2663        _ => acc,
2664    })
2665}
2666
2667// ---------------------------------------------------------------------------
2668// Generic (dialect-agnostic) indentation detection and helpers
2669// ---------------------------------------------------------------------------
2670
2671fn first_line_is_indented(ctx: &LintContext) -> bool {
2672    let statement_start = ctx.statement_range.start;
2673    if statement_start == 0 {
2674        return false;
2675    }
2676
2677    let line_start = ctx.sql[..statement_start]
2678        .rfind('\n')
2679        .map_or(0, |index| index + 1);
2680    let leading = &ctx.sql[line_start..statement_start];
2681    !leading.is_empty() && leading.chars().all(char::is_whitespace)
2682}
2683
2684fn ignore_first_line_indent_for_fragmented_statement(ctx: &LintContext) -> bool {
2685    if ctx.statement_index == 0 || ctx.statement_range.start == 0 {
2686        return false;
2687    }
2688
2689    let prefix = &ctx.sql[..ctx.statement_range.start.min(ctx.sql.len())];
2690    let prev_non_ws = prefix.chars().rev().find(|ch| !ch.is_whitespace());
2691    matches!(prev_non_ws, Some(ch) if ch != ';')
2692}
2693
2694fn first_line_is_template_fragment(ctx: &LintContext) -> bool {
2695    let statement_start = ctx.statement_range.start;
2696    if statement_start == 0 {
2697        return false;
2698    }
2699
2700    let line_start = ctx.sql[..statement_start]
2701        .rfind('\n')
2702        .map_or(0, |index| index + 1);
2703    let leading = &ctx.sql[line_start..statement_start];
2704    if leading.is_empty() || !leading.chars().all(char::is_whitespace) {
2705        return false;
2706    }
2707
2708    let before_line = &ctx.sql[..line_start];
2709    for raw_line in before_line.lines().rev() {
2710        let trimmed = raw_line.trim();
2711        if trimmed.is_empty() {
2712            continue;
2713        }
2714        return is_template_boundary_line(trimmed);
2715    }
2716
2717    false
2718}
2719
2720// ---------------------------------------------------------------------------
2721// Structural indent detection
2722// ---------------------------------------------------------------------------
2723
2724/// Returns true if any line has indentation that violates structural
2725/// expectations. This catches cases where all indents are valid multiples
2726/// of indent_unit but are at the wrong depth for their SQL context.
2727///
2728/// The check focuses on "standalone clause keyword" patterns: when a clause
2729/// keyword that expects indented content (SELECT, FROM, WHERE, SET, etc.)
2730/// appears alone on a line, the content on the following line must be
2731/// indented by one indent_unit.
2732/// Returns autofix edits for structural indentation violations. When empty,
2733/// no structural violation was found.
2734fn structural_indent_edits(
2735    ctx: &LintContext,
2736    indent_unit: usize,
2737    tab_space_size: usize,
2738    indent_style: IndentStyle,
2739    options: &LayoutIndent,
2740) -> Vec<Lt02AutofixEdit> {
2741    let sql = ctx.statement_sql();
2742
2743    if options.ignore_templated_areas && contains_template_marker(sql) {
2744        return Vec::new();
2745    }
2746
2747    // Skip structural check for templated SQL. Template expansion can
2748    // produce indentation patterns that look structurally wrong but are
2749    // correct in the original source.
2750    if ctx.is_templated() {
2751        return Vec::new();
2752    }
2753
2754    // Try to tokenize; if we cannot, fall back to no structural check.
2755    let tokens = tokenize_for_structural_check(sql, ctx);
2756    let tokens = match tokens.as_deref() {
2757        Some(t) if !t.is_empty() => t,
2758        _ => return Vec::new(),
2759    };
2760
2761    // Build per-line token info.
2762    let line_infos = build_line_token_infos(tokens);
2763    if line_infos.is_empty() {
2764        return Vec::new();
2765    }
2766
2767    let actual_indents = actual_indent_map(sql, tab_space_size);
2768    let line_info_list = statement_line_infos(sql);
2769    let mut edits = Vec::new();
2770
2771    // Check for structural violations: when a content-bearing clause keyword
2772    // is alone on its line, the following content line must be indented by
2773    // indent_unit more than the keyword line.
2774    let lines: Vec<usize> = line_infos.keys().copied().collect();
2775    for (i, &line) in lines.iter().enumerate() {
2776        let info = &line_infos[&line];
2777        if !info.is_standalone_content_clause {
2778            continue;
2779        }
2780
2781        let keyword_indent = actual_indents.get(&line).copied().unwrap_or(0);
2782        let expected_content_indent = keyword_indent + indent_unit;
2783
2784        // Find the next content line (skip blank lines).
2785        if let Some(&next_line) = lines.get(i + 1) {
2786            let next_info = &line_infos[&next_line];
2787            // Skip content lines that are clause keywords (they set their
2788            // own indent context) or SELECT modifiers (DISTINCT/ALL) which
2789            // belong with the preceding SELECT, not as indented content.
2790            if !next_info.starts_with_clause_keyword && !next_info.starts_with_select_modifier {
2791                let next_actual = actual_indents.get(&next_line).copied().unwrap_or(0);
2792                if next_actual != expected_content_indent {
2793                    if let Some(line_info) = line_info_list.get(next_line) {
2794                        let start = line_info.start;
2795                        let end = line_info.start + line_info.indent_end;
2796                        if end <= sql.len() && start <= end {
2797                            let replacement = make_indent(
2798                                expected_content_indent,
2799                                indent_unit,
2800                                tab_space_size,
2801                                indent_style,
2802                            );
2803                            edits.push(Lt02AutofixEdit {
2804                                start,
2805                                end,
2806                                replacement,
2807                            });
2808                        }
2809                    }
2810                }
2811            }
2812        }
2813    }
2814
2815    // Check for comment-only trailing lines at deeper indentation than
2816    // any content line. E.g. `SELECT 1\n    -- foo\n        -- bar`
2817    // has comments indented beyond the content (indent 0), which is wrong.
2818    if let Some(&last_content_line) = line_infos
2819        .iter()
2820        .rev()
2821        .find(|(_, info)| !info.is_comment_only)
2822        .map(|(line, _)| line)
2823    {
2824        let last_content_indent = actual_indents.get(&last_content_line).copied().unwrap_or(0);
2825        // Check comment-only lines after the last content line.
2826        for (&line, info) in &line_infos {
2827            if line > last_content_line && info.is_comment_only {
2828                if options.ignore_comment_lines {
2829                    continue;
2830                }
2831                let comment_indent = actual_indents.get(&line).copied().unwrap_or(0);
2832                if comment_indent > last_content_indent {
2833                    if let Some(line_info) = line_info_list.get(line) {
2834                        let start = line_info.start;
2835                        let end = line_info.start + line_info.indent_end;
2836                        if end <= sql.len() && start <= end {
2837                            let replacement = make_indent(
2838                                last_content_indent,
2839                                indent_unit,
2840                                tab_space_size,
2841                                indent_style,
2842                            );
2843                            edits.push(Lt02AutofixEdit {
2844                                start,
2845                                end,
2846                                replacement,
2847                            });
2848                        }
2849                    }
2850                }
2851            }
2852        }
2853    }
2854
2855    edits
2856}
2857
2858struct ScanLine<'a> {
2859    trimmed: &'a str,
2860    indent: usize,
2861    words: Vec<String>,
2862    is_blank: bool,
2863    is_comment_only: bool,
2864    inline_case_offset: Option<usize>,
2865    prev_significant: Option<usize>,
2866    next_significant: Option<usize>,
2867}
2868
2869fn link_significant_lines(scans: &mut [ScanLine<'_>]) {
2870    let mut prev_significant = None;
2871    for (idx, scan) in scans.iter_mut().enumerate() {
2872        scan.prev_significant = prev_significant;
2873        if !scan.is_blank && !scan.is_comment_only {
2874            prev_significant = Some(idx);
2875        }
2876    }
2877
2878    let mut next_significant = None;
2879    for idx in (0..scans.len()).rev() {
2880        scans[idx].next_significant = next_significant;
2881        if !scans[idx].is_blank && !scans[idx].is_comment_only {
2882            next_significant = Some(idx);
2883        }
2884    }
2885}
2886
2887#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2888enum TemplateMultilineMode {
2889    Expression,
2890    Statement,
2891    Comment,
2892}
2893
2894#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2895enum TemplateControlKind {
2896    Open,
2897    Mid,
2898    Close,
2899}
2900
2901#[derive(Clone, Debug)]
2902struct TemplateControlTag {
2903    kind: TemplateControlKind,
2904    keyword: Option<String>,
2905}
2906
2907fn detect_additional_indentation_violation(
2908    sql: &str,
2909    indent_unit: usize,
2910    tab_space_size: usize,
2911    options: &LayoutIndent,
2912    dialect: Dialect,
2913) -> bool {
2914    let lines: Vec<&str> = sql.lines().collect();
2915    if lines.is_empty() {
2916        return false;
2917    }
2918
2919    let indent_map = actual_indent_map(sql, tab_space_size);
2920    let mut scans: Vec<_> = lines
2921        .iter()
2922        .enumerate()
2923        .map(|(line_idx, line)| {
2924            let trimmed = line.trim_start();
2925            let is_blank = trimmed.trim().is_empty();
2926            let is_comment_only = is_comment_line(trimmed);
2927            let words = if is_blank {
2928                Vec::new()
2929            } else {
2930                split_upper_words(trimmed)
2931            };
2932            ScanLine {
2933                trimmed,
2934                indent: indent_map.get(&line_idx).copied().unwrap_or(0),
2935                words,
2936                is_blank,
2937                is_comment_only,
2938                inline_case_offset: None,
2939                prev_significant: None,
2940                next_significant: None,
2941            }
2942        })
2943        .collect();
2944    link_significant_lines(&mut scans);
2945
2946    let template_only_lines = template_only_line_flags(&lines);
2947    let mut sql_template_block_indents: Vec<usize> = Vec::new();
2948
2949    for idx in 0..scans.len() {
2950        let line = &scans[idx];
2951        if line.is_blank {
2952            continue;
2953        }
2954
2955        let template_controls = template_control_tags_in_line(line.trimmed);
2956        if !template_controls.is_empty() {
2957            for tag in template_controls {
2958                match tag.kind {
2959                    TemplateControlKind::Open => {
2960                        if tag
2961                            .keyword
2962                            .as_deref()
2963                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
2964                        {
2965                            sql_template_block_indents.push(line.indent);
2966                        }
2967                    }
2968                    TemplateControlKind::Mid => {
2969                        if let Some(expected_indent) = sql_template_block_indents.last() {
2970                            if line.indent != *expected_indent {
2971                                return true;
2972                            }
2973                        }
2974                    }
2975                    TemplateControlKind::Close => {
2976                        if tag
2977                            .keyword
2978                            .as_deref()
2979                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
2980                        {
2981                            if let Some(open_indent) = sql_template_block_indents.pop() {
2982                                if line.indent != open_indent {
2983                                    return true;
2984                                }
2985                            }
2986                        }
2987                    }
2988                }
2989            }
2990            continue;
2991        }
2992
2993        if template_only_lines.get(idx).copied().unwrap_or(false) {
2994            continue;
2995        }
2996
2997        let in_sql_template_block = !sql_template_block_indents.is_empty();
2998        if in_sql_template_block {
2999            let required_indent = sql_template_block_indents[0] + indent_unit;
3000            if line.indent < required_indent {
3001                return true;
3002            }
3003        }
3004
3005        let first = line.words.first().map(String::as_str);
3006        let second = line.words.get(1).map(String::as_str);
3007
3008        if matches!(first, Some("ELSE")) && words_contain_in_order(&line.words, "ELSE", "END") {
3009            return true;
3010        }
3011
3012        let upper = line.trimmed.to_ascii_uppercase();
3013        if upper.contains(" AS (SELECT") || upper.starts_with("(SELECT") {
3014            return true;
3015        }
3016
3017        if matches!(first, Some("DECLARE")) && line.words.len() > 1 {
3018            return true;
3019        }
3020
3021        if upper.contains(" PROCEDURE") {
3022            if let Some(next_idx) = next_significant_line(&scans, idx) {
3023                if scans[next_idx].trimmed.starts_with('@') && scans[next_idx].indent <= line.indent
3024                {
3025                    return true;
3026                }
3027            }
3028        }
3029
3030        if matches!(first, Some("ELSE")) {
3031            if idx == 0 && matches!(dialect, Dialect::Mssql) {
3032                return true;
3033            }
3034            if let Some(prev_idx) =
3035                previous_line_matching(&scans, idx, |f, _| matches!(f, Some("IF" | "ELSE")))
3036            {
3037                if line.indent > scans[prev_idx].indent {
3038                    return true;
3039                }
3040            }
3041        }
3042
3043        if options.indented_ctes && matches!(first, Some("WITH")) {
3044            if let Some(next_idx) = next_significant_line(&scans, idx) {
3045                if scans[next_idx].indent != line.indent + indent_unit {
3046                    return true;
3047                }
3048            }
3049        }
3050
3051        if is_content_clause_line(first, line.trimmed) {
3052            let expected_indent = line.indent + indent_unit;
3053            let mut scan_idx = idx + 1;
3054            while scan_idx < scans.len() {
3055                let next = &scans[scan_idx];
3056                if next.is_blank {
3057                    scan_idx += 1;
3058                    continue;
3059                }
3060                if template_only_lines.get(scan_idx).copied().unwrap_or(false) {
3061                    let one_line_template_expr =
3062                        next.trimmed.starts_with("{{") && next.trimmed.contains("}}");
3063                    if one_line_template_expr {
3064                        // One-line templated expressions can still represent
3065                        // content elements (e.g. SELECT list items) and should
3066                        // keep their surrounding indentation.
3067                    } else {
3068                        scan_idx += 1;
3069                        continue;
3070                    }
3071                }
3072                let next_first = next.words.first().map(String::as_str);
3073                if is_clause_boundary(next_first, next.trimmed) {
3074                    break;
3075                }
3076                if next.is_comment_only {
3077                    scan_idx += 1;
3078                    continue;
3079                }
3080                if next.indent < expected_indent {
3081                    return true;
3082                }
3083                scan_idx += 1;
3084            }
3085        }
3086
3087        if is_join_clause(first, second) && !in_sql_template_block {
3088            if let Some(prev_join_idx) = previous_line_matching(&scans, idx, is_join_clause) {
3089                let prev_first = scans[prev_join_idx].words.first().map(String::as_str);
3090                let prev_second = scans[prev_join_idx].words.get(1).map(String::as_str);
3091                if previous_significant_line(&scans, idx) == Some(prev_join_idx)
3092                    && join_requires_condition(prev_first, prev_second)
3093                    && line.indent < scans[prev_join_idx].indent + indent_unit
3094                {
3095                    return true;
3096                }
3097            }
3098
3099            let parent_from_indent =
3100                previous_line_indent_matching(&scans, idx, |f, _| matches!(f, Some("FROM")))
3101                    .or_else(|| previous_line_indent_matching(&scans, idx, is_join_clause))
3102                    .unwrap_or(0);
3103            let expected = parent_from_indent
3104                + if options.indented_joins {
3105                    indent_unit
3106                } else {
3107                    0
3108                };
3109            if line.indent != expected {
3110                return true;
3111            }
3112        }
3113
3114        if matches!(first, Some("USING" | "ON")) {
3115            let parent_indent = previous_line_indent_matching(&scans, idx, |f, s| {
3116                is_join_clause(f, s) || matches!(f, Some("USING"))
3117            })
3118            .unwrap_or(0);
3119            let expected = parent_indent
3120                + if options.indented_using_on {
3121                    indent_unit
3122                } else {
3123                    0
3124                };
3125            if line.indent != expected {
3126                return true;
3127            }
3128        }
3129
3130        if line.is_comment_only && !options.ignore_comment_lines {
3131            if let (Some(prev_idx), Some(next_idx)) = (
3132                previous_significant_line(&scans, idx),
3133                next_significant_line(&scans, idx),
3134            ) {
3135                if is_join_clause(
3136                    scans[next_idx].words.first().map(String::as_str),
3137                    scans[next_idx].words.get(1).map(String::as_str),
3138                ) || matches!(
3139                    scans[next_idx].words.first().map(String::as_str),
3140                    Some("FROM" | "WHERE" | "HAVING" | "QUALIFY" | "LIMIT")
3141                ) {
3142                    let allowed = scans[prev_idx].indent.max(scans[next_idx].indent);
3143                    if line.indent > allowed {
3144                        return true;
3145                    }
3146                }
3147            }
3148        }
3149
3150        if options.implicit_indents == ImplicitIndentsMode::Require
3151            && matches!(first, Some("WHERE" | "HAVING" | "ON" | "CASE"))
3152            && line.words.len() == 1
3153        {
3154            return true;
3155        }
3156
3157        if options.implicit_indents == ImplicitIndentsMode::Allow
3158            && matches!(first, Some("WHERE"))
3159            && line.words.len() > 1
3160            && line.trimmed.contains('(')
3161            && !line.trimmed.contains(')')
3162            && !line.trimmed.trim_end().ends_with('(')
3163        {
3164            return true;
3165        }
3166
3167        if matches!(first, Some("WHERE" | "HAVING")) && line.words.len() > 1 {
3168            if let Some(next_idx) = next_significant_line(&scans, idx) {
3169                if matches!(
3170                    scans[next_idx].words.first().map(String::as_str),
3171                    Some("AND" | "OR")
3172                ) {
3173                    match options.implicit_indents {
3174                        ImplicitIndentsMode::Forbid => return true,
3175                        ImplicitIndentsMode::Allow | ImplicitIndentsMode::Require => {
3176                            if scans[next_idx].indent < line.indent + indent_unit {
3177                                return true;
3178                            }
3179                        }
3180                    }
3181                }
3182            }
3183        }
3184
3185        if matches!(first, Some("ON")) {
3186            if options.indented_on_contents {
3187                let on_has_inline = line.words.len() > 1;
3188                if let Some(next_idx) = next_significant_line(&scans, idx) {
3189                    let next_first = scans[next_idx].words.first().map(String::as_str);
3190                    if on_has_inline && matches!(next_first, Some("AND" | "OR")) {
3191                        match options.implicit_indents {
3192                            ImplicitIndentsMode::Allow => {
3193                                if scans[next_idx].indent < line.indent + indent_unit {
3194                                    return true;
3195                                }
3196                            }
3197                            ImplicitIndentsMode::Forbid | ImplicitIndentsMode::Require => {
3198                                return true;
3199                            }
3200                        }
3201                    }
3202                    if !on_has_inline && scans[next_idx].indent < line.indent + indent_unit {
3203                        return true;
3204                    }
3205                }
3206            } else if let Some(next_idx) = next_significant_line(&scans, idx) {
3207                let next_first = scans[next_idx].words.first().map(String::as_str);
3208                if matches!(next_first, Some("AND" | "OR")) && scans[next_idx].indent != line.indent
3209                {
3210                    return true;
3211                }
3212            }
3213        }
3214
3215        if options.indented_on_contents && line_contains_inline_on(line.trimmed) {
3216            if let Some(next_idx) = next_significant_line(&scans, idx) {
3217                if matches!(
3218                    scans[next_idx].words.first().map(String::as_str),
3219                    Some("AND" | "OR")
3220                ) {
3221                    match options.implicit_indents {
3222                        ImplicitIndentsMode::Allow => {
3223                            if scans[next_idx].indent < line.indent + indent_unit {
3224                                return true;
3225                            }
3226                        }
3227                        ImplicitIndentsMode::Forbid | ImplicitIndentsMode::Require => {
3228                            return true;
3229                        }
3230                    }
3231                }
3232            }
3233        }
3234
3235        if options.indented_then && matches!(first, Some("THEN")) {
3236            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
3237                let prev_first = scans[prev_idx].words.first().map(String::as_str);
3238                if matches!(prev_first, Some("WHEN")) && line.indent <= scans[prev_idx].indent {
3239                    return true;
3240                }
3241            }
3242        }
3243
3244        if !options.indented_then && matches!(first, Some("THEN")) {
3245            if let Some(prev_idx) = previous_significant_line(&scans, idx) {
3246                if line.indent > scans[prev_idx].indent + indent_unit {
3247                    return true;
3248                }
3249            }
3250        }
3251
3252        if !options.indented_then_contents && matches!(first, Some("THEN")) {
3253            if let Some(next_idx) = next_significant_line(&scans, idx) {
3254                if scans[next_idx].indent > line.indent + indent_unit {
3255                    return true;
3256                }
3257            }
3258        }
3259    }
3260
3261    false
3262}
3263
3264fn detect_tsql_else_if_successive_violation(sql: &str, tab_space_size: usize) -> bool {
3265    let lines: Vec<&str> = sql.lines().collect();
3266    let indent_map = actual_indent_map(sql, tab_space_size);
3267    let mut scans: Vec<_> = lines
3268        .iter()
3269        .enumerate()
3270        .map(|(line_idx, line)| {
3271            let trimmed = line.trim_start();
3272            let is_blank = trimmed.trim().is_empty();
3273            let words = if is_blank {
3274                Vec::new()
3275            } else {
3276                split_upper_words(trimmed)
3277            };
3278            ScanLine {
3279                trimmed,
3280                indent: indent_map.get(&line_idx).copied().unwrap_or(0),
3281                words,
3282                is_blank,
3283                is_comment_only: is_comment_line(trimmed),
3284                inline_case_offset: None,
3285                prev_significant: None,
3286                next_significant: None,
3287            }
3288        })
3289        .collect();
3290    link_significant_lines(&mut scans);
3291
3292    for idx in 0..scans.len() {
3293        let line = &scans[idx];
3294        if line.is_blank || line.is_comment_only {
3295            continue;
3296        }
3297
3298        if !matches!(line.words.first().map(String::as_str), Some("ELSE")) {
3299            continue;
3300        }
3301
3302        if let Some(prev_idx) =
3303            previous_line_matching(&scans, idx, |f, _| matches!(f, Some("IF" | "ELSE")))
3304        {
3305            if line.indent > scans[prev_idx].indent {
3306                return true;
3307            }
3308        }
3309    }
3310
3311    false
3312}
3313
3314fn split_upper_words(text: &str) -> Vec<String> {
3315    text.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
3316        .filter(|word| !word.is_empty())
3317        .map(|word| word.to_ascii_uppercase())
3318        .collect()
3319}
3320
3321fn is_comment_line(trimmed: &str) -> bool {
3322    trimmed.starts_with("--")
3323        || trimmed.starts_with("/*")
3324        || trimmed.starts_with('*')
3325        || trimmed.starts_with("*/")
3326}
3327
3328fn words_contain_in_order(words: &[String], first: &str, second: &str) -> bool {
3329    let Some(first_pos) = words.iter().position(|word| word == first) else {
3330        return false;
3331    };
3332    words.iter().skip(first_pos + 1).any(|word| word == second)
3333}
3334
3335fn is_content_clause_line(first_word: Option<&str>, trimmed: &str) -> bool {
3336    matches!(
3337        first_word,
3338        Some("SELECT")
3339            | Some("FROM")
3340            | Some("WHERE")
3341            | Some("SET")
3342            | Some("RETURNING")
3343            | Some("HAVING")
3344            | Some("LIMIT")
3345            | Some("QUALIFY")
3346            | Some("WINDOW")
3347            | Some("DECLARE")
3348            | Some("VALUES")
3349            | Some("UPDATE")
3350    ) && split_upper_words(trimmed).len() == 1
3351}
3352
3353fn is_join_clause(first_word: Option<&str>, second_word: Option<&str>) -> bool {
3354    matches!(first_word, Some("JOIN" | "APPLY"))
3355        || (matches!(
3356            first_word,
3357            Some("LEFT" | "RIGHT" | "FULL" | "INNER" | "CROSS" | "OUTER" | "NATURAL")
3358        ) && matches!(second_word, Some("JOIN" | "APPLY")))
3359}
3360
3361fn join_requires_condition(first_word: Option<&str>, second_word: Option<&str>) -> bool {
3362    matches!(
3363        first_word,
3364        Some("JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL")
3365    ) || matches!(
3366        (first_word, second_word),
3367        (Some("OUTER"), Some("JOIN")) | (Some("NATURAL"), Some("JOIN"))
3368    )
3369}
3370
3371fn is_clause_boundary(first_word: Option<&str>, trimmed: &str) -> bool {
3372    matches!(
3373        first_word,
3374        Some("SELECT")
3375            | Some("FROM")
3376            | Some("WHERE")
3377            | Some("GROUP")
3378            | Some("ORDER")
3379            | Some("HAVING")
3380            | Some("LIMIT")
3381            | Some("QUALIFY")
3382            | Some("WINDOW")
3383            | Some("RETURNING")
3384            | Some("SET")
3385            | Some("UPDATE")
3386            | Some("DELETE")
3387            | Some("INSERT")
3388            | Some("MERGE")
3389            | Some("WITH")
3390            | Some("JOIN")
3391            | Some("LEFT")
3392            | Some("RIGHT")
3393            | Some("FULL")
3394            | Some("INNER")
3395            | Some("OUTER")
3396            | Some("CROSS")
3397            | Some("USING")
3398            | Some("ON")
3399            | Some("WHEN")
3400            | Some("THEN")
3401            | Some("ELSE")
3402            | Some("END")
3403    ) || trimmed.starts_with(')')
3404}
3405
3406fn next_significant_line(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
3407    scans.get(from_idx).and_then(|scan| scan.next_significant)
3408}
3409
3410fn previous_significant_line(scans: &[ScanLine<'_>], from_idx: usize) -> Option<usize> {
3411    scans.get(from_idx).and_then(|scan| scan.prev_significant)
3412}
3413
3414fn previous_line_matching(
3415    scans: &[ScanLine<'_>],
3416    from_idx: usize,
3417    predicate: impl Fn(Option<&str>, Option<&str>) -> bool,
3418) -> Option<usize> {
3419    (0..from_idx).rev().find(|idx| {
3420        let first = scans[*idx].words.first().map(String::as_str);
3421        let second = scans[*idx].words.get(1).map(String::as_str);
3422        predicate(first, second)
3423    })
3424}
3425
3426fn previous_line_indent_matching(
3427    scans: &[ScanLine<'_>],
3428    from_idx: usize,
3429    predicate: impl Fn(Option<&str>, Option<&str>) -> bool,
3430) -> Option<usize> {
3431    (0..from_idx).rev().find_map(|idx| {
3432        let first = scans[idx].words.first().map(String::as_str);
3433        let second = scans[idx].words.get(1).map(String::as_str);
3434        predicate(first, second).then_some(scans[idx].indent)
3435    })
3436}
3437
3438fn line_contains_inline_on(trimmed: &str) -> bool {
3439    let upper = trimmed.to_ascii_uppercase();
3440    upper.contains(" ON ") && !upper.starts_with("ON ")
3441}
3442
3443fn contains_template_marker(sql: &str) -> bool {
3444    sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
3445}
3446
3447fn is_template_boundary_line(trimmed: &str) -> bool {
3448    trimmed.starts_with("{%")
3449        || trimmed.starts_with("{{")
3450        || trimmed.starts_with("{#")
3451        || trimmed.starts_with("%}")
3452        || trimmed.starts_with("}}")
3453        || trimmed.starts_with("#}")
3454        || trimmed.ends_with("%}")
3455        || trimmed.ends_with("}}")
3456        || trimmed.ends_with("#}")
3457}
3458
3459fn template_only_line_flags(lines: &[&str]) -> Vec<bool> {
3460    let mut flags = vec![false; lines.len()];
3461    let mut multiline_mode: Option<TemplateMultilineMode> = None;
3462    let mut non_sql_depth = 0usize;
3463
3464    for (idx, raw_line) in lines.iter().enumerate() {
3465        let trimmed = raw_line.trim_start();
3466        if trimmed.is_empty() {
3467            continue;
3468        }
3469
3470        if let Some(mode) = multiline_mode {
3471            flags[idx] = true;
3472            if line_closes_multiline_template(trimmed, mode) {
3473                multiline_mode = None;
3474            }
3475            continue;
3476        }
3477
3478        let mut template_only = false;
3479
3480        // Track `{% ... %}` blocks so macro/set bodies are treated as template-only.
3481        let control_tags = template_control_tags_in_line(trimmed);
3482        if !control_tags.is_empty() {
3483            template_only = true;
3484            for tag in &control_tags {
3485                match tag.kind {
3486                    TemplateControlKind::Open => {
3487                        if tag
3488                            .keyword
3489                            .as_deref()
3490                            .is_some_and(is_non_sql_template_keyword)
3491                        {
3492                            non_sql_depth += 1;
3493                        }
3494                    }
3495                    TemplateControlKind::Close => {
3496                        if tag
3497                            .keyword
3498                            .as_deref()
3499                            .is_some_and(is_non_sql_template_keyword)
3500                            && non_sql_depth > 0
3501                        {
3502                            non_sql_depth -= 1;
3503                        }
3504                    }
3505                    TemplateControlKind::Mid => {}
3506                }
3507            }
3508        }
3509
3510        if let Some(mode) = line_starts_multiline_template(trimmed) {
3511            template_only = true;
3512            multiline_mode = Some(mode);
3513        } else if trimmed.starts_with("%}")
3514            || trimmed.starts_with("}}")
3515            || trimmed.starts_with("#}")
3516        {
3517            template_only = true;
3518        }
3519
3520        if (trimmed.starts_with("{{") || trimmed.starts_with("{#") || trimmed.starts_with("{%"))
3521            && !line_has_sql_outside_template_tags(trimmed)
3522        {
3523            template_only = true;
3524        }
3525
3526        if non_sql_depth > 0 {
3527            template_only = true;
3528        }
3529
3530        flags[idx] = template_only;
3531    }
3532
3533    flags
3534}
3535
3536fn line_starts_multiline_template(trimmed: &str) -> Option<TemplateMultilineMode> {
3537    if trimmed.starts_with("{{") && !trimmed.contains("}}") {
3538        return Some(TemplateMultilineMode::Expression);
3539    }
3540    if trimmed.starts_with("{%") && !trimmed.contains("%}") {
3541        return Some(TemplateMultilineMode::Statement);
3542    }
3543    if trimmed.starts_with("{#") && !trimmed.contains("#}") {
3544        return Some(TemplateMultilineMode::Comment);
3545    }
3546    None
3547}
3548
3549fn line_closes_multiline_template(trimmed: &str, mode: TemplateMultilineMode) -> bool {
3550    match mode {
3551        TemplateMultilineMode::Expression => trimmed.contains("}}"),
3552        TemplateMultilineMode::Statement => trimmed.contains("%}"),
3553        TemplateMultilineMode::Comment => trimmed.contains("#}"),
3554    }
3555}
3556
3557fn is_non_sql_template_keyword(keyword: &str) -> bool {
3558    matches!(
3559        keyword,
3560        "macro" | "set" | "call" | "filter" | "raw" | "test"
3561    )
3562}
3563
3564fn template_control_tags_in_line(line: &str) -> Vec<TemplateControlTag> {
3565    let mut out = Vec::new();
3566    let mut cursor = 0usize;
3567
3568    while let Some(open_rel) = line[cursor..].find("{%") {
3569        let open = cursor + open_rel;
3570        let Some(close_rel) = line[open + 2..].find("%}") else {
3571            break;
3572        };
3573        let close = open + 2 + close_rel;
3574        let mut inner = &line[open + 2..close];
3575        inner = inner.trim();
3576        if let Some(stripped) = inner.strip_prefix('-') {
3577            inner = stripped.trim_start();
3578        }
3579        if let Some(stripped) = inner.strip_suffix('-') {
3580            inner = stripped.trim_end();
3581        }
3582        if let Some(first) = inner.split_whitespace().next() {
3583            let first = first.to_ascii_lowercase();
3584            if first.starts_with("end") {
3585                let keyword = first.strip_prefix("end").unwrap_or("").to_string();
3586                out.push(TemplateControlTag {
3587                    kind: TemplateControlKind::Close,
3588                    keyword: (!keyword.is_empty()).then_some(keyword),
3589                });
3590            } else if matches!(first.as_str(), "else" | "elif") {
3591                out.push(TemplateControlTag {
3592                    kind: TemplateControlKind::Mid,
3593                    keyword: None,
3594                });
3595            } else {
3596                out.push(TemplateControlTag {
3597                    kind: TemplateControlKind::Open,
3598                    keyword: Some(first),
3599                });
3600            }
3601        }
3602        cursor = close + 2;
3603    }
3604
3605    out
3606}
3607
3608fn line_has_sql_outside_template_tags(line: &str) -> bool {
3609    let mut index = 0usize;
3610    while index < line.len() {
3611        let rest = &line[index..];
3612        if rest.starts_with("{{") {
3613            let Some(close) = rest.find("}}") else {
3614                return line[..index].chars().any(|ch| !ch.is_whitespace());
3615            };
3616            index += close + 2;
3617            continue;
3618        }
3619        if rest.starts_with("{%") {
3620            let Some(close) = rest.find("%}") else {
3621                return line[..index].chars().any(|ch| !ch.is_whitespace());
3622            };
3623            index += close + 2;
3624            continue;
3625        }
3626        if rest.starts_with("{#") {
3627            let Some(close) = rest.find("#}") else {
3628                return line[..index].chars().any(|ch| !ch.is_whitespace());
3629            };
3630            index += close + 2;
3631            continue;
3632        }
3633
3634        let Some(ch) = rest.chars().next() else {
3635            break;
3636        };
3637        if !ch.is_whitespace() {
3638            return true;
3639        }
3640        index += ch.len_utf8();
3641    }
3642
3643    false
3644}
3645
3646fn templated_detection_confident(sql: &str, indent_unit: usize, tab_space_size: usize) -> bool {
3647    if templated_control_confident_violation(sql, indent_unit, tab_space_size) {
3648        return true;
3649    }
3650
3651    let lines: Vec<&str> = sql.lines().collect();
3652    if lines.is_empty() {
3653        return false;
3654    }
3655
3656    for idx in 0..lines.len() {
3657        let line = lines[idx];
3658        let trimmed = line.trim_start();
3659        if trimmed.is_empty() || is_comment_line(trimmed) {
3660            continue;
3661        }
3662        let indent = line
3663            .chars()
3664            .take_while(|ch| *ch == ' ' || *ch == '\t')
3665            .count();
3666
3667        if let Some(prev_idx) = (0..idx).rev().find(|prev| {
3668            let prev_trim = lines[*prev].trim_start();
3669            !prev_trim.is_empty() && !is_comment_line(prev_trim)
3670        }) {
3671            let prev_trimmed = lines[prev_idx].trim_start();
3672            let prev_upper = prev_trimmed.to_ascii_uppercase();
3673
3674            if lines[prev_idx].trim_end().ends_with(',') && indent == 0 {
3675                return true;
3676            }
3677
3678            if is_content_clause_line(
3679                split_upper_words(&prev_upper).first().map(String::as_str),
3680                &prev_upper,
3681            ) && indent == 0
3682            {
3683                return true;
3684            }
3685        }
3686
3687        if trimmed.starts_with("{{")
3688            && indent == 0
3689            && (0..idx)
3690                .rev()
3691                .find(|prev| {
3692                    let prev_trim = lines[*prev].trim_start();
3693                    !prev_trim.is_empty() && !is_comment_line(prev_trim)
3694                })
3695                .is_some_and(|prev_idx| lines[prev_idx].trim_end().ends_with(','))
3696        {
3697            return true;
3698        }
3699    }
3700
3701    false
3702}
3703
3704fn templated_control_confident_violation(
3705    sql: &str,
3706    indent_unit: usize,
3707    tab_space_size: usize,
3708) -> bool {
3709    let lines: Vec<&str> = sql.lines().collect();
3710    if lines.is_empty() {
3711        return false;
3712    }
3713
3714    let template_only_lines = template_only_line_flags(&lines);
3715    let indent_map = actual_indent_map(sql, tab_space_size);
3716    let mut sql_template_block_indents: Vec<usize> = Vec::new();
3717
3718    for (idx, line) in lines.iter().enumerate() {
3719        let trimmed = line.trim_start();
3720        if trimmed.trim().is_empty() {
3721            continue;
3722        }
3723
3724        let indent = indent_map.get(&idx).copied().unwrap_or(0);
3725        let controls = template_control_tags_in_line(trimmed);
3726        if !controls.is_empty() {
3727            for tag in controls {
3728                match tag.kind {
3729                    TemplateControlKind::Open => {
3730                        if tag
3731                            .keyword
3732                            .as_deref()
3733                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
3734                        {
3735                            sql_template_block_indents.push(indent);
3736                        }
3737                    }
3738                    TemplateControlKind::Mid => {
3739                        if let Some(expected_indent) = sql_template_block_indents.last() {
3740                            if indent != *expected_indent {
3741                                return true;
3742                            }
3743                        }
3744                    }
3745                    TemplateControlKind::Close => {
3746                        if tag
3747                            .keyword
3748                            .as_deref()
3749                            .is_none_or(|keyword| !is_non_sql_template_keyword(keyword))
3750                        {
3751                            if let Some(open_indent) = sql_template_block_indents.pop() {
3752                                if indent != open_indent {
3753                                    return true;
3754                                }
3755                            }
3756                        }
3757                    }
3758                }
3759            }
3760            continue;
3761        }
3762
3763        if template_only_lines.get(idx).copied().unwrap_or(false) {
3764            continue;
3765        }
3766
3767        if !sql_template_block_indents.is_empty() {
3768            let required_indent = sql_template_block_indents[0] + indent_unit;
3769            if indent < required_indent {
3770                return true;
3771            }
3772        }
3773    }
3774
3775    false
3776}
3777
3778/// Build the indent string for a given target width.
3779fn make_indent(
3780    width: usize,
3781    _indent_unit: usize,
3782    tab_space_size: usize,
3783    indent_style: IndentStyle,
3784) -> String {
3785    if width == 0 {
3786        return String::new();
3787    }
3788    match indent_style {
3789        IndentStyle::Spaces => " ".repeat(width),
3790        IndentStyle::Tabs => {
3791            let tab_width = tab_space_size.max(1);
3792            let tab_count = width.div_ceil(tab_width);
3793            "\t".repeat(tab_count)
3794        }
3795    }
3796}
3797
3798/// Per-line summary of token structure.
3799struct LineTokenInfo {
3800    /// True if the line starts with a top-level clause keyword.
3801    starts_with_clause_keyword: bool,
3802    /// True if the line starts with a content-bearing clause keyword that is
3803    /// alone on the line. Content-bearing keywords are those whose content
3804    /// should be indented on the following line (SELECT, FROM, WHERE, SET,
3805    /// RETURNING, HAVING, LIMIT, QUALIFY, WINDOW, DECLARE). Keywords like
3806    /// WITH, CREATE, UNION are excluded because their "content" is other
3807    /// clause-level constructs, not indented content.
3808    is_standalone_content_clause: bool,
3809    /// True if the line contains only comment tokens.
3810    is_comment_only: bool,
3811    /// True if the line starts with a SELECT modifier (DISTINCT, ALL) that
3812    /// belongs with a preceding SELECT keyword rather than being content
3813    /// that should be indented.
3814    starts_with_select_modifier: bool,
3815}
3816
3817/// Keywords whose content on the following line should be indented.
3818fn is_content_bearing_clause(kw: Keyword) -> bool {
3819    matches!(
3820        kw,
3821        Keyword::SELECT
3822            | Keyword::FROM
3823            | Keyword::WHERE
3824            | Keyword::SET
3825            | Keyword::RETURNING
3826            | Keyword::HAVING
3827            | Keyword::LIMIT
3828            | Keyword::QUALIFY
3829            | Keyword::WINDOW
3830            | Keyword::DECLARE
3831            | Keyword::VALUES
3832            | Keyword::UPDATE
3833    )
3834}
3835
3836/// Build per-line token info from the token stream.
3837fn build_line_token_infos(tokens: &[StructuralToken]) -> BTreeMap<usize, LineTokenInfo> {
3838    let mut result: BTreeMap<usize, LineTokenInfo> = BTreeMap::new();
3839
3840    // Group non-trivia tokens by line.
3841    let mut tokens_by_line: BTreeMap<usize, Vec<&StructuralToken>> = BTreeMap::new();
3842    for token in tokens {
3843        if is_whitespace_or_newline(&token.token) {
3844            continue;
3845        }
3846        tokens_by_line.entry(token.line).or_default().push(token);
3847    }
3848
3849    // Track preceding keyword for GROUP BY / ORDER BY detection.
3850    let mut prev_keyword: Option<Keyword> = None;
3851
3852    for (&line, line_tokens) in &tokens_by_line {
3853        let first = &line_tokens[0];
3854        let starts_with_clause = is_first_token_clause_keyword(first, prev_keyword);
3855
3856        // Check if the first keyword is content-bearing.
3857        let first_is_content_bearing = match &first.token {
3858            Token::Word(w) => is_content_bearing_clause(w.keyword),
3859            _ => false,
3860        };
3861
3862        // A clause keyword is "standalone" if all non-trivia tokens on the
3863        // line are clause keywords / modifiers / comments / semicolons.
3864        let is_standalone = starts_with_clause && first_is_content_bearing && {
3865            line_tokens.iter().all(|t| match &t.token {
3866                Token::Word(w) => {
3867                    is_clause_keyword_word(w.keyword)
3868                        || w.keyword == Keyword::NoKeyword && is_join_modifier(&w.value)
3869                }
3870                Token::SemiColon => true,
3871                _ => is_comment_token(&t.token),
3872            })
3873        };
3874
3875        let comment_only = line_tokens.iter().all(|t| is_comment_token(&t.token));
3876
3877        let starts_with_select_modifier = match &first.token {
3878            Token::Word(w) => matches!(w.keyword, Keyword::DISTINCT | Keyword::ALL),
3879            _ => false,
3880        };
3881
3882        result.insert(
3883            line,
3884            LineTokenInfo {
3885                starts_with_clause_keyword: starts_with_clause,
3886                is_standalone_content_clause: is_standalone,
3887                is_comment_only: comment_only,
3888                starts_with_select_modifier,
3889            },
3890        );
3891
3892        // Update prev_keyword from last keyword on this line.
3893        for t in line_tokens.iter().rev() {
3894            if let Token::Word(w) = &t.token {
3895                if w.keyword != Keyword::NoKeyword {
3896                    prev_keyword = Some(w.keyword);
3897                    break;
3898                }
3899            }
3900        }
3901    }
3902
3903    result
3904}
3905
3906fn is_first_token_clause_keyword(token: &StructuralToken, prev_keyword: Option<Keyword>) -> bool {
3907    match &token.token {
3908        Token::Word(w) => is_top_level_clause_keyword(w.keyword, prev_keyword),
3909        _ => false,
3910    }
3911}
3912
3913fn is_comment_token(token: &Token) -> bool {
3914    matches!(
3915        token,
3916        Token::Whitespace(Whitespace::SingleLineComment { .. })
3917            | Token::Whitespace(Whitespace::MultiLineComment(_))
3918    )
3919}
3920
3921/// Tokenize SQL for structural analysis. Falls back to statement-level
3922/// tokenization when document tokens are not available.
3923fn tokenize_for_structural_check(sql: &str, ctx: &LintContext) -> Option<Vec<StructuralToken>> {
3924    // Fall back to statement-level tokenization (document tokens use
3925    // 1-indexed lines which makes correlation harder; local tokenization
3926    // gives 0-indexed consistency).
3927    let dialect = ctx.dialect().to_sqlparser_dialect();
3928    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
3929    let Ok(tokens) = tokenizer.tokenize_with_location() else {
3930        return None;
3931    };
3932
3933    Some(
3934        tokens
3935            .into_iter()
3936            .filter_map(|t| {
3937                let line = t.span.start.line as usize;
3938                let col = t.span.start.column as usize;
3939                let offset = line_col_to_offset(sql, line, col)?;
3940                Some(StructuralToken {
3941                    token: t.token,
3942                    offset,
3943                    line: line.saturating_sub(1),
3944                })
3945            })
3946            .collect(),
3947    )
3948}
3949
3950#[derive(Clone)]
3951struct StructuralToken {
3952    token: Token,
3953    #[allow(dead_code)]
3954    offset: usize,
3955    line: usize,
3956}
3957
3958/// Returns true if the keyword starts a top-level SQL clause.
3959fn is_top_level_clause_keyword(kw: Keyword, _prev_keyword: Option<Keyword>) -> bool {
3960    is_clause_keyword_word(kw)
3961}
3962
3963/// Core set of SQL clause keywords that establish a new indent level.
3964fn is_clause_keyword_word(kw: Keyword) -> bool {
3965    matches!(
3966        kw,
3967        Keyword::SELECT
3968            | Keyword::FROM
3969            | Keyword::WHERE
3970            | Keyword::SET
3971            | Keyword::UPDATE
3972            | Keyword::INSERT
3973            | Keyword::DELETE
3974            | Keyword::MERGE
3975            | Keyword::USING
3976            | Keyword::INTO
3977            | Keyword::VALUES
3978            | Keyword::RETURNING
3979            | Keyword::HAVING
3980            | Keyword::LIMIT
3981            | Keyword::WINDOW
3982            | Keyword::QUALIFY
3983            | Keyword::WITH
3984            | Keyword::BEGIN
3985            | Keyword::DECLARE
3986            | Keyword::IF
3987            | Keyword::RETURNS
3988            | Keyword::CREATE
3989            | Keyword::DROP
3990            | Keyword::ON
3991            | Keyword::JOIN
3992            | Keyword::INNER
3993            | Keyword::LEFT
3994            | Keyword::RIGHT
3995            | Keyword::FULL
3996            | Keyword::CROSS
3997            | Keyword::OUTER
3998    )
3999}
4000
4001fn is_join_modifier(word: &str) -> bool {
4002    let upper = word.to_ascii_uppercase();
4003    matches!(
4004        upper.as_str(),
4005        "JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL" | "CROSS" | "OUTER" | "APPLY"
4006    )
4007}
4008
4009fn is_whitespace_or_newline(token: &Token) -> bool {
4010    matches!(
4011        token,
4012        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
4013    )
4014}
4015
4016/// Build a map of line_index -> actual indent width from the SQL text.
4017fn actual_indent_map(sql: &str, tab_space_size: usize) -> BTreeMap<usize, usize> {
4018    let mut result = BTreeMap::new();
4019    for (idx, line) in sql.lines().enumerate() {
4020        if line.trim().is_empty() {
4021            continue;
4022        }
4023        let indent = leading_indent_from_prefix(line, tab_space_size);
4024        result.insert(idx, indent.width);
4025    }
4026    result
4027}
4028
4029// ---------------------------------------------------------------------------
4030// Original indent snapshot and autofix infrastructure
4031// ---------------------------------------------------------------------------
4032
4033#[derive(Clone, Copy)]
4034struct LeadingIndent {
4035    width: usize,
4036    space_count: usize,
4037    tab_count: usize,
4038    has_mixed_indent_chars: bool,
4039}
4040
4041#[derive(Clone, Copy)]
4042struct LineIndentSnapshot {
4043    line_index: usize,
4044    indent: LeadingIndent,
4045}
4046
4047struct StatementLineInfo {
4048    start: usize,
4049    indent_end: usize,
4050}
4051
4052#[derive(Clone)]
4053struct Lt02AutofixEdit {
4054    start: usize,
4055    end: usize,
4056    replacement: String,
4057}
4058
4059fn should_prefer_lt02_edit(candidate: &Lt02AutofixEdit, current: &Lt02AutofixEdit) -> bool {
4060    // Mirror planner ordering for same-start conflicts:
4061    // lower `end` wins, then lexicographically lower replacement.
4062    if candidate.end != current.end {
4063        return candidate.end < current.end;
4064    }
4065
4066    candidate.replacement < current.replacement
4067}
4068
4069fn should_prefer_lt02_patch(candidate: &IssuePatchEdit, current: &IssuePatchEdit) -> bool {
4070    // Mirror planner ordering for same-start conflicts.
4071    if candidate.span.end != current.span.end {
4072        return candidate.span.end < current.span.end;
4073    }
4074
4075    candidate.replacement < current.replacement
4076}
4077
4078fn collapse_lt02_autofix_edits_by_start(edits: Vec<Lt02AutofixEdit>) -> Vec<Lt02AutofixEdit> {
4079    let mut by_start: BTreeMap<usize, Lt02AutofixEdit> = BTreeMap::new();
4080    for edit in edits {
4081        match by_start.entry(edit.start) {
4082            std::collections::btree_map::Entry::Vacant(entry) => {
4083                entry.insert(edit);
4084            }
4085            std::collections::btree_map::Entry::Occupied(mut entry) => {
4086                if should_prefer_lt02_edit(&edit, entry.get()) {
4087                    entry.insert(edit);
4088                }
4089            }
4090        }
4091    }
4092    by_start.into_values().collect()
4093}
4094
4095fn line_indent_snapshots(ctx: &LintContext, tab_space_size: usize) -> Vec<LineIndentSnapshot> {
4096    if let Some(tokens) = tokenize_with_offsets_for_context(ctx) {
4097        let statement_start_line = offset_to_line(ctx.sql, ctx.statement_range.start);
4098        let mut first_token_by_line: BTreeMap<usize, usize> = BTreeMap::new();
4099        for token in &tokens {
4100            if token.start < ctx.statement_range.start || token.start >= ctx.statement_range.end {
4101                continue;
4102            }
4103            if is_whitespace_token(&token.token) {
4104                continue;
4105            }
4106            first_token_by_line
4107                .entry(token.start_line)
4108                .or_insert(token.start);
4109        }
4110
4111        return first_token_by_line
4112            .into_iter()
4113            .map(|(line, token_start)| {
4114                let line_start = ctx.sql[..token_start]
4115                    .rfind('\n')
4116                    .map_or(0, |index| index + 1);
4117                let leading = &ctx.sql[line_start..token_start];
4118                LineIndentSnapshot {
4119                    line_index: line.saturating_sub(statement_start_line),
4120                    indent: leading_indent_from_prefix(leading, tab_space_size),
4121                }
4122            })
4123            .collect();
4124    }
4125
4126    let sql = ctx.statement_sql();
4127    let Some(tokens) = tokenize_with_locations(sql, ctx.dialect()) else {
4128        return sql
4129            .lines()
4130            .enumerate()
4131            .filter_map(|(line_index, line)| {
4132                if line.trim().is_empty() {
4133                    return None;
4134                }
4135                Some(LineIndentSnapshot {
4136                    line_index,
4137                    indent: leading_indent(line, tab_space_size),
4138                })
4139            })
4140            .collect();
4141    };
4142
4143    let mut first_token_by_line: std::collections::BTreeMap<usize, usize> =
4144        std::collections::BTreeMap::new();
4145    for token in &tokens {
4146        if is_whitespace_token(&token.token) {
4147            continue;
4148        }
4149        let line = token.span.start.line as usize;
4150        let column = token.span.start.column as usize;
4151        first_token_by_line.entry(line).or_insert(column);
4152    }
4153
4154    first_token_by_line
4155        .into_iter()
4156        .filter_map(|(line, column)| {
4157            let line_start = line_col_to_offset(sql, line, 1)?;
4158            let token_start = line_col_to_offset(sql, line, column)?;
4159            let leading = &sql[line_start..token_start];
4160            Some(LineIndentSnapshot {
4161                line_index: line.saturating_sub(1),
4162                indent: leading_indent_from_prefix(leading, tab_space_size),
4163            })
4164        })
4165        .collect()
4166}
4167
4168#[derive(Clone)]
4169struct LocatedToken {
4170    token: Token,
4171    start: usize,
4172    start_line: usize,
4173}
4174
4175fn tokenize_with_locations(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
4176    let dialect = dialect.to_sqlparser_dialect();
4177    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
4178    tokenizer.tokenize_with_location().ok()
4179}
4180
4181fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
4182    ctx.with_document_tokens(|tokens| {
4183        if tokens.is_empty() {
4184            return None;
4185        }
4186
4187        Some(
4188            tokens
4189                .iter()
4190                .filter_map(|token| {
4191                    token_with_span_offsets(ctx.sql, token).map(|(start, _end)| LocatedToken {
4192                        token: token.token.clone(),
4193                        start,
4194                        start_line: token.span.start.line as usize,
4195                    })
4196                })
4197                .collect::<Vec<_>>(),
4198        )
4199    })
4200}
4201
4202fn is_whitespace_token(token: &Token) -> bool {
4203    matches!(
4204        token,
4205        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
4206    )
4207}
4208
4209fn leading_indent(line: &str, tab_space_size: usize) -> LeadingIndent {
4210    leading_indent_from_prefix(line, tab_space_size)
4211}
4212
4213fn leading_indent_from_prefix(prefix: &str, tab_space_size: usize) -> LeadingIndent {
4214    let mut width = 0usize;
4215    let mut space_count = 0usize;
4216    let mut tab_count = 0usize;
4217
4218    for ch in prefix.chars() {
4219        match ch {
4220            ' ' => {
4221                space_count += 1;
4222                width += 1;
4223            }
4224            '\t' => {
4225                tab_count += 1;
4226                width += tab_space_size;
4227            }
4228            _ => break,
4229        }
4230    }
4231
4232    LeadingIndent {
4233        width,
4234        space_count,
4235        tab_count,
4236        has_mixed_indent_chars: space_count > 0 && tab_count > 0,
4237    }
4238}
4239
4240fn indentation_autofix_edits(
4241    statement_sql: &str,
4242    snapshots: &[LineIndentSnapshot],
4243    indent_unit: usize,
4244    tab_space_size: usize,
4245    indent_style: IndentStyle,
4246) -> Vec<Lt02AutofixEdit> {
4247    let line_infos = statement_line_infos(statement_sql);
4248    let mut edits = Vec::new();
4249
4250    for snapshot in snapshots {
4251        let Some(line_info) = line_infos.get(snapshot.line_index) else {
4252            continue;
4253        };
4254        let start = line_info.start;
4255        let end = line_info.start + line_info.indent_end;
4256        if end > statement_sql.len() || start > end {
4257            continue;
4258        }
4259
4260        let current_prefix = &statement_sql[start..end];
4261        let replacement = if snapshot.line_index == 0 {
4262            String::new()
4263        } else {
4264            normalized_indent_replacement(
4265                snapshot.indent.width,
4266                indent_unit,
4267                tab_space_size,
4268                indent_style,
4269            )
4270        };
4271
4272        if replacement != current_prefix {
4273            edits.push(Lt02AutofixEdit {
4274                start,
4275                end,
4276                replacement,
4277            });
4278        }
4279    }
4280
4281    edits
4282}
4283
4284fn statement_line_infos(sql: &str) -> Vec<StatementLineInfo> {
4285    let mut infos = Vec::new();
4286    let mut line_start = 0usize;
4287
4288    for segment in sql.split_inclusive('\n') {
4289        let line = segment.strip_suffix('\n').unwrap_or(segment);
4290        let indent_end = line
4291            .char_indices()
4292            .find_map(|(index, ch)| {
4293                if matches!(ch, ' ' | '\t') {
4294                    None
4295                } else {
4296                    Some(index)
4297                }
4298            })
4299            .unwrap_or(line.len());
4300        infos.push(StatementLineInfo {
4301            start: line_start,
4302            indent_end,
4303        });
4304        line_start += segment.len();
4305    }
4306
4307    infos
4308}
4309
4310fn statement_line_index_for_offset(
4311    line_infos: &[StatementLineInfo],
4312    offset: usize,
4313) -> Option<usize> {
4314    if line_infos.is_empty() {
4315        return None;
4316    }
4317
4318    let idx = line_infos.partition_point(|line| line.start <= offset);
4319    let line_idx = idx
4320        .saturating_sub(1)
4321        .min(line_infos.len().saturating_sub(1));
4322    Some(line_idx)
4323}
4324
4325fn normalized_indent_replacement(
4326    width: usize,
4327    indent_unit: usize,
4328    tab_space_size: usize,
4329    indent_style: IndentStyle,
4330) -> String {
4331    if width == 0 {
4332        return String::new();
4333    }
4334
4335    let rounded = rounded_indent_width(width, indent_unit.max(1));
4336    if rounded == 0 {
4337        return String::new();
4338    }
4339
4340    match indent_style {
4341        IndentStyle::Spaces => " ".repeat(rounded),
4342        IndentStyle::Tabs => {
4343            let tab_width = tab_space_size.max(1);
4344            let tab_count = rounded.div_ceil(tab_width).max(1);
4345            "\t".repeat(tab_count)
4346        }
4347    }
4348}
4349
4350fn rounded_indent_width(width: usize, indent_unit: usize) -> usize {
4351    if width == 0 || indent_unit == 0 {
4352        return width;
4353    }
4354
4355    if width.is_multiple_of(indent_unit) {
4356        return width;
4357    }
4358
4359    let down = (width / indent_unit) * indent_unit;
4360    let up = down + indent_unit;
4361    if down == 0 {
4362        up
4363    } else if width - down <= up - width {
4364        down
4365    } else {
4366        up
4367    }
4368}
4369
4370fn ceil_indent_width(width: usize, indent_unit: usize) -> usize {
4371    if width == 0 || indent_unit == 0 {
4372        return width;
4373    }
4374    width.div_ceil(indent_unit) * indent_unit
4375}
4376
4377fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
4378    if line == 0 || column == 0 {
4379        return None;
4380    }
4381
4382    let mut current_line = 1usize;
4383    let mut current_col = 1usize;
4384
4385    for (offset, ch) in sql.char_indices() {
4386        if current_line == line && current_col == column {
4387            return Some(offset);
4388        }
4389
4390        if ch == '\n' {
4391            current_line += 1;
4392            current_col = 1;
4393        } else {
4394            current_col += 1;
4395        }
4396    }
4397
4398    if current_line == line && current_col == column {
4399        return Some(sql.len());
4400    }
4401
4402    None
4403}
4404
4405fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
4406    let start = line_col_to_offset(
4407        sql,
4408        token.span.start.line as usize,
4409        token.span.start.column as usize,
4410    )?;
4411    let end = line_col_to_offset(
4412        sql,
4413        token.span.end.line as usize,
4414        token.span.end.column as usize,
4415    )?;
4416    Some((start, end))
4417}
4418
4419fn offset_to_line(sql: &str, offset: usize) -> usize {
4420    1 + sql[..offset.min(sql.len())]
4421        .chars()
4422        .filter(|ch| *ch == '\n')
4423        .count()
4424}
4425
4426#[cfg(test)]
4427mod tests {
4428    use super::*;
4429    use crate::linter::config::LintConfig;
4430    use crate::linter::rule::with_active_dialect;
4431    use crate::parser::parse_sql;
4432    use crate::types::{Dialect, IssueAutofixApplicability};
4433
4434    fn run(sql: &str) -> Vec<Issue> {
4435        run_with_config(sql, LintConfig::default())
4436    }
4437
4438    fn run_with_config(sql: &str, config: LintConfig) -> Vec<Issue> {
4439        let statements = parse_sql(sql).expect("parse");
4440        let rule = LayoutIndent::from_config(&config);
4441        statements
4442            .iter()
4443            .enumerate()
4444            .flat_map(|(index, statement)| {
4445                rule.check(
4446                    statement,
4447                    &LintContext {
4448                        sql,
4449                        statement_range: 0..sql.len(),
4450                        statement_index: index,
4451                    },
4452                )
4453            })
4454            .collect()
4455    }
4456
4457    fn run_postgres(sql: &str) -> Vec<Issue> {
4458        with_active_dialect(Dialect::Postgres, || run(sql))
4459    }
4460
4461    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
4462        let autofix = issue.autofix.as_ref()?;
4463        let mut out = sql.to_string();
4464        let mut edits = autofix.edits.clone();
4465        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
4466        for edit in edits.into_iter().rev() {
4467            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
4468        }
4469        Some(out)
4470    }
4471
4472    fn apply_all_issue_autofixes(sql: &str, issues: &[Issue]) -> Option<String> {
4473        let mut all_edits = Vec::new();
4474        for issue in issues {
4475            if let Some(autofix) = issue.autofix.as_ref() {
4476                all_edits.extend(autofix.edits.clone());
4477            }
4478        }
4479        if all_edits.is_empty() {
4480            return None;
4481        }
4482
4483        all_edits.sort_by_key(|edit| (edit.span.start, edit.span.end, edit.replacement.clone()));
4484        all_edits.dedup_by(|left, right| {
4485            left.span.start == right.span.start
4486                && left.span.end == right.span.end
4487                && left.replacement == right.replacement
4488        });
4489
4490        let mut out = sql.to_string();
4491        for edit in all_edits.into_iter().rev() {
4492            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
4493        }
4494        Some(out)
4495    }
4496
4497    fn normalize_whitespace(text: &str) -> String {
4498        text.split_whitespace().collect::<Vec<_>>().join(" ")
4499    }
4500
4501    #[test]
4502    fn flags_odd_indent_width() {
4503        let issues = run("SELECT a\n   , b\nFROM t");
4504        assert_eq!(issues.len(), 1);
4505        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4506    }
4507
4508    #[test]
4509    fn odd_indent_width_emits_safe_autofix() {
4510        let sql = "SELECT a\n   , b\nFROM t";
4511        let issues = run(sql);
4512        assert_eq!(issues.len(), 1);
4513        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
4514        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
4515        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4516        assert_eq!(fixed, "SELECT a\n    , b\nFROM t");
4517    }
4518
4519    #[test]
4520    fn flags_first_line_indentation() {
4521        let issues = run("   SELECT 1");
4522        assert_eq!(issues.len(), 1);
4523        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4524    }
4525
4526    #[test]
4527    fn first_line_indentation_emits_safe_autofix_when_editable() {
4528        let sql = "   SELECT 1";
4529        let issues = run(sql);
4530        assert_eq!(issues.len(), 1);
4531        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
4532        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
4533        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4534        assert_eq!(fixed, "SELECT 1");
4535    }
4536
4537    #[test]
4538    fn does_not_flag_even_indent_width() {
4539        assert!(run("SELECT a\n    , b\nFROM t").is_empty());
4540    }
4541
4542    #[test]
4543    fn flags_mixed_tab_and_space_indentation() {
4544        let issues = run("SELECT a\n \t, b\nFROM t");
4545        assert_eq!(issues.len(), 1);
4546        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4547    }
4548
4549    #[test]
4550    fn tab_space_size_config_is_applied_for_tab_indentation_width() {
4551        let config = LintConfig {
4552            enabled: true,
4553            disabled_rules: vec![],
4554            rule_configs: std::collections::BTreeMap::from([(
4555                "layout.indent".to_string(),
4556                serde_json::json!({"tab_space_size": 2, "indent_unit": "tab"}),
4557            )]),
4558        };
4559        let issues = run_with_config("SELECT a\n\t, b\nFROM t", config);
4560        assert!(issues.is_empty());
4561    }
4562
4563    #[test]
4564    fn tab_indent_unit_disallows_space_indent() {
4565        let config = LintConfig {
4566            enabled: true,
4567            disabled_rules: vec![],
4568            rule_configs: std::collections::BTreeMap::from([(
4569                "layout.indent".to_string(),
4570                serde_json::json!({"indent_unit": "tab"}),
4571            )]),
4572        };
4573        let issues = run_with_config("SELECT a\n    , b\nFROM t", config);
4574        assert_eq!(issues.len(), 1);
4575        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4576    }
4577
4578    #[test]
4579    fn tab_indent_style_emits_tab_autofix() {
4580        let config = LintConfig {
4581            enabled: true,
4582            disabled_rules: vec![],
4583            rule_configs: std::collections::BTreeMap::from([(
4584                "layout.indent".to_string(),
4585                serde_json::json!({"indent_unit": "tab"}),
4586            )]),
4587        };
4588        let sql = "SELECT a\n    , b\nFROM t";
4589        let issues = run_with_config(sql, config);
4590        assert_eq!(issues.len(), 1);
4591        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
4592        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
4593        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4594        assert_eq!(fixed, "SELECT a\n\t, b\nFROM t");
4595    }
4596
4597    #[test]
4598    fn indentation_section_options_are_supported() {
4599        let config = LintConfig {
4600            enabled: true,
4601            disabled_rules: vec![],
4602            rule_configs: std::collections::BTreeMap::from([(
4603                "indentation".to_string(),
4604                serde_json::json!({"indent_unit": "tab", "tab_space_size": 2}),
4605            )]),
4606        };
4607        let issues = run_with_config("SELECT a\n\t, b\nFROM t", config);
4608        assert!(issues.is_empty());
4609    }
4610
4611    #[test]
4612    fn indentation_on_comment_line_is_checked() {
4613        let issues = run("SELECT 1\n   -- comment\nFROM t");
4614        assert_eq!(issues.len(), 1);
4615        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4616    }
4617
4618    #[test]
4619    fn first_line_indent_outside_statement_range_is_report_only() {
4620        let sql = "   SELECT 1";
4621        let statements = parse_sql(sql).expect("parse");
4622        let rule = LayoutIndent::default();
4623        let issues = rule.check(
4624            &statements[0],
4625            &LintContext {
4626                sql,
4627                statement_range: 3..sql.len(),
4628                statement_index: 0,
4629            },
4630        );
4631        assert_eq!(issues.len(), 1);
4632        assert!(
4633            issues[0].autofix.is_none(),
4634            "non-editable first-line prefix should remain report-only"
4635        );
4636    }
4637
4638    #[test]
4639    fn fragmented_non_semicolon_statement_triggers_first_line_indent_guard() {
4640        let sql = "SELECT\n    a";
4641        assert!(
4642            ignore_first_line_indent_for_fragmented_statement(&LintContext {
4643                sql,
4644                statement_range: 7..sql.len(),
4645                statement_index: 1,
4646            }),
4647            "fragmented follow-on statement chunks should ignore first-line LT02"
4648        );
4649    }
4650
4651    // Structural indentation tests.
4652
4653    #[test]
4654    fn flags_clause_content_not_indented_under_update() {
4655        // UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = '';
4656        // "foo" should be indented under UPDATE, "updated" under SET.
4657        let issues = run("UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = '';");
4658        assert_eq!(issues.len(), 1, "should flag unindented clause contents");
4659        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4660    }
4661
4662    #[test]
4663    fn flags_unindented_from_content() {
4664        // FROM\nmy_tbl should flag because my_tbl is not indented.
4665        let issues = run("SELECT\n    a,\n    b\nFROM\nmy_tbl");
4666        assert_eq!(issues.len(), 1);
4667        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4668    }
4669
4670    #[test]
4671    fn accepts_properly_indented_clauses() {
4672        // All clause contents properly indented.
4673        let issues = run("SELECT\n    a,\n    b\nFROM\n    my_tbl\nWHERE\n    a = 1");
4674        assert!(issues.is_empty(), "properly indented SQL should not flag");
4675    }
4676
4677    #[test]
4678    fn flags_trailing_comment_wrong_indent() {
4679        // Trailing comments at deepening indent levels after content at
4680        // indent 0. Both `-- foo` (indent 4) and `-- bar` (indent 8) are
4681        // deeper than `SELECT 1` (indent 0).
4682        let issues = run("SELECT 1\n    -- foo\n        -- bar");
4683        assert_eq!(issues.len(), 1);
4684        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4685    }
4686
4687    #[test]
4688    fn accepts_properly_indented_trailing_comment() {
4689        // Comment at same indent as the content line before it is fine.
4690        let issues = run("SELECT\n    a\n    -- explains next col\n    , b\nFROM t");
4691        assert!(issues.is_empty());
4692    }
4693
4694    // Structural autofix tests.
4695
4696    #[test]
4697    fn structural_autofix_indents_content_under_clause_keyword() {
4698        // RETURNING\nupdated should fix to RETURNING\n    updated
4699        let sql = "INSERT INTO foo (updated)\nVALUES (now())\nRETURNING\nupdated;";
4700        let issues = run(sql);
4701        assert_eq!(issues.len(), 1);
4702        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
4703        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
4704        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4705        assert_eq!(
4706            fixed,
4707            "INSERT INTO foo (updated)\nVALUES (now())\nRETURNING\n    updated;"
4708        );
4709    }
4710
4711    #[test]
4712    fn structural_autofix_indents_update_content() {
4713        // UPDATE\nfoo -> UPDATE\n    foo
4714        let sql = "UPDATE\nfoo\nSET\nupdated = now()\nWHERE\n    bar = ''";
4715        let issues = run(sql);
4716        assert_eq!(issues.len(), 1);
4717        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4718        assert_eq!(
4719            fixed,
4720            "UPDATE\n    foo\nSET\n    updated = now()\nWHERE\n    bar = ''"
4721        );
4722    }
4723
4724    #[test]
4725    fn structural_autofix_indents_from_content() {
4726        let sql = "SELECT\n    a,\n    b\nFROM\nmy_tbl";
4727        let issues = run(sql);
4728        assert_eq!(issues.len(), 1);
4729        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4730        assert_eq!(fixed, "SELECT\n    a,\n    b\nFROM\n    my_tbl");
4731    }
4732
4733    #[test]
4734    fn structural_autofix_fixes_trailing_comment_indent() {
4735        let sql = "SELECT 1\n    -- foo\n        -- bar";
4736        let issues = run(sql);
4737        assert_eq!(issues.len(), 1);
4738        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4739        // Both comments should be at indent 0 (same as content line).
4740        assert_eq!(fixed, "SELECT 1\n-- foo\n-- bar");
4741    }
4742
4743    #[test]
4744    fn structural_autofix_does_not_add_parenthesis_spacing() {
4745        let sql = "SELECT coalesce(foo,\n              bar)\n   FROM tbl";
4746        let issues = run(sql);
4747        assert_eq!(issues.len(), 1);
4748        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
4749        assert_eq!(
4750            normalize_whitespace(&fixed),
4751            "SELECT coalesce(foo, bar) FROM tbl"
4752        );
4753    }
4754
4755    #[test]
4756    fn detects_tsql_successive_else_if_indent_violation() {
4757        let sql = "IF (1 > 1)\n    PRINT 'A';\n    ELSE IF (2 > 2)\n        PRINT 'B';\n        ELSE IF (3 > 3)\n            PRINT 'C';\n            ELSE\n                PRINT 'D';\n";
4758        assert!(detect_tsql_else_if_successive_violation(sql, 4));
4759    }
4760
4761    #[test]
4762    fn allows_tsql_proper_else_if_chain() {
4763        let sql = "IF (1 > 1)\n    PRINT 'A';\nELSE IF (2 > 2)\n    PRINT 'B';\nELSE IF (3 > 3)\n    PRINT 'C';\nELSE\n    PRINT 'D';\n";
4764        assert!(!detect_tsql_else_if_successive_violation(sql, 4));
4765    }
4766
4767    #[test]
4768    fn mssql_partial_parse_fallback_detects_successive_else_if_violation() {
4769        let sql = "IF (1 > 1)\n    PRINT 'A';\n    ELSE IF (2 > 2)\n        PRINT 'B';\n        ELSE IF (3 > 3)\n            PRINT 'C';\n            ELSE\n                PRINT 'D';\n";
4770        let first_statement = "IF (1 > 1)\n    PRINT 'A';";
4771        let placeholder = parse_sql("SELECT 1").expect("parse placeholder");
4772        let rule = LayoutIndent::default();
4773        let issues = with_active_dialect(Dialect::Mssql, || {
4774            rule.check(
4775                &placeholder[0],
4776                &LintContext {
4777                    sql,
4778                    statement_range: 0..first_statement.len(),
4779                    statement_index: 0,
4780                },
4781            )
4782        });
4783        assert_eq!(issues.len(), 1);
4784        assert_eq!(issues[0].code, issue_codes::LINT_LT_002);
4785    }
4786
4787    #[test]
4788    fn postgres_where_inline_condition_chain_autofixes() {
4789        let sql = "SELECT\n    a\nFROM t\nWHERE a = 1\nAND b = 2";
4790        let issues = run_postgres(sql);
4791        assert!(!issues.is_empty());
4792        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4793        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4794        assert_eq!(
4795            fixed,
4796            "SELECT\n    a\nFROM t\nWHERE\n    a = 1\n    AND b = 2"
4797        );
4798    }
4799
4800    #[test]
4801    fn postgres_having_inline_condition_chain_autofixes() {
4802        let sql = "SELECT\n    workspace_id\nFROM t\nHAVING SUM(cost_usd) > 0\n    AND AVG(cost_usd) < 10";
4803        let issues = run_postgres(sql);
4804        assert!(!issues.is_empty());
4805        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4806        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4807        assert_eq!(
4808            fixed,
4809            "SELECT\n    workspace_id\nFROM t\nHAVING\n    SUM(cost_usd) > 0\n    AND AVG(cost_usd) < 10"
4810        );
4811    }
4812
4813    #[test]
4814    fn postgres_where_inline_operator_continuation_autofixes() {
4815        let sql = "SELECT\n    1\nFROM t\nWHERE is_active\n= TRUE";
4816        let issues = run_postgres(sql);
4817        assert!(!issues.is_empty());
4818        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4819        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4820        assert_eq!(
4821            fixed,
4822            "SELECT\n    1\nFROM t\nWHERE\n    is_active\n    = TRUE"
4823        );
4824    }
4825
4826    #[test]
4827    fn postgres_standalone_where_block_autofixes_with_other_lt02_violation() {
4828        let sql = "SELECT\n   1\nFROM t\nWHERE\na = 1\nAND b = 2";
4829        let issues = run_postgres(sql);
4830        assert!(!issues.is_empty());
4831        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4832        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4833        assert_eq!(
4834            fixed,
4835            "SELECT\n    1\nFROM t\nWHERE\n    a = 1\n    AND b = 2"
4836        );
4837    }
4838
4839    #[test]
4840    fn postgres_standalone_where_block_under_exists_autofixes() {
4841        let sql = "SELECT\n    1\nFROM t\nWHERE\n    EXISTS (\n        SELECT 1 FROM ledger.cluster_live_status cls\n        JOIN ledger.cluster c ON c.cluster_id = cls.cluster_id\n        WHERE\n          c.id = i.subject_id::uuid\n          AND cls.state != 'RUNNING'\n    )";
4842        let mut postgres_only = sql.to_string();
4843        let mut postgres_only_edits = collapse_lt02_autofix_edits_by_start(
4844            postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces),
4845        );
4846        let line_infos = statement_line_infos(sql);
4847        let debug_edits: Vec<String> = postgres_only_edits
4848            .iter()
4849            .map(|edit| {
4850                let line = statement_line_index_for_offset(&line_infos, edit.start)
4851                    .map(|line_idx| line_idx + 1)
4852                    .unwrap_or(0);
4853                format!(
4854                    "line={line} start={} end={} replacement={:?}",
4855                    edit.start,
4856                    edit.end,
4857                    edit.replacement.replace(' ', "ยท")
4858                )
4859            })
4860            .collect();
4861        postgres_only_edits.sort_by(|left, right| right.start.cmp(&left.start));
4862        for edit in postgres_only_edits {
4863            if edit.start <= edit.end && edit.end <= postgres_only.len() {
4864                postgres_only.replace_range(edit.start..edit.end, &edit.replacement);
4865            }
4866        }
4867        assert!(
4868            postgres_only.contains(
4869                "        WHERE\n            c.id = i.subject_id::uuid\n            AND cls.state != 'RUNNING'"
4870            ),
4871            "postgres structural edits should indent standalone WHERE block, got:\n{postgres_only}\nedits={debug_edits:#?}"
4872        );
4873
4874        let issues = run_postgres(sql);
4875        assert!(!issues.is_empty());
4876        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4877        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4878        assert!(
4879            fixed.contains(
4880                "        WHERE\n            c.id = i.subject_id::uuid\n            AND cls.state != 'RUNNING'"
4881            ),
4882            "nested standalone WHERE block should indent condition lines under WHERE, got:\n{fixed}"
4883        );
4884    }
4885
4886    #[test]
4887    fn postgres_trailing_as_alias_break_autofixes() {
4888        let sql = "SELECT\n    o.id AS\n    org_unit_id\nFROM t AS o";
4889        let issues = run_postgres(sql);
4890        assert!(!issues.is_empty());
4891        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4892        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4893        assert_eq!(
4894            fixed,
4895            "SELECT\n    o.id\n        AS\n        org_unit_id\nFROM t AS o"
4896        );
4897    }
4898
4899    #[test]
4900    fn postgres_on_conflict_set_block_autofixes() {
4901        let sql = "INSERT INTO foo (id, value)\nVALUES (1, 'x')\nON CONFLICT (id) DO UPDATE\nSET value = EXCLUDED.value,\nupdated_at = NOW()";
4902        let issues = run_postgres(sql);
4903        assert!(!issues.is_empty());
4904        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4905        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4906        assert_eq!(
4907            fixed,
4908            "INSERT INTO foo (id, value)\nVALUES (1, 'x')\nON CONFLICT (id) DO UPDATE\n    SET\n        value = EXCLUDED.value,\n        updated_at = NOW()"
4909        );
4910    }
4911
4912    #[test]
4913    fn postgres_select_after_union_operator_autofixes() {
4914        let sql = "SELECT\n    1 AS a\nUNION ALL\nSELECT a, b";
4915        let issues = run_postgres(sql);
4916        assert!(!issues.is_empty());
4917        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4918        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4919        assert_eq!(fixed, "SELECT\n    1 AS a\nUNION ALL\nSELECT\n    a, b");
4920    }
4921
4922    #[test]
4923    fn postgres_unioned_identifier_does_not_trigger_union_select_break() {
4924        let sql = "WITH unioned AS (\n    SELECT a, b\n    FROM t\n)\nSELECT\n    a\nFROM unioned";
4925        let issues = run_postgres(sql);
4926        assert!(
4927            issues.is_empty(),
4928            "UNIONED identifier should not be treated like a UNION set operator"
4929        );
4930    }
4931
4932    #[test]
4933    fn postgres_where_block_with_nested_subquery_autofixes() {
4934        let sql =
4935            "SELECT\n    1\nFROM t\nWHERE a = 1\nAND b IN (\nSELECT 1\nWHERE TRUE\n)\nAND c = 2";
4936        let issues = run_postgres(sql);
4937        assert!(!issues.is_empty());
4938        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4939        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4940        assert_eq!(
4941            fixed,
4942            "SELECT\n    1\nFROM t\nWHERE\n    a = 1\n    AND b IN (\n        SELECT 1\n        WHERE TRUE\n    )\n    AND c = 2"
4943        );
4944    }
4945
4946    #[test]
4947    fn postgres_inline_join_on_with_operator_continuation_autofixes() {
4948        let sql = "SELECT\n    1\nFROM foo AS f\nINNER\nJOIN bar AS b ON f.id = b.id AND b.is_current\n= TRUE";
4949        let issues = run_postgres(sql);
4950        assert!(!issues.is_empty());
4951        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4952        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4953        assert_eq!(
4954            fixed,
4955            "SELECT\n    1\nFROM foo AS f\nINNER\nJOIN bar AS b\n    ON f.id = b.id AND b.is_current\n        = TRUE"
4956        );
4957    }
4958
4959    #[test]
4960    fn postgres_standalone_on_block_with_nested_parens_autofixes() {
4961        let sql = "SELECT\n    1\nFROM foo AS c\nLEFT JOIN bar AS v\n    ON\n    -- Non-PIPELINE: exact version_key match\n        (c.cluster_source <> 'PIPELINE' AND c.dbr_version = v.version_key)\n    OR\n    -- PIPELINE: match on main_version + photon\n    (c.cluster_source = 'PIPELINE'\n    AND c.parsed_main_version = v.main_version\n    AND c.parsed_is_photon = v.is_photon\n    AND v.is_lts = TRUE)";
4962        let mut fixed = sql.to_string();
4963        let mut edits = collapse_lt02_autofix_edits_by_start(
4964            postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces),
4965        );
4966        edits.sort_by(|left, right| right.start.cmp(&left.start));
4967        for edit in edits {
4968            if edit.start <= edit.end && edit.end <= fixed.len() {
4969                fixed.replace_range(edit.start..edit.end, &edit.replacement);
4970            }
4971        }
4972
4973        assert!(
4974            fixed.contains(
4975                "        OR\n        -- PIPELINE: match on main_version + photon\n        (c.cluster_source = 'PIPELINE'\n            AND c.parsed_main_version = v.main_version\n            AND c.parsed_is_photon = v.is_photon\n            AND v.is_lts = TRUE)"
4976            ),
4977            "standalone ON block should indent nested AND conditions, got:\n{fixed}"
4978        );
4979    }
4980
4981    #[test]
4982    fn postgres_inline_case_when_block_autofixes() {
4983        let sql =
4984            "SELECT\n    CASE WHEN a > 0\n        THEN 1\n        ELSE 0\n    END AS x\nFROM t";
4985        let issues = run_postgres(sql);
4986        assert!(!issues.is_empty());
4987        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
4988        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
4989        assert_eq!(
4990            fixed,
4991            "SELECT\n    CASE\n        WHEN a > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t"
4992        );
4993    }
4994
4995    #[test]
4996    fn postgres_when_trailing_continuation_autofixes() {
4997        let sql = "SELECT\n    CASE\n        WHEN COALESCE(a,\n            b) > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t";
4998        let issues = run_postgres(sql);
4999        assert!(!issues.is_empty());
5000        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
5001        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5002        assert_eq!(
5003            fixed,
5004            "SELECT\n    CASE\n        WHEN\n            COALESCE(a,\n            b) > 0\n            THEN 1\n        ELSE 0\n    END AS x\nFROM t"
5005        );
5006    }
5007
5008    #[test]
5009    fn postgres_then_trailing_continuation_autofixes() {
5010        let sql = "SELECT\n    CASE\n        WHEN a > 0\n            THEN GREATEST(0,\n                a - 1)\n        ELSE 0\n    END AS x\nFROM t";
5011        let issues = run_postgres(sql);
5012        assert!(!issues.is_empty());
5013        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
5014        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5015        assert_eq!(
5016            fixed,
5017            "SELECT\n    CASE\n        WHEN a > 0\n            THEN\n                GREATEST(0,\n                a - 1)\n        ELSE 0\n    END AS x\nFROM t"
5018        );
5019    }
5020
5021    #[test]
5022    fn postgres_partition_by_multiline_continuation_autofixes() {
5023        let sql = "SELECT\n    SUM(cost_usd) OVER (PARTITION BY workspace_id,\n        usage_date) AS running_cost\nFROM t";
5024        let issues = run_postgres(sql);
5025        assert!(!issues.is_empty());
5026        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
5027        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5028        assert_eq!(
5029            fixed,
5030            "SELECT\n    SUM(cost_usd) OVER (PARTITION BY\n        workspace_id,\n        usage_date) AS running_cost\nFROM t"
5031        );
5032    }
5033
5034    #[test]
5035    fn postgres_select_star_with_continuation_autofixes() {
5036        let sql = "SELECT\n    *\nFROM (\n    SELECT *,\n        ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) AS rn\n    FROM t\n) AS ranked";
5037        let issues = run_postgres(sql);
5038        assert!(!issues.is_empty());
5039        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
5040        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5041        assert_eq!(
5042            fixed,
5043            "SELECT\n    *\nFROM (\n    SELECT\n        *,\n        ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) AS rn\n    FROM t\n) AS ranked"
5044        );
5045    }
5046
5047    #[test]
5048    fn postgres_case_when_multiline_expression_autofixes() {
5049        let sql = "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE WHEN SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files";
5050        let issues = run_postgres(sql);
5051        assert!(
5052            issues.iter().any(|issue| issue.autofix.is_some()),
5053            "expected LT02 autofix for CASE WHEN multiline expression"
5054        );
5055        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5056        assert_eq!(
5057            fixed,
5058            "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE\n        WHEN\n            SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files"
5059        );
5060    }
5061
5062    #[test]
5063    fn postgres_case_when_multiline_expression_generates_keyword_edits() {
5064        let sql = "SELECT\n    workspace_id,\n    table_full_name,\n    usage_date,\n    COUNT(*) AS query_count,\n    SUM(COALESCE(pruned_files_count, 0)) AS total_pruned_files,\n    SUM(COALESCE(read_files_count, 0)) AS total_read_files,\n    CASE WHEN SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n        0)) > 0\n            THEN\n            SUM(COALESCE(pruned_files_count, 0))::numeric\n            / SUM(COALESCE(pruned_files_count, 0) + COALESCE(read_files_count,\n                0))\n    END AS pruning_ratio\nFROM query_files";
5065        let edits = postgres_keyword_break_and_indent_edits(sql, 4, 4, IndentStyle::Spaces);
5066        assert!(
5067            edits.iter().any(|edit| edit.replacement.contains('\n')),
5068            "expected postgres keyword-break edits for CASE WHEN multiline expression"
5069        );
5070    }
5071
5072    #[test]
5073    fn postgres_partition_by_inline_case_autofixes() {
5074        let sql = "SELECT\n    ROW_NUMBER() OVER (\n        PARTITION BY CASE\n            WHEN a > 0\n            THEN 1\n            ELSE 0\n        END\n    ) AS rn\nFROM t";
5075        let issues = run_postgres(sql);
5076        assert!(!issues.is_empty());
5077        assert!(issues.iter().any(|issue| issue.autofix.is_some()));
5078        let fixed = apply_all_issue_autofixes(sql, &issues).expect("apply all autofixes");
5079        assert_eq!(
5080            fixed,
5081            "SELECT\n    ROW_NUMBER() OVER (\n        PARTITION BY\n            CASE\n                WHEN a > 0\n                    THEN 1\n                ELSE 0\n            END\n    ) AS rn\nFROM t"
5082        );
5083    }
5084}