1use 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 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 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 } 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 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 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 autofix_edits = collapse_lt02_autofix_edits_by_start(autofix_edits);
352
353 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
483fn 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 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 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
2667fn 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
2720fn 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 if ctx.is_templated() {
2751 return Vec::new();
2752 }
2753
2754 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 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 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 if let Some(&next_line) = lines.get(i + 1) {
2786 let next_info = &line_infos[&next_line];
2787 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 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 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 } 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 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
3778fn 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
3798struct LineTokenInfo {
3800 starts_with_clause_keyword: bool,
3802 is_standalone_content_clause: bool,
3809 is_comment_only: bool,
3811 starts_with_select_modifier: bool,
3815}
3816
3817fn 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
3836fn build_line_token_infos(tokens: &[StructuralToken]) -> BTreeMap<usize, LineTokenInfo> {
3838 let mut result: BTreeMap<usize, LineTokenInfo> = BTreeMap::new();
3839
3840 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 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 let first_is_content_bearing = match &first.token {
3858 Token::Word(w) => is_content_bearing_clause(w.keyword),
3859 _ => false,
3860 };
3861
3862 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 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
3921fn tokenize_for_structural_check(sql: &str, ctx: &LintContext) -> Option<Vec<StructuralToken>> {
3924 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
3958fn is_top_level_clause_keyword(kw: Keyword, _prev_keyword: Option<Keyword>) -> bool {
3960 is_clause_keyword_word(kw)
3961}
3962
3963fn 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
4016fn 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#[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 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 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 #[test]
4654 fn flags_clause_content_not_indented_under_update() {
4655 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 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 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 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 let issues = run("SELECT\n a\n -- explains next col\n , b\nFROM t");
4691 assert!(issues.is_empty());
4692 }
4693
4694 #[test]
4697 fn structural_autofix_indents_content_under_clause_keyword() {
4698 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 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 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}