Skip to main content

flowscope_cli/
fix.rs

1//! SQL lint auto-fix helpers.
2//!
3//! Fixing is best-effort and deterministic. We combine:
4//! - AST rewrites for structurally safe transforms.
5//! - Text rewrites for parity-style formatting/convention rules.
6//! - Lint before/after comparison to report per-rule removed violations.
7
8use crate::fix_engine::{
9    apply_edits as apply_patch_edits, derive_protected_ranges, plan_fixes, BlockedReason,
10    Edit as PatchEdit, Fix as PatchFix, FixApplicability as PatchApplicability,
11    ProtectedRange as PatchProtectedRange, ProtectedRangeKind as PatchProtectedRangeKind,
12};
13use flowscope_core::linter::config::canonicalize_rule_code;
14use flowscope_core::{
15    analyze, issue_codes, parse_sql_with_dialect, AnalysisOptions, AnalyzeRequest, Dialect, Issue,
16    IssueAutofixApplicability, LintConfig, ParseError,
17};
18#[cfg(feature = "templating")]
19use flowscope_core::{TemplateConfig, TemplateMode};
20use sqlparser::ast::helpers::attached_token::AttachedToken;
21use sqlparser::ast::*;
22use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
23use std::borrow::Cow;
24use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
25use std::hash::{Hash, Hasher};
26use std::time::{Duration, Instant};
27
28/// Compute a 64-bit hash of a string for cheap cycle detection.
29fn hash_sql(sql: &str) -> u64 {
30    let mut h = std::collections::hash_map::DefaultHasher::new();
31    sql.hash(&mut h);
32    h.finish()
33}
34
35#[derive(Debug, Default, Clone, PartialEq, Eq)]
36#[must_use]
37pub struct FixCounts {
38    /// Per-rule fix counts, ordered by rule code for deterministic output.
39    by_rule: BTreeMap<String, usize>,
40}
41
42impl FixCounts {
43    pub fn total(&self) -> usize {
44        self.by_rule.values().sum()
45    }
46
47    pub fn add(&mut self, code: &str, count: usize) {
48        if count == 0 {
49            return;
50        }
51        *self.by_rule.entry(code.to_string()).or_insert(0) += count;
52    }
53
54    pub fn get(&self, code: &str) -> usize {
55        self.by_rule.get(code).copied().unwrap_or(0)
56    }
57
58    pub fn merge(&mut self, other: &Self) {
59        for (code, count) in &other.by_rule {
60            self.add(code, *count);
61        }
62    }
63
64    fn from_removed(before: &BTreeMap<String, usize>, after: &BTreeMap<String, usize>) -> Self {
65        let mut out = Self::default();
66        for (code, before_count) in before {
67            let after_count = after.get(code).copied().unwrap_or(0);
68            if *before_count > after_count {
69                out.add(code, before_count - after_count);
70            }
71        }
72        out
73    }
74}
75
76#[derive(Debug, Clone)]
77#[must_use]
78pub struct FixOutcome {
79    pub sql: String,
80    pub counts: FixCounts,
81    pub changed: bool,
82    pub skipped_due_to_comments: bool,
83    pub skipped_due_to_regression: bool,
84    pub skipped_counts: FixSkippedCounts,
85}
86
87#[derive(Debug, Clone)]
88#[must_use]
89pub struct FixLintState {
90    issues: Vec<Issue>,
91    counts: BTreeMap<String, usize>,
92}
93
94impl FixLintState {
95    fn from_issues(issues: Vec<Issue>) -> Self {
96        let counts = lint_rule_counts_from_issues(&issues);
97        Self { issues, counts }
98    }
99
100    pub fn counts(&self) -> &BTreeMap<String, usize> {
101        &self.counts
102    }
103}
104
105#[derive(Debug, Clone)]
106#[must_use]
107pub struct FixPassResult {
108    pub outcome: FixOutcome,
109    pub post_lint_state: FixLintState,
110}
111
112#[derive(Debug, Default, Clone, PartialEq, Eq)]
113#[must_use]
114pub struct FixSkippedCounts {
115    pub unsafe_skipped: usize,
116    pub protected_range_blocked: usize,
117    pub overlap_conflict_blocked: usize,
118    pub display_only: usize,
119}
120
121#[derive(Debug, Clone, Copy)]
122#[must_use]
123pub struct FixOptions {
124    pub include_unsafe_fixes: bool,
125    pub include_rewrite_candidates: bool,
126}
127
128impl Default for FixOptions {
129    fn default() -> Self {
130        Self {
131            include_unsafe_fixes: false,
132            include_rewrite_candidates: true,
133        }
134    }
135}
136
137/// Max bounded fix passes per file during lint fix execution.
138///
139/// Three passes capture the vast majority of cascading fixes while avoiding
140/// disproportionate long-tail runtime on large statements.
141const MAX_LINT_FIX_PASSES: usize = 3;
142/// Extra cleanup passes granted at the end of the normal loop budget when
143/// progress is still being made.
144const MAX_LINT_FIX_BONUS_PASSES: usize = 1;
145/// Allow one additional large-SQL cleanup pass when LT02 has been improving.
146///
147/// This narrowly recovers the last indentation edge cases without reopening
148/// the broad long-tail cost of unrestricted bonus passes.
149const MAX_LINT_FIX_LARGE_SQL_LT02_EXTRA_PASSES: usize = 1;
150/// SQL byte length above which an extra LT02 cleanup pass is considered.
151/// 10 KB targets the large statements where LT02 cascading indentation fixes
152/// benefit most from one more pass.
153const LINT_FIX_LARGE_SQL_LT02_EXTRA_PASS_THRESHOLD: usize = 10_000;
154/// Stop extra cleanup passes on large SQL when LT02/LT03 are no longer moving
155/// and residual violations are overwhelmingly known mostly-unfixable classes.
156const LINT_FIX_MOSTLY_UNFIXABLE_STOP_THRESHOLD: usize = 4_000;
157/// If at most this many residual violations belong to fixable rules, the
158/// statement is considered "mostly unfixable" and extra passes are skipped.
159const MAX_RESIDUAL_POTENTIALLY_FIXABLE_FOR_STOP: usize = 2;
160/// Denominator for the mostly-unfixable ratio check: fixable residuals must be
161/// at most 1/N of total remaining violations (20% when N=5).
162const MOSTLY_UNFIXABLE_RATIO_DENOMINATOR: usize = 5;
163/// Hard wall-clock timeout for the entire multi-pass fix loop per file.
164/// This is a safety net for pathological inputs; typical files finish in
165/// well under a second.
166const FIX_LOOP_TIMEOUT: Duration = Duration::from_secs(30);
167
168/// Runtime configuration for the multi-pass lint-fix execution.
169///
170/// Maps CLI/API flags to the internal fix engine options.
171#[derive(Debug, Clone, Copy, Default)]
172pub struct LintFixRuntimeOptions {
173    pub include_unsafe_fixes: bool,
174    pub legacy_ast_fixes: bool,
175}
176
177/// Aggregate statistics about fix candidates that were skipped or blocked
178/// across all passes of a multi-pass lint-fix execution.
179#[derive(Debug, Clone, Copy, Default)]
180pub struct FixCandidateStats {
181    pub skipped: usize,
182    /// Total blocked candidates (sum of all `blocked_*` fields).
183    pub blocked: usize,
184    pub blocked_unsafe: usize,
185    pub blocked_display_only: usize,
186    pub blocked_protected_range: usize,
187    pub blocked_overlap_conflict: usize,
188}
189
190impl FixCandidateStats {
191    pub fn total_skipped_or_blocked(self) -> usize {
192        self.skipped + self.blocked
193    }
194
195    pub fn merge(&mut self, other: Self) {
196        self.skipped += other.skipped;
197        self.blocked += other.blocked;
198        self.blocked_unsafe += other.blocked_unsafe;
199        self.blocked_display_only += other.blocked_display_only;
200        self.blocked_protected_range += other.blocked_protected_range;
201        self.blocked_overlap_conflict += other.blocked_overlap_conflict;
202    }
203}
204
205/// Result of a multi-pass lint-fix execution.
206///
207/// Combines the final [`FixOutcome`] with aggregate [`FixCandidateStats`]
208/// collected across all passes.
209#[derive(Debug, Clone)]
210pub struct LintFixExecution {
211    pub outcome: FixOutcome,
212    pub candidate_stats: FixCandidateStats,
213}
214
215#[derive(Debug, Clone, Default)]
216struct RuleFilter {
217    disabled: HashSet<String>,
218    st005_forbid_subquery_in: St005ForbidSubqueryIn,
219}
220
221#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
222enum St005ForbidSubqueryIn {
223    Both,
224    #[default]
225    Join,
226    From,
227}
228
229impl St005ForbidSubqueryIn {
230    fn forbid_from(self) -> bool {
231        matches!(self, Self::Both | Self::From)
232    }
233
234    fn forbid_join(self) -> bool {
235        matches!(self, Self::Both | Self::Join)
236    }
237}
238
239impl RuleFilter {
240    fn from_lint_config(lint_config: &LintConfig) -> Self {
241        let disabled: HashSet<String> = lint_config
242            .disabled_rules
243            .iter()
244            .filter_map(|rule| {
245                let trimmed = rule.trim();
246                if trimmed.is_empty() {
247                    return None;
248                }
249                Some(
250                    canonicalize_rule_code(trimmed).unwrap_or_else(|| trimmed.to_ascii_uppercase()),
251                )
252            })
253            .collect();
254        let st005_forbid_subquery_in = match lint_config
255            .rule_option_str(issue_codes::LINT_ST_005, "forbid_subquery_in")
256            .unwrap_or("join")
257            .to_ascii_lowercase()
258            .as_str()
259        {
260            "from" => St005ForbidSubqueryIn::From,
261            "both" => St005ForbidSubqueryIn::Both,
262            _ => St005ForbidSubqueryIn::Join,
263        };
264        Self {
265            disabled,
266            st005_forbid_subquery_in,
267        }
268    }
269
270    fn allows(&self, code: &str) -> bool {
271        let canonical =
272            canonicalize_rule_code(code).unwrap_or_else(|| code.trim().to_ascii_uppercase());
273        !self.disabled.contains(&canonical)
274    }
275
276    fn with_rule_disabled(&self, code: &str) -> Self {
277        let mut updated = self.clone();
278        let canonical =
279            canonicalize_rule_code(code).unwrap_or_else(|| code.trim().to_ascii_uppercase());
280        updated.disabled.insert(canonical);
281        updated
282    }
283}
284
285struct FixProfileGuard {
286    enabled: bool,
287    started_at: Instant,
288    sql_len: usize,
289    include_rewrite_candidates: bool,
290    include_unsafe_fixes: bool,
291    marks: Vec<(&'static str, Duration)>,
292}
293
294impl FixProfileGuard {
295    fn new(sql_len: usize, fix_options: FixOptions) -> Self {
296        Self {
297            enabled: std::env::var_os("FLOWSCOPE_FIX_PROFILE").is_some(),
298            started_at: Instant::now(),
299            sql_len,
300            include_rewrite_candidates: fix_options.include_rewrite_candidates,
301            include_unsafe_fixes: fix_options.include_unsafe_fixes,
302            marks: Vec::new(),
303        }
304    }
305
306    fn record(&mut self, label: &'static str, started: Instant) {
307        if self.enabled {
308            self.marks.push((label, started.elapsed()));
309        }
310    }
311}
312
313impl Drop for FixProfileGuard {
314    fn drop(&mut self) {
315        if !self.enabled {
316            return;
317        }
318
319        let total = self.started_at.elapsed();
320        let mut summary = format!(
321            "flowscope: fix-profile sql_len={} rewrite={} unsafe={} total={:.2}ms",
322            self.sql_len,
323            self.include_rewrite_candidates,
324            self.include_unsafe_fixes,
325            total.as_secs_f64() * 1000.0
326        );
327        for (label, duration) in &self.marks {
328            summary.push_str(&format!(
329                " | {label}={:.2}ms",
330                duration.as_secs_f64() * 1000.0
331            ));
332        }
333        eprintln!("{summary}");
334    }
335}
336
337/// Apply deterministic lint fixes to a SQL document.
338///
339/// Notes:
340/// - Fixes are planned as localized patches and applied only when non-overlapping.
341/// - Parse errors are returned so callers can decide whether to continue linting.
342pub fn apply_lint_fixes(
343    sql: &str,
344    dialect: Dialect,
345    disabled_rules: &[String],
346) -> Result<FixOutcome, ParseError> {
347    apply_lint_fixes_with_lint_config(
348        sql,
349        dialect,
350        &LintConfig {
351            enabled: true,
352            disabled_rules: disabled_rules.to_vec(),
353            rule_configs: BTreeMap::new(),
354        },
355    )
356}
357
358pub fn apply_lint_fixes_with_lint_config(
359    sql: &str,
360    dialect: Dialect,
361    lint_config: &LintConfig,
362) -> Result<FixOutcome, ParseError> {
363    apply_lint_fixes_with_options(
364        sql,
365        dialect,
366        lint_config,
367        FixOptions {
368            // Preserve existing behavior for direct/internal callers.
369            include_unsafe_fixes: true,
370            include_rewrite_candidates: true,
371        },
372    )
373}
374
375pub fn apply_lint_fixes_with_options_and_lint_state(
376    sql: &str,
377    dialect: Dialect,
378    lint_config: &LintConfig,
379    fix_options: FixOptions,
380    before_lint_state: Option<FixLintState>,
381) -> Result<FixPassResult, ParseError> {
382    apply_lint_fixes_with_options_internal(
383        sql,
384        dialect,
385        lint_config,
386        fix_options,
387        before_lint_state,
388    )
389}
390
391pub fn apply_lint_fixes_with_options(
392    sql: &str,
393    dialect: Dialect,
394    lint_config: &LintConfig,
395    fix_options: FixOptions,
396) -> Result<FixOutcome, ParseError> {
397    Ok(
398        apply_lint_fixes_with_options_internal(sql, dialect, lint_config, fix_options, None)?
399            .outcome,
400    )
401}
402
403/// Apply lint fixes using a bounded multi-pass loop with cascading fallback.
404///
405/// Each pass applies safe fixes and re-lints to capture cascading improvements.
406/// The loop terminates when no further progress is made, the pass budget is
407/// exhausted, or a hard wall-clock timeout is reached.
408pub fn apply_lint_fixes_with_runtime_options(
409    sql: &str,
410    dialect: Dialect,
411    lint_config: &LintConfig,
412    runtime_options: LintFixRuntimeOptions,
413) -> Result<LintFixExecution, ParseError> {
414    let fix_options = FixOptions {
415        include_unsafe_fixes: runtime_options.include_unsafe_fixes,
416        include_rewrite_candidates: runtime_options.legacy_ast_fixes,
417    };
418
419    let mut current_sql = sql.to_string();
420    let mut merged_counts = FixCounts::default();
421    let mut merged_candidate_stats = FixCandidateStats::default();
422    let mut any_changed = false;
423    let mut lt03_touched = false;
424    let mut lt02_touched = false;
425    let mut last_outcome = None;
426    let mut cached_lint_state: Option<FixLintState> = None;
427    let mut seen_sql: HashSet<u64> = HashSet::from([hash_sql(&current_sql)]);
428    let mut overlap_retried_sql: HashSet<u64> = HashSet::new();
429    let mut pass_limit = MAX_LINT_FIX_PASSES;
430    let mut bonus_passes_granted = 0usize;
431    let mut large_sql_lt02_extra_passes_granted = 0usize;
432    let mut pass_index = 0usize;
433    let fix_started_at = Instant::now();
434
435    while pass_index < pass_limit {
436        if pass_index > 0 && fix_started_at.elapsed() >= FIX_LOOP_TIMEOUT {
437            break;
438        }
439        let pass_result = apply_lint_fixes_with_options_and_lint_state(
440            &current_sql,
441            dialect,
442            lint_config,
443            fix_options,
444            cached_lint_state.take(),
445        )?;
446        let outcome = pass_result.outcome;
447        let post_lint_state = pass_result.post_lint_state;
448
449        // Avoid oscillating between previously seen SQL states across passes.
450        if outcome.changed && !seen_sql.insert(hash_sql(&outcome.sql)) {
451            break;
452        }
453
454        merged_counts.merge(&outcome.counts);
455        merged_candidate_stats.merge(collect_fix_candidate_stats(&outcome, runtime_options));
456        if outcome.counts.get(issue_codes::LINT_LT_003) > 0 {
457            lt03_touched = true;
458        }
459        if outcome.counts.get(issue_codes::LINT_LT_002) > 0 {
460            lt02_touched = true;
461        }
462        let lt_cleanup_progress = outcome.counts.get(issue_codes::LINT_LT_003) > 0
463            || outcome.counts.get(issue_codes::LINT_LT_002) > 0;
464        let lt02_remaining = post_lint_state
465            .counts()
466            .get(issue_codes::LINT_LT_002)
467            .copied()
468            .unwrap_or(0)
469            > 0;
470        let residual_is_mostly_unfixable = is_mostly_unfixable_residual(post_lint_state.counts());
471
472        if outcome.changed {
473            any_changed = true;
474            current_sql = outcome.sql.clone();
475        }
476        cached_lint_state = Some(post_lint_state);
477
478        let mut continue_fixing = outcome.changed
479            && !outcome.skipped_due_to_comments
480            && !outcome.skipped_due_to_regression;
481        if continue_fixing
482            && should_stop_large_mostly_unfixable(
483                pass_index,
484                current_sql.len(),
485                lt02_touched,
486                lt03_touched,
487                lt_cleanup_progress,
488                residual_is_mostly_unfixable,
489            )
490        {
491            continue_fixing = false;
492        }
493        // Only retry overlap conflicts once per unique SQL state: re-running on
494        // unchanged SQL would produce the same conflicts and waste the pass budget.
495        let overlap_retry = !outcome.changed
496            && !outcome.skipped_due_to_comments
497            && !outcome.skipped_due_to_regression
498            && outcome.skipped_counts.overlap_conflict_blocked > 0
499            && overlap_retried_sql.insert(hash_sql(&current_sql));
500
501        // Some files keep improving right at the bounded pass budget. Allow a
502        // small number of extra cleanup passes to avoid near-miss leftovers.
503        if (continue_fixing || overlap_retry)
504            && pass_index + 1 == pass_limit
505            && bonus_passes_granted < MAX_LINT_FIX_BONUS_PASSES
506            && (overlap_retry || lt03_touched || lt02_touched)
507        {
508            pass_limit += 1;
509            bonus_passes_granted += 1;
510        }
511
512        if continue_fixing
513            && pass_index + 1 == pass_limit
514            && bonus_passes_granted >= MAX_LINT_FIX_BONUS_PASSES
515            && large_sql_lt02_extra_passes_granted < MAX_LINT_FIX_LARGE_SQL_LT02_EXTRA_PASSES
516            && current_sql.len() >= LINT_FIX_LARGE_SQL_LT02_EXTRA_PASS_THRESHOLD
517            && lt02_remaining
518        {
519            pass_limit += 1;
520            large_sql_lt02_extra_passes_granted += 1;
521        }
522
523        last_outcome = Some(outcome);
524
525        if !continue_fixing && !overlap_retry {
526            break;
527        }
528
529        pass_index += 1;
530    }
531
532    let mut outcome = last_outcome.expect("at least one fix pass should run");
533    if any_changed {
534        outcome.sql = current_sql;
535        outcome.changed = true;
536        outcome.counts = merged_counts;
537        // Multi-pass terminated after no further changes or bounded pass limit.
538        outcome.skipped_due_to_comments = false;
539        outcome.skipped_due_to_regression = false;
540    }
541
542    Ok(LintFixExecution {
543        outcome,
544        candidate_stats: merged_candidate_stats,
545    })
546}
547
548fn collect_fix_candidate_stats(
549    outcome: &FixOutcome,
550    runtime_options: LintFixRuntimeOptions,
551) -> FixCandidateStats {
552    let blocked_unsafe = if runtime_options.include_unsafe_fixes {
553        0
554    } else {
555        outcome.skipped_counts.unsafe_skipped
556    };
557    let blocked_display_only = outcome.skipped_counts.display_only;
558    let blocked_protected_range = outcome.skipped_counts.protected_range_blocked;
559    let blocked_overlap_conflict = outcome.skipped_counts.overlap_conflict_blocked;
560    let blocked =
561        blocked_unsafe + blocked_display_only + blocked_protected_range + blocked_overlap_conflict;
562
563    let stats = FixCandidateStats {
564        skipped: 0,
565        blocked,
566        blocked_unsafe,
567        blocked_display_only,
568        blocked_protected_range,
569        blocked_overlap_conflict,
570    };
571    debug_assert_eq!(
572        stats.blocked,
573        stats.blocked_unsafe
574            + stats.blocked_display_only
575            + stats.blocked_protected_range
576            + stats.blocked_overlap_conflict,
577        "blocked total must equal sum of blocked_* components"
578    );
579    stats
580}
581
582fn is_mostly_unfixable_rule(code: &str) -> bool {
583    matches!(
584        code,
585        issue_codes::LINT_AL_003
586            | issue_codes::LINT_RF_002
587            | issue_codes::LINT_RF_004
588            | issue_codes::LINT_LT_005
589    )
590}
591
592fn is_mostly_unfixable_residual(after_counts: &BTreeMap<String, usize>) -> bool {
593    let mut residual_total = 0usize;
594    let mut potentially_fixable = 0usize;
595
596    for (code, count) in after_counts {
597        if *count == 0 || code == issue_codes::PARSE_ERROR {
598            continue;
599        }
600        residual_total += *count;
601        if !is_mostly_unfixable_rule(code) {
602            potentially_fixable += *count;
603            if potentially_fixable > MAX_RESIDUAL_POTENTIALLY_FIXABLE_FOR_STOP {
604                return false;
605            }
606        }
607    }
608
609    if residual_total == 0 {
610        return false;
611    }
612
613    potentially_fixable * MOSTLY_UNFIXABLE_RATIO_DENOMINATOR <= residual_total
614}
615
616/// Whether to stop granting extra cleanup passes on large SQL whose residual
617/// violations are overwhelmingly in known mostly-unfixable rule classes
618/// (AL003, RF002, RF004, LT005) and indentation rules have stopped moving.
619fn should_stop_large_mostly_unfixable(
620    pass_index: usize,
621    sql_len: usize,
622    lt02_touched: bool,
623    lt03_touched: bool,
624    lt_cleanup_progress: bool,
625    residual_is_mostly_unfixable: bool,
626) -> bool {
627    pass_index + 1 >= MAX_LINT_FIX_PASSES
628        && sql_len >= LINT_FIX_MOSTLY_UNFIXABLE_STOP_THRESHOLD
629        && !lt02_touched
630        && !lt03_touched
631        && !lt_cleanup_progress
632        && residual_is_mostly_unfixable
633}
634
635fn apply_lint_fixes_with_options_internal(
636    sql: &str,
637    dialect: Dialect,
638    lint_config: &LintConfig,
639    fix_options: FixOptions,
640    before_lint_state: Option<FixLintState>,
641) -> Result<FixPassResult, ParseError> {
642    let mut profile = FixProfileGuard::new(sql.len(), fix_options);
643    // Statements above this byte length use tighter iteration budgets to avoid
644    // disproportionate runtime on large/complex SQL.  4 KB covers ~95% of
645    // real-world statements while keeping the expensive tail bounded.
646    const INCREMENTAL_LARGE_SQL_THRESHOLD: usize = 4_000;
647
648    // Parse-error recovery: try a handful of single-rule passes to find a fix
649    // set that does not introduce new parse errors.  Large SQL gets a single
650    // pass with a single rule evaluation to keep cost O(rules) not O(rules²).
651    const INCREMENTAL_MAX_ITERATIONS_PARSE_ERROR: usize = 4;
652    const INCREMENTAL_MAX_ITERATIONS_PARSE_ERROR_LARGE_SQL: usize = 1;
653    const INCREMENTAL_MAX_RULE_EVALUATIONS_PARSE_ERROR_LARGE_SQL: usize = 1;
654
655    // Default incremental plan: up to 24 iterations lets most multi-rule
656    // cascades converge.  Large SQL halves this to keep wall-clock bounded.
657    const INCREMENTAL_MAX_ITERATIONS_DEFAULT: usize = 24;
658    const INCREMENTAL_MAX_ITERATIONS_DEFAULT_LARGE_SQL: usize = 12;
659
660    // Overlap recovery: retry conflicting edits one rule at a time.  8
661    // iterations suffice for typical conflict counts (capped separately by
662    // MAX_OVERLAP_CONFLICTS_FOR_INCREMENTAL_RECOVERY).  Large SQL is
663    // restricted to a single pass with a single rule to avoid quadratic cost.
664    const INCREMENTAL_MAX_ITERATIONS_OVERLAP_RECOVERY: usize = 8;
665    const INCREMENTAL_MAX_ITERATIONS_OVERLAP_RECOVERY_LARGE_SQL: usize = 1;
666    const INCREMENTAL_MAX_RULE_EVALUATIONS_OVERLAP_RECOVERY_LARGE_SQL: usize = 1;
667    let is_large_sql = sql.len() >= INCREMENTAL_LARGE_SQL_THRESHOLD;
668    let incremental_parse_error_iterations = if is_large_sql {
669        INCREMENTAL_MAX_ITERATIONS_PARSE_ERROR_LARGE_SQL
670    } else {
671        INCREMENTAL_MAX_ITERATIONS_PARSE_ERROR
672    };
673    let incremental_default_iterations = if is_large_sql {
674        INCREMENTAL_MAX_ITERATIONS_DEFAULT_LARGE_SQL
675    } else {
676        INCREMENTAL_MAX_ITERATIONS_DEFAULT
677    };
678    let incremental_parse_error_rule_evaluations = if is_large_sql {
679        INCREMENTAL_MAX_RULE_EVALUATIONS_PARSE_ERROR_LARGE_SQL
680    } else {
681        usize::MAX
682    };
683    let incremental_overlap_recovery_iterations = if is_large_sql {
684        INCREMENTAL_MAX_ITERATIONS_OVERLAP_RECOVERY_LARGE_SQL
685    } else {
686        INCREMENTAL_MAX_ITERATIONS_OVERLAP_RECOVERY
687    };
688    let incremental_overlap_recovery_rule_evaluations = if is_large_sql {
689        INCREMENTAL_MAX_RULE_EVALUATIONS_OVERLAP_RECOVERY_LARGE_SQL
690    } else {
691        usize::MAX
692    };
693    let rule_filter = RuleFilter::from_lint_config(lint_config);
694
695    let (before_issues, before_counts) = if let Some(state) = before_lint_state {
696        let stage_started = Instant::now();
697        profile.record("lint_state_cached", stage_started);
698        (state.issues, state.counts)
699    } else {
700        let stage_started = Instant::now();
701        let before_issues = lint_issues(sql, dialect, lint_config);
702        profile.record("lint_issues_before", stage_started);
703
704        let stage_started = Instant::now();
705        let before_counts = lint_rule_counts_from_issues(&before_issues);
706        profile.record("before_counts", stage_started);
707        (before_issues, before_counts)
708    };
709
710    let stage_started = Instant::now();
711    let mut core_candidates = build_fix_candidates_from_issue_autofixes(sql, &before_issues);
712    core_candidates.extend(build_al001_fallback_candidates(
713        sql,
714        dialect,
715        &before_issues,
716        lint_config,
717    ));
718    profile.record("core_candidates", stage_started);
719
720    let stage_started = Instant::now();
721    let core_autofix_rules =
722        collect_core_autofix_rules(&before_issues, fix_options.include_unsafe_fixes);
723    profile.record("core_autofix_rules", stage_started);
724    let mut candidates = Vec::new();
725
726    if fix_options.include_rewrite_candidates {
727        let rewrite_stage_started = Instant::now();
728        let safe_rule_filter = if fix_options.include_unsafe_fixes {
729            rule_filter.clone()
730        } else {
731            // Structural subquery-to-CTE rewrites are useful but higher risk and
732            // therefore opt-in under `--unsafe-fixes`.
733            rule_filter.with_rule_disabled(issue_codes::LINT_ST_005)
734        };
735
736        let mut statements = parse_sql_with_dialect(sql, dialect)?;
737        for stmt in &mut statements {
738            fix_statement(stmt, &safe_rule_filter);
739        }
740
741        let rewritten_sql = render_statements(&statements, sql);
742        let rewritten_sql = if safe_rule_filter.allows(issue_codes::LINT_AL_001) {
743            apply_configured_table_alias_style(&rewritten_sql, dialect, lint_config)
744        } else {
745            preserve_original_table_alias_style(sql, &rewritten_sql, dialect)
746        };
747
748        let mut rewrite_candidates = build_fix_candidates_from_rewrite(
749            sql,
750            &rewritten_sql,
751            FixCandidateApplicability::Safe,
752            FixCandidateSource::PrimaryRewrite,
753        );
754        if !fix_options.include_unsafe_fixes {
755            let mut unsafe_statements = parse_sql_with_dialect(sql, dialect)?;
756            for stmt in &mut unsafe_statements {
757                fix_statement(stmt, &rule_filter);
758            }
759            let unsafe_sql = render_statements(&unsafe_statements, sql);
760            let unsafe_sql = if rule_filter.allows(issue_codes::LINT_AL_001) {
761                apply_configured_table_alias_style(&unsafe_sql, dialect, lint_config)
762            } else {
763                preserve_original_table_alias_style(sql, &unsafe_sql, dialect)
764            };
765            if unsafe_sql != rewritten_sql {
766                rewrite_candidates.extend(build_fix_candidates_from_rewrite(
767                    sql,
768                    &unsafe_sql,
769                    FixCandidateApplicability::Unsafe,
770                    FixCandidateSource::UnsafeFallback,
771                ));
772            }
773        }
774
775        candidates.extend(rewrite_candidates);
776        profile.record("rewrite_candidates", rewrite_stage_started);
777    }
778
779    candidates.extend(core_candidates.iter().cloned());
780
781    let stage_started = Instant::now();
782    let protected_ranges =
783        collect_comment_protected_ranges(sql, dialect, !fix_options.include_unsafe_fixes);
784    profile.record("protected_ranges", stage_started);
785
786    let stage_started = Instant::now();
787    let planned = plan_fix_candidates(
788        sql,
789        candidates,
790        &protected_ranges,
791        fix_options.include_unsafe_fixes,
792    );
793    profile.record("plan_fix_candidates", stage_started);
794
795    let stage_started = Instant::now();
796    let mut fixed_sql = apply_planned_edits(sql, &planned.edits);
797    profile.record("apply_planned_edits", stage_started);
798
799    let stage_started = Instant::now();
800    let mut after_lint_state = if fixed_sql == sql {
801        FixLintState {
802            issues: before_issues.clone(),
803            counts: before_counts.clone(),
804        }
805    } else {
806        lint_state(&fixed_sql, dialect, lint_config)
807    };
808    let mut after_counts = after_lint_state.counts.clone();
809    profile.record("after_counts", stage_started);
810
811    let before_total = regression_guard_total(&before_counts);
812    let after_total = regression_guard_total(&after_counts);
813    let mut skipped_counts = planned.skipped.clone();
814
815    if parse_errors_increased(&before_counts, &after_counts) {
816        if let Some(result) = try_fallback_fix(
817            sql,
818            dialect,
819            lint_config,
820            &before_counts,
821            &before_issues,
822            &core_candidates,
823            &protected_ranges,
824            fix_options.include_unsafe_fixes,
825            incremental_parse_error_iterations,
826            incremental_parse_error_rule_evaluations,
827            &mut profile,
828            "fallback_core_only_parse_errors",
829            "fallback_incremental_parse_errors",
830        ) {
831            return Ok(result);
832        }
833
834        return Ok(FixPassResult {
835            post_lint_state: FixLintState {
836                issues: before_issues.clone(),
837                counts: before_counts.clone(),
838            },
839            outcome: FixOutcome {
840                sql: sql.to_string(),
841                counts: FixCounts::default(),
842                changed: false,
843                skipped_due_to_comments: false,
844                skipped_due_to_regression: true,
845                skipped_counts,
846            },
847        });
848    }
849
850    if fix_options.include_rewrite_candidates
851        && core_autofix_rules_not_improved(&before_counts, &after_counts, &core_autofix_rules)
852    {
853        if let Some(result) = try_fallback_fix(
854            sql,
855            dialect,
856            lint_config,
857            &before_counts,
858            &before_issues,
859            &core_candidates,
860            &protected_ranges,
861            fix_options.include_unsafe_fixes,
862            incremental_default_iterations,
863            usize::MAX,
864            &mut profile,
865            "fallback_core_only_rewrite_guard",
866            "fallback_incremental_rewrite_guard",
867        ) {
868            return Ok(result);
869        }
870    }
871
872    // Strict regression guard: never apply a fix set that increases total
873    // violations, and also retry with core-only planning when net totals are
874    // flat but per-rule regressions mask improvements.
875    let masked_or_worse = after_total > before_total
876        || (after_total == before_total
877            && after_counts != before_counts
878            && core_autofix_rules_not_improved(&before_counts, &after_counts, &core_autofix_rules));
879    if masked_or_worse {
880        if let Some(result) = try_fallback_fix(
881            sql,
882            dialect,
883            lint_config,
884            &before_counts,
885            &before_issues,
886            &core_candidates,
887            &protected_ranges,
888            fix_options.include_unsafe_fixes,
889            incremental_default_iterations,
890            usize::MAX,
891            &mut profile,
892            "fallback_core_only_masked_or_worse",
893            "fallback_incremental_masked_or_worse",
894        ) {
895            return Ok(result);
896        }
897
898        return Ok(FixPassResult {
899            post_lint_state: FixLintState {
900                issues: before_issues.clone(),
901                counts: before_counts.clone(),
902            },
903            outcome: FixOutcome {
904                sql: sql.to_string(),
905                counts: FixCounts::default(),
906                changed: false,
907                skipped_due_to_comments: false,
908                skipped_due_to_regression: true,
909                skipped_counts,
910            },
911        });
912    }
913
914    // Incremental overlap recovery can be very expensive on large/highly
915    // conflicted statements. Cap this path to low-conflict cases where the
916    // extra per-rule search is most likely to pay off.
917    const MAX_OVERLAP_CONFLICTS_FOR_INCREMENTAL_RECOVERY: usize = 8;
918    const MAX_OVERLAP_CONFLICTS_FOR_INCREMENTAL_RECOVERY_LARGE_SQL: usize = 8;
919    let overlap_recovery_conflict_limit = if is_large_sql {
920        MAX_OVERLAP_CONFLICTS_FOR_INCREMENTAL_RECOVERY_LARGE_SQL
921    } else {
922        MAX_OVERLAP_CONFLICTS_FOR_INCREMENTAL_RECOVERY
923    };
924    if !fix_options.include_rewrite_candidates
925        && skipped_counts.overlap_conflict_blocked > 0
926        && skipped_counts.overlap_conflict_blocked <= overlap_recovery_conflict_limit
927    {
928        let stage_started = Instant::now();
929        if let Some(incremental) = try_incremental_core_fix_plan(
930            &fixed_sql,
931            dialect,
932            lint_config,
933            &after_counts,
934            Some(after_lint_state.issues.as_slice()),
935            fix_options.include_unsafe_fixes,
936            incremental_overlap_recovery_iterations,
937            incremental_overlap_recovery_rule_evaluations,
938        ) {
939            profile.record("incremental_overlap_recovery", stage_started);
940            merge_skipped_counts(&mut skipped_counts, &incremental.skipped_counts);
941            fixed_sql = incremental.sql;
942            let recount_started = Instant::now();
943            after_lint_state = lint_state(&fixed_sql, dialect, lint_config);
944            after_counts = after_lint_state.counts.clone();
945            profile.record("after_counts_overlap_recovery", recount_started);
946        } else {
947            profile.record("incremental_overlap_recovery", stage_started);
948        }
949    }
950
951    let stage_started = Instant::now();
952    let counts = FixCounts::from_removed(&before_counts, &after_counts);
953    profile.record("final_removed_counts", stage_started);
954
955    if counts.total() == 0 {
956        return Ok(FixPassResult {
957            post_lint_state: FixLintState {
958                issues: before_issues.clone(),
959                counts: before_counts.clone(),
960            },
961            outcome: FixOutcome {
962                sql: sql.to_string(),
963                counts,
964                changed: false,
965                skipped_due_to_comments: false,
966                skipped_due_to_regression: false,
967                skipped_counts,
968            },
969        });
970    }
971    let changed = fixed_sql != sql;
972
973    Ok(FixPassResult {
974        post_lint_state: after_lint_state,
975        outcome: FixOutcome {
976            sql: fixed_sql,
977            counts,
978            changed,
979            skipped_due_to_comments: false,
980            skipped_due_to_regression: false,
981            skipped_counts,
982        },
983    })
984}
985
986/// Check whether SQL contains comment markers outside of quoted regions.
987#[cfg(test)]
988fn contains_comment_markers(sql: &str, dialect: Dialect) -> bool {
989    #[derive(Clone, Copy, PartialEq, Eq)]
990    enum ScanMode {
991        Outside,
992        SingleQuote,
993        DoubleQuote,
994        BacktickQuote,
995        BracketQuote,
996    }
997
998    let bytes = sql.as_bytes();
999    let mut mode = ScanMode::Outside;
1000    let mut i = 0usize;
1001
1002    while i < bytes.len() {
1003        let b = bytes[i];
1004        let next = bytes.get(i + 1).copied();
1005
1006        match mode {
1007            ScanMode::Outside => {
1008                if b == b'\'' {
1009                    mode = ScanMode::SingleQuote;
1010                    i += 1;
1011                    continue;
1012                }
1013                if b == b'"' {
1014                    mode = ScanMode::DoubleQuote;
1015                    i += 1;
1016                    continue;
1017                }
1018                if b == b'`' {
1019                    mode = ScanMode::BacktickQuote;
1020                    i += 1;
1021                    continue;
1022                }
1023                if b == b'[' {
1024                    mode = ScanMode::BracketQuote;
1025                    i += 1;
1026                    continue;
1027                }
1028
1029                if b == b'-' && next == Some(b'-') {
1030                    return true;
1031                }
1032                if b == b'/' && next == Some(b'*') {
1033                    return true;
1034                }
1035                if matches!(dialect, Dialect::Mysql) && b == b'#' {
1036                    return true;
1037                }
1038
1039                i += 1;
1040            }
1041            ScanMode::SingleQuote => {
1042                if b == b'\'' && next == Some(b'\'') {
1043                    i += 2;
1044                } else if b == b'\'' {
1045                    mode = ScanMode::Outside;
1046                    i += 1;
1047                } else {
1048                    i += 1;
1049                }
1050            }
1051            ScanMode::DoubleQuote => {
1052                if b == b'"' && next == Some(b'"') {
1053                    i += 2;
1054                } else if b == b'"' {
1055                    mode = ScanMode::Outside;
1056                    i += 1;
1057                } else {
1058                    i += 1;
1059                }
1060            }
1061            ScanMode::BacktickQuote => {
1062                if b == b'`' && next == Some(b'`') {
1063                    i += 2;
1064                } else if b == b'`' {
1065                    mode = ScanMode::Outside;
1066                    i += 1;
1067                } else {
1068                    i += 1;
1069                }
1070            }
1071            ScanMode::BracketQuote => {
1072                if b == b']' && next == Some(b']') {
1073                    i += 2;
1074                } else if b == b']' {
1075                    mode = ScanMode::Outside;
1076                    i += 1;
1077                } else {
1078                    i += 1;
1079                }
1080            }
1081        }
1082    }
1083
1084    false
1085}
1086
1087fn render_statements(statements: &[Statement], original: &str) -> String {
1088    let mut rendered = statements
1089        .iter()
1090        .map(ToString::to_string)
1091        .collect::<Vec<_>>()
1092        .join(";\n");
1093
1094    if statements.len() > 1 || original.trim_end().ends_with(';') {
1095        rendered.push(';');
1096    }
1097
1098    rendered
1099}
1100
1101fn lint_rule_counts(
1102    sql: &str,
1103    dialect: Dialect,
1104    lint_config: &LintConfig,
1105) -> BTreeMap<String, usize> {
1106    let issues = lint_issues(sql, dialect, lint_config);
1107    lint_rule_counts_from_issues(&issues)
1108}
1109
1110fn lint_state(sql: &str, dialect: Dialect, lint_config: &LintConfig) -> FixLintState {
1111    let issues = lint_issues(sql, dialect, lint_config);
1112    FixLintState::from_issues(issues)
1113}
1114
1115fn lint_issues(sql: &str, dialect: Dialect, lint_config: &LintConfig) -> Vec<Issue> {
1116    let mut result = analyze(&AnalyzeRequest {
1117        sql: sql.to_string(),
1118        files: None,
1119        dialect,
1120        source_name: None,
1121        options: Some(AnalysisOptions {
1122            lint: Some(lint_config.clone()),
1123            ..Default::default()
1124        }),
1125        schema: None,
1126        #[cfg(feature = "templating")]
1127        template_config: None,
1128    });
1129
1130    #[cfg(feature = "templating")]
1131    {
1132        if contains_template_markers(sql)
1133            && issues_have_parse_errors(&result.issues)
1134            && template_retry_enabled_for_fixes(lint_config)
1135        {
1136            let jinja_result = analyze(&AnalyzeRequest {
1137                sql: sql.to_string(),
1138                files: None,
1139                dialect,
1140                source_name: None,
1141                options: Some(AnalysisOptions {
1142                    lint: Some(lint_config.clone()),
1143                    ..Default::default()
1144                }),
1145                schema: None,
1146                template_config: Some(TemplateConfig {
1147                    mode: TemplateMode::Jinja,
1148                    context: HashMap::new(),
1149                }),
1150            });
1151
1152            result = if issues_have_template_errors(&jinja_result.issues) {
1153                analyze(&AnalyzeRequest {
1154                    sql: sql.to_string(),
1155                    files: None,
1156                    dialect,
1157                    source_name: None,
1158                    options: Some(AnalysisOptions {
1159                        lint: Some(lint_config.clone()),
1160                        ..Default::default()
1161                    }),
1162                    schema: None,
1163                    template_config: Some(TemplateConfig {
1164                        mode: TemplateMode::Dbt,
1165                        context: HashMap::new(),
1166                    }),
1167                })
1168            } else {
1169                jinja_result
1170            };
1171        }
1172    }
1173
1174    result
1175        .issues
1176        .into_iter()
1177        .filter(|issue| issue.code.starts_with("LINT_") || issue.code == issue_codes::PARSE_ERROR)
1178        .collect()
1179}
1180
1181#[cfg(feature = "templating")]
1182fn contains_template_markers(sql: &str) -> bool {
1183    sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
1184}
1185
1186#[cfg(feature = "templating")]
1187fn template_retry_enabled_for_fixes(lint_config: &LintConfig) -> bool {
1188    let registry_config = LintConfig {
1189        enabled: true,
1190        disabled_rules: vec![],
1191        rule_configs: BTreeMap::new(),
1192    };
1193    let enabled_codes: Vec<String> = flowscope_core::linter::rules::all_rules(&registry_config)
1194        .into_iter()
1195        .map(|rule| rule.code().to_string())
1196        .filter(|code| lint_config.is_rule_enabled(code))
1197        .collect();
1198
1199    if enabled_codes.len() != 1 {
1200        return false;
1201    }
1202
1203    let only_code = &enabled_codes[0];
1204    only_code.eq_ignore_ascii_case(issue_codes::LINT_LT_004)
1205        || only_code.eq_ignore_ascii_case(issue_codes::LINT_LT_007)
1206        || only_code.eq_ignore_ascii_case(issue_codes::LINT_CP_003)
1207}
1208
1209#[cfg(feature = "templating")]
1210fn issues_have_parse_errors(issues: &[Issue]) -> bool {
1211    issues
1212        .iter()
1213        .any(|issue| issue.code == issue_codes::PARSE_ERROR)
1214}
1215
1216#[cfg(feature = "templating")]
1217fn issues_have_template_errors(issues: &[Issue]) -> bool {
1218    issues
1219        .iter()
1220        .any(|issue| issue.code == issue_codes::TEMPLATE_ERROR)
1221}
1222
1223fn lint_rule_counts_from_issues(issues: &[Issue]) -> BTreeMap<String, usize> {
1224    let mut counts = BTreeMap::new();
1225    for issue in issues {
1226        *counts.entry(issue.code.clone()).or_insert(0usize) += 1;
1227    }
1228    counts
1229}
1230
1231fn collect_core_autofix_rules(issues: &[Issue], allow_unsafe: bool) -> HashSet<String> {
1232    issues
1233        .iter()
1234        .filter_map(|issue| {
1235            let autofix = issue.autofix.as_ref()?;
1236            let applicable = match autofix.applicability {
1237                IssueAutofixApplicability::Safe => true,
1238                IssueAutofixApplicability::Unsafe => allow_unsafe,
1239                IssueAutofixApplicability::DisplayOnly => false,
1240            };
1241            if applicable && core_autofix_conflict_priority(Some(issue.code.as_str())) == 0 {
1242                Some(issue.code.clone())
1243            } else {
1244                None
1245            }
1246        })
1247        .collect()
1248}
1249
1250fn core_autofix_rules_not_improved(
1251    before_counts: &BTreeMap<String, usize>,
1252    after_counts: &BTreeMap<String, usize>,
1253    core_autofix_rules: &HashSet<String>,
1254) -> bool {
1255    let lt03_improved = after_counts
1256        .get(issue_codes::LINT_LT_003)
1257        .copied()
1258        .unwrap_or(0)
1259        < before_counts
1260            .get(issue_codes::LINT_LT_003)
1261            .copied()
1262            .unwrap_or(0);
1263
1264    core_autofix_rules.iter().any(|code| {
1265        if lt03_improved && code == issue_codes::LINT_LT_005 {
1266            // Allow LT03 fixes that trade one LT05 long-line violation for one
1267            // LT03 operator-layout violation at equal total counts.
1268            return false;
1269        }
1270        let before_count = before_counts.get(code).copied().unwrap_or(0);
1271        before_count > 0 && after_counts.get(code).copied().unwrap_or(0) >= before_count
1272    })
1273}
1274
1275fn parse_errors_increased(
1276    before_counts: &BTreeMap<String, usize>,
1277    after_counts: &BTreeMap<String, usize>,
1278) -> bool {
1279    after_counts
1280        .get(issue_codes::PARSE_ERROR)
1281        .copied()
1282        .unwrap_or(0)
1283        > before_counts
1284            .get(issue_codes::PARSE_ERROR)
1285            .copied()
1286            .unwrap_or(0)
1287}
1288
1289fn regression_guard_total(counts: &BTreeMap<String, usize>) -> usize {
1290    counts.values().copied().sum()
1291}
1292
1293/// Try core-only then incremental fallback plans, returning the first
1294/// successful `FixPassResult`.  Used when the primary fix plan causes a
1295/// regression (parse errors, rewrite guard, or masked/worse counts).
1296#[allow(clippy::too_many_arguments)]
1297fn try_fallback_fix(
1298    sql: &str,
1299    dialect: Dialect,
1300    lint_config: &LintConfig,
1301    before_counts: &BTreeMap<String, usize>,
1302    before_issues: &[Issue],
1303    core_candidates: &[FixCandidate],
1304    protected_ranges: &[PatchProtectedRange],
1305    allow_unsafe: bool,
1306    incremental_iterations: usize,
1307    incremental_rule_evaluations: usize,
1308    profile: &mut FixProfileGuard,
1309    core_label: &'static str,
1310    incremental_label: &'static str,
1311) -> Option<FixPassResult> {
1312    let stage_started = Instant::now();
1313    if let Some(outcome) = try_core_only_fix_plan(
1314        sql,
1315        dialect,
1316        lint_config,
1317        before_counts,
1318        core_candidates,
1319        protected_ranges,
1320        allow_unsafe,
1321    ) {
1322        profile.record(core_label, stage_started);
1323        return Some(FixPassResult {
1324            post_lint_state: lint_state(&outcome.sql, dialect, lint_config),
1325            outcome,
1326        });
1327    }
1328    profile.record(core_label, stage_started);
1329
1330    let stage_started = Instant::now();
1331    if let Some(outcome) = try_incremental_core_fix_plan(
1332        sql,
1333        dialect,
1334        lint_config,
1335        before_counts,
1336        Some(before_issues),
1337        allow_unsafe,
1338        incremental_iterations,
1339        incremental_rule_evaluations,
1340    ) {
1341        profile.record(incremental_label, stage_started);
1342        return Some(FixPassResult {
1343            post_lint_state: lint_state(&outcome.sql, dialect, lint_config),
1344            outcome,
1345        });
1346    }
1347    profile.record(incremental_label, stage_started);
1348
1349    None
1350}
1351
1352fn try_core_only_fix_plan(
1353    sql: &str,
1354    dialect: Dialect,
1355    lint_config: &LintConfig,
1356    before_counts: &BTreeMap<String, usize>,
1357    core_candidates: &[FixCandidate],
1358    protected_ranges: &[PatchProtectedRange],
1359    allow_unsafe: bool,
1360) -> Option<FixOutcome> {
1361    if core_candidates.is_empty() {
1362        return None;
1363    }
1364
1365    let planned = plan_fix_candidates(
1366        sql,
1367        core_candidates.to_vec(),
1368        protected_ranges,
1369        allow_unsafe,
1370    );
1371    if planned.edits.is_empty() {
1372        return None;
1373    }
1374
1375    let fixed_sql = apply_planned_edits(sql, &planned.edits);
1376    if fixed_sql == sql {
1377        return None;
1378    }
1379
1380    let after_counts = lint_rule_counts(&fixed_sql, dialect, lint_config);
1381    if parse_errors_increased(before_counts, &after_counts) {
1382        return None;
1383    }
1384
1385    let counts = FixCounts::from_removed(before_counts, &after_counts);
1386    let before_total = regression_guard_total(before_counts);
1387    let after_total = regression_guard_total(&after_counts);
1388    if counts.total() == 0 || after_total > before_total {
1389        return None;
1390    }
1391
1392    Some(FixOutcome {
1393        sql: fixed_sql,
1394        counts,
1395        changed: true,
1396        skipped_due_to_comments: false,
1397        skipped_due_to_regression: false,
1398        skipped_counts: planned.skipped,
1399    })
1400}
1401
1402fn is_incremental_core_candidate(candidate: &FixCandidate, allow_unsafe: bool) -> bool {
1403    if candidate.source != FixCandidateSource::CoreAutofix {
1404        return false;
1405    }
1406
1407    if candidate.rule_code.is_none() {
1408        return false;
1409    }
1410
1411    match candidate.applicability {
1412        FixCandidateApplicability::Safe => true,
1413        FixCandidateApplicability::Unsafe => allow_unsafe,
1414        FixCandidateApplicability::DisplayOnly => false,
1415    }
1416}
1417
1418#[derive(Debug, Clone, Copy, Eq, PartialEq)]
1419enum Al001AliasingPreference {
1420    Explicit,
1421    Implicit,
1422}
1423
1424fn al001_aliasing_preference(lint_config: &LintConfig) -> Al001AliasingPreference {
1425    if lint_config
1426        .rule_option_str(issue_codes::LINT_AL_001, "aliasing")
1427        .is_some_and(|value| value.eq_ignore_ascii_case("implicit"))
1428    {
1429        Al001AliasingPreference::Implicit
1430    } else {
1431        Al001AliasingPreference::Explicit
1432    }
1433}
1434
1435fn build_al001_fallback_candidates(
1436    sql: &str,
1437    dialect: Dialect,
1438    issues: &[Issue],
1439    lint_config: &LintConfig,
1440) -> Vec<FixCandidate> {
1441    let fallback_issues: Vec<&Issue> = issues
1442        .iter()
1443        .filter(|issue| {
1444            issue.code.eq_ignore_ascii_case(issue_codes::LINT_AL_001) && issue.span.is_some()
1445        })
1446        .collect();
1447    if fallback_issues.is_empty() {
1448        return Vec::new();
1449    }
1450
1451    let Some(tokens) = alias_tokenize_with_offsets(sql, dialect) else {
1452        return Vec::new();
1453    };
1454
1455    let preference = al001_aliasing_preference(lint_config);
1456    let mut candidates = Vec::new();
1457    for issue in fallback_issues {
1458        let Some(span) = issue.span else {
1459            continue;
1460        };
1461        let alias_start = span.start.min(sql.len());
1462        let previous_token = tokens
1463            .iter()
1464            .rev()
1465            .find(|token| token.end <= alias_start && !is_alias_trivia_token(&token.token));
1466
1467        match preference {
1468            Al001AliasingPreference::Explicit => {
1469                if previous_token.is_some_and(|token| is_as_token(&token.token)) {
1470                    continue;
1471                }
1472                let replacement = if has_whitespace_before_offset(sql, alias_start) {
1473                    "AS "
1474                } else {
1475                    " AS "
1476                };
1477                candidates.push(FixCandidate {
1478                    start: alias_start,
1479                    end: alias_start,
1480                    replacement: replacement.to_string(),
1481                    applicability: FixCandidateApplicability::Safe,
1482                    source: FixCandidateSource::CoreAutofix,
1483                    rule_code: Some(issue_codes::LINT_AL_001.to_string()),
1484                });
1485            }
1486            Al001AliasingPreference::Implicit => {
1487                let Some(as_token) = previous_token.filter(|token| is_as_token(&token.token))
1488                else {
1489                    continue;
1490                };
1491                candidates.push(FixCandidate {
1492                    start: as_token.start,
1493                    end: alias_start,
1494                    replacement: " ".to_string(),
1495                    applicability: FixCandidateApplicability::Safe,
1496                    source: FixCandidateSource::CoreAutofix,
1497                    rule_code: Some(issue_codes::LINT_AL_001.to_string()),
1498                });
1499            }
1500        }
1501    }
1502
1503    candidates
1504}
1505
1506fn merge_skipped_counts(total: &mut FixSkippedCounts, current: &FixSkippedCounts) {
1507    total.unsafe_skipped += current.unsafe_skipped;
1508    total.protected_range_blocked += current.protected_range_blocked;
1509    total.overlap_conflict_blocked += current.overlap_conflict_blocked;
1510    total.display_only += current.display_only;
1511}
1512
1513#[allow(clippy::too_many_arguments)]
1514fn try_incremental_core_fix_plan(
1515    sql: &str,
1516    dialect: Dialect,
1517    lint_config: &LintConfig,
1518    before_counts: &BTreeMap<String, usize>,
1519    initial_issues: Option<&[Issue]>,
1520    allow_unsafe: bool,
1521    max_iterations: usize,
1522    max_rule_evaluations_per_iteration: usize,
1523) -> Option<FixOutcome> {
1524    let mut current_sql = sql.to_string();
1525    let mut current_counts = before_counts.clone();
1526    let mut changed = false;
1527    let mut skipped_counts = FixSkippedCounts::default();
1528    let mut counts_cache: HashMap<String, BTreeMap<String, usize>> = HashMap::new();
1529    let mut seen_sql: HashSet<u64> = HashSet::new();
1530    seen_sql.insert(hash_sql(&current_sql));
1531
1532    let max_iterations = max_iterations.max(1);
1533    let max_rule_evaluations_per_iteration = max_rule_evaluations_per_iteration.max(1);
1534    let mut initial_issues = initial_issues;
1535    for _ in 0..max_iterations {
1536        let issues: Cow<'_, [Issue]> = if let Some(issues) = initial_issues.take() {
1537            Cow::Borrowed(issues)
1538        } else {
1539            Cow::Owned(lint_issues(&current_sql, dialect, lint_config))
1540        };
1541        let mut all_candidates = build_fix_candidates_from_issue_autofixes(&current_sql, &issues);
1542        all_candidates.extend(build_al001_fallback_candidates(
1543            &current_sql,
1544            dialect,
1545            &issues,
1546            lint_config,
1547        ));
1548        let candidates = all_candidates
1549            .into_iter()
1550            .filter(|candidate| is_incremental_core_candidate(candidate, allow_unsafe))
1551            .collect::<Vec<_>>();
1552
1553        if candidates.is_empty() {
1554            break;
1555        }
1556
1557        let mut by_rule: BTreeMap<String, Vec<FixCandidate>> = BTreeMap::new();
1558        for candidate in candidates {
1559            if let Some(rule_code) = candidate.rule_code.clone() {
1560                by_rule.entry(rule_code).or_default().push(candidate);
1561            }
1562        }
1563
1564        if by_rule.is_empty() {
1565            break;
1566        }
1567
1568        let protected_ranges =
1569            collect_comment_protected_ranges(&current_sql, dialect, !allow_unsafe);
1570        let current_total = regression_guard_total(&current_counts);
1571        let mut ordered_rules = by_rule.into_iter().collect::<Vec<_>>();
1572        if max_rule_evaluations_per_iteration != usize::MAX {
1573            ordered_rules.sort_by(
1574                |(left_rule, left_candidates), (right_rule, right_candidates)| {
1575                    let left_count = current_counts.get(left_rule).copied().unwrap_or(0);
1576                    let right_count = current_counts.get(right_rule).copied().unwrap_or(0);
1577                    right_count
1578                        .cmp(&left_count)
1579                        .then_with(|| right_candidates.len().cmp(&left_candidates.len()))
1580                        .then_with(|| left_rule.cmp(right_rule))
1581                },
1582            );
1583        }
1584
1585        let mut best_rule: Option<String> = None;
1586        let mut best_sql: Option<String> = None;
1587        let mut best_counts: Option<BTreeMap<String, usize>> = None;
1588        let mut best_removed = 0usize;
1589        let mut best_after_total = usize::MAX;
1590        let mut evaluated_candidate_sql = HashSet::new();
1591
1592        let mut rule_evaluations = 0usize;
1593        for (rule_code, rule_candidates) in ordered_rules {
1594            if rule_evaluations >= max_rule_evaluations_per_iteration {
1595                break;
1596            }
1597            let planned = plan_fix_candidates(
1598                &current_sql,
1599                rule_candidates,
1600                &protected_ranges,
1601                allow_unsafe,
1602            );
1603            merge_skipped_counts(&mut skipped_counts, &planned.skipped);
1604
1605            if planned.edits.is_empty() {
1606                continue;
1607            }
1608            rule_evaluations += 1;
1609
1610            let candidate_sql = apply_planned_edits(&current_sql, &planned.edits);
1611            if candidate_sql == current_sql {
1612                continue;
1613            }
1614            if !evaluated_candidate_sql.insert(candidate_sql.clone()) {
1615                continue;
1616            }
1617
1618            let candidate_counts = if let Some(cached) = counts_cache.get(&candidate_sql) {
1619                cached.clone()
1620            } else {
1621                let counts = lint_rule_counts(&candidate_sql, dialect, lint_config);
1622                counts_cache.insert(candidate_sql.clone(), counts.clone());
1623                counts
1624            };
1625            if parse_errors_increased(&current_counts, &candidate_counts) {
1626                continue;
1627            }
1628
1629            let candidate_after_total = regression_guard_total(&candidate_counts);
1630            if candidate_after_total > current_total {
1631                continue;
1632            }
1633
1634            let candidate_removed =
1635                FixCounts::from_removed(&current_counts, &candidate_counts).total();
1636            if candidate_removed == 0 {
1637                continue;
1638            }
1639
1640            let better = candidate_removed > best_removed
1641                || (candidate_removed == best_removed && candidate_after_total < best_after_total)
1642                || (candidate_removed == best_removed
1643                    && candidate_after_total == best_after_total
1644                    && best_rule
1645                        .as_ref()
1646                        .is_none_or(|current_best| rule_code < *current_best));
1647
1648            if better {
1649                best_removed = candidate_removed;
1650                best_after_total = candidate_after_total;
1651                best_rule = Some(rule_code);
1652                best_sql = Some(candidate_sql);
1653                best_counts = Some(candidate_counts);
1654            }
1655        }
1656
1657        let Some(next_sql) = best_sql else {
1658            break;
1659        };
1660        let Some(next_counts) = best_counts else {
1661            break;
1662        };
1663        if !seen_sql.insert(hash_sql(&next_sql)) {
1664            break;
1665        }
1666
1667        current_sql = next_sql;
1668        current_counts = next_counts;
1669        changed = true;
1670    }
1671
1672    if !changed || current_sql == sql {
1673        return None;
1674    }
1675
1676    let final_counts = FixCounts::from_removed(before_counts, &current_counts);
1677    if final_counts.total() == 0 {
1678        return None;
1679    }
1680
1681    Some(FixOutcome {
1682        sql: current_sql,
1683        counts: final_counts,
1684        changed: true,
1685        skipped_due_to_comments: false,
1686        skipped_due_to_regression: false,
1687        skipped_counts,
1688    })
1689}
1690
1691#[derive(Debug, Clone)]
1692struct TableAliasOccurrence {
1693    alias_key: String,
1694    alias_start: usize,
1695    explicit_as: bool,
1696    as_start: Option<usize>,
1697}
1698
1699fn preserve_original_table_alias_style(
1700    original_sql: &str,
1701    fixed_sql: &str,
1702    dialect: Dialect,
1703) -> String {
1704    let Some(original_aliases) = table_alias_occurrences(original_sql, dialect) else {
1705        return fixed_sql.to_string();
1706    };
1707    let Some(fixed_aliases) = table_alias_occurrences(fixed_sql, dialect) else {
1708        return fixed_sql.to_string();
1709    };
1710
1711    let mut desired_by_alias: BTreeMap<String, VecDeque<bool>> = BTreeMap::new();
1712    for alias in original_aliases {
1713        desired_by_alias
1714            .entry(alias.alias_key)
1715            .or_default()
1716            .push_back(alias.explicit_as);
1717    }
1718
1719    let mut removals = Vec::new();
1720    for alias in fixed_aliases {
1721        let desired_explicit = desired_by_alias
1722            .get_mut(&alias.alias_key)
1723            .and_then(VecDeque::pop_front)
1724            .unwrap_or(alias.explicit_as);
1725
1726        if alias.explicit_as && !desired_explicit {
1727            if let Some(as_start) = alias.as_start {
1728                removals.push((as_start, alias.alias_start));
1729            }
1730        }
1731    }
1732
1733    apply_byte_removals(fixed_sql, removals)
1734}
1735
1736fn apply_configured_table_alias_style(
1737    sql: &str,
1738    dialect: Dialect,
1739    lint_config: &LintConfig,
1740) -> String {
1741    let prefer_implicit = matches!(
1742        al001_aliasing_preference(lint_config),
1743        Al001AliasingPreference::Implicit
1744    );
1745    enforce_table_alias_style(sql, dialect, prefer_implicit)
1746}
1747
1748fn enforce_table_alias_style(sql: &str, dialect: Dialect, prefer_implicit: bool) -> String {
1749    let Some(aliases) = table_alias_occurrences(sql, dialect) else {
1750        return sql.to_string();
1751    };
1752
1753    if prefer_implicit {
1754        let removals: Vec<(usize, usize)> = aliases
1755            .into_iter()
1756            .filter_map(|alias| {
1757                if alias.explicit_as {
1758                    alias.as_start.map(|as_start| (as_start, alias.alias_start))
1759                } else {
1760                    None
1761                }
1762            })
1763            .collect();
1764        return apply_byte_removals(sql, removals);
1765    }
1766
1767    let insertions: Vec<(usize, &'static str)> = aliases
1768        .into_iter()
1769        .filter(|alias| !alias.explicit_as)
1770        .map(|alias| {
1771            let insertion = if has_whitespace_before_offset(sql, alias.alias_start) {
1772                "AS "
1773            } else {
1774                " AS "
1775            };
1776            (alias.alias_start, insertion)
1777        })
1778        .collect();
1779    apply_byte_insertions(sql, insertions)
1780}
1781
1782fn has_whitespace_before_offset(sql: &str, offset: usize) -> bool {
1783    sql.get(..offset)
1784        .and_then(|prefix| prefix.chars().next_back())
1785        .is_some_and(char::is_whitespace)
1786}
1787
1788fn apply_byte_removals(sql: &str, mut removals: Vec<(usize, usize)>) -> String {
1789    if removals.is_empty() {
1790        return sql.to_string();
1791    }
1792
1793    removals.sort_unstable();
1794    removals.dedup();
1795
1796    let mut out = sql.to_string();
1797    for (start, end) in removals.into_iter().rev() {
1798        if start < end && end <= out.len() {
1799            out.replace_range(start..end, "");
1800        }
1801    }
1802    out
1803}
1804
1805fn apply_byte_insertions(sql: &str, mut insertions: Vec<(usize, &'static str)>) -> String {
1806    if insertions.is_empty() {
1807        return sql.to_string();
1808    }
1809
1810    insertions.retain(|(offset, _)| *offset <= sql.len());
1811    if insertions.is_empty() {
1812        return sql.to_string();
1813    }
1814
1815    insertions
1816        .sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(right.1)));
1817    insertions.dedup_by(|left, right| left.0 == right.0);
1818
1819    let extra_len: usize = insertions
1820        .iter()
1821        .map(|(_, insertion)| insertion.len())
1822        .sum();
1823    let mut out = String::with_capacity(sql.len() + extra_len);
1824    let mut cursor = 0usize;
1825    for (offset, insertion) in insertions {
1826        if offset < cursor || offset > sql.len() {
1827            continue;
1828        }
1829        out.push_str(&sql[cursor..offset]);
1830        out.push_str(insertion);
1831        cursor = offset;
1832    }
1833    out.push_str(&sql[cursor..]);
1834    out
1835}
1836
1837#[derive(Debug, Clone, PartialEq, Eq)]
1838struct SpanEdit {
1839    start: usize,
1840    end: usize,
1841    replacement: String,
1842}
1843
1844impl SpanEdit {
1845    fn replace(start: usize, end: usize, replacement: impl Into<String>) -> Self {
1846        Self {
1847            start,
1848            end,
1849            replacement: replacement.into(),
1850        }
1851    }
1852}
1853
1854#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
1855#[allow(dead_code)]
1856enum FixCandidateApplicability {
1857    Safe,
1858    Unsafe,
1859    DisplayOnly,
1860}
1861
1862impl FixCandidateApplicability {
1863    fn sort_key(self) -> u8 {
1864        match self {
1865            Self::Safe => 0,
1866            Self::Unsafe => 1,
1867            Self::DisplayOnly => 2,
1868        }
1869    }
1870}
1871
1872#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
1873#[allow(dead_code)]
1874enum FixCandidateSource {
1875    PrimaryRewrite,
1876    CoreAutofix,
1877    UnsafeFallback,
1878    DisplayHint,
1879}
1880
1881fn core_autofix_conflict_priority(rule_code: Option<&str>) -> u8 {
1882    let Some(code) = rule_code else {
1883        return 2;
1884    };
1885
1886    if code.eq_ignore_ascii_case(issue_codes::LINT_AM_001)
1887        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_001)
1888        || code.eq_ignore_ascii_case(issue_codes::LINT_AM_002)
1889        || code.eq_ignore_ascii_case(issue_codes::LINT_AM_003)
1890        || code.eq_ignore_ascii_case(issue_codes::LINT_AM_005)
1891        || code.eq_ignore_ascii_case(issue_codes::LINT_AM_008)
1892        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_002)
1893        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_003)
1894        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_004)
1895        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_005)
1896        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_006)
1897        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_007)
1898        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_010)
1899        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_012)
1900        || code.eq_ignore_ascii_case(issue_codes::LINT_CP_001)
1901        || code.eq_ignore_ascii_case(issue_codes::LINT_CP_002)
1902        || code.eq_ignore_ascii_case(issue_codes::LINT_CP_003)
1903        || code.eq_ignore_ascii_case(issue_codes::LINT_CP_004)
1904        || code.eq_ignore_ascii_case(issue_codes::LINT_CP_005)
1905        || code.eq_ignore_ascii_case(issue_codes::LINT_AL_001)
1906        || code.eq_ignore_ascii_case(issue_codes::LINT_AL_002)
1907        || code.eq_ignore_ascii_case(issue_codes::LINT_AL_005)
1908        || code.eq_ignore_ascii_case(issue_codes::LINT_AL_007)
1909        || code.eq_ignore_ascii_case(issue_codes::LINT_AL_009)
1910        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_001)
1911        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_002)
1912        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_003)
1913        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_004)
1914        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_005)
1915        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_006)
1916        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_007)
1917        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_008)
1918        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_009)
1919        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_010)
1920        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_011)
1921        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_012)
1922        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_013)
1923        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_014)
1924        || code.eq_ignore_ascii_case(issue_codes::LINT_LT_015)
1925        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_001)
1926        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_002)
1927        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_006)
1928        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_009)
1929        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_005)
1930        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_008)
1931        || code.eq_ignore_ascii_case(issue_codes::LINT_ST_012)
1932        || code.eq_ignore_ascii_case(issue_codes::LINT_TQ_002)
1933        || code.eq_ignore_ascii_case(issue_codes::LINT_TQ_003)
1934        || code.eq_ignore_ascii_case(issue_codes::LINT_RF_003)
1935        || code.eq_ignore_ascii_case(issue_codes::LINT_RF_004)
1936        || code.eq_ignore_ascii_case(issue_codes::LINT_CV_011)
1937        || code.eq_ignore_ascii_case(issue_codes::LINT_RF_006)
1938        || code.eq_ignore_ascii_case(issue_codes::LINT_JJ_001)
1939    {
1940        0
1941    } else {
1942        2
1943    }
1944}
1945
1946#[derive(Debug, Clone)]
1947struct FixCandidate {
1948    start: usize,
1949    end: usize,
1950    replacement: String,
1951    applicability: FixCandidateApplicability,
1952    source: FixCandidateSource,
1953    rule_code: Option<String>,
1954}
1955
1956fn fix_candidate_source_priority(candidate: &FixCandidate) -> u8 {
1957    match candidate.source {
1958        FixCandidateSource::CoreAutofix => {
1959            core_autofix_conflict_priority(candidate.rule_code.as_deref())
1960        }
1961        FixCandidateSource::PrimaryRewrite => 1,
1962        FixCandidateSource::UnsafeFallback => 3,
1963        FixCandidateSource::DisplayHint => 4,
1964    }
1965}
1966
1967#[derive(Debug, Default)]
1968struct PlannedFixes {
1969    edits: Vec<PatchEdit>,
1970    skipped: FixSkippedCounts,
1971}
1972
1973fn build_fix_candidates_from_rewrite(
1974    sql: &str,
1975    rewritten_sql: &str,
1976    applicability: FixCandidateApplicability,
1977    source: FixCandidateSource,
1978) -> Vec<FixCandidate> {
1979    if sql == rewritten_sql {
1980        return Vec::new();
1981    }
1982
1983    let mut candidates = derive_localized_span_edits(sql, rewritten_sql)
1984        .into_iter()
1985        .map(|edit| FixCandidate {
1986            start: edit.start,
1987            end: edit.end,
1988            replacement: edit.replacement,
1989            applicability,
1990            source,
1991            rule_code: None,
1992        })
1993        .collect::<Vec<_>>();
1994
1995    if candidates.is_empty() {
1996        candidates.push(FixCandidate {
1997            start: 0,
1998            end: sql.len(),
1999            replacement: rewritten_sql.to_string(),
2000            applicability,
2001            source,
2002            rule_code: None,
2003        });
2004    }
2005
2006    candidates
2007}
2008
2009fn build_fix_candidates_from_issue_autofixes(sql: &str, issues: &[Issue]) -> Vec<FixCandidate> {
2010    let issue_values: Vec<serde_json::Value> = issues
2011        .iter()
2012        .filter_map(|issue| serde_json::to_value(issue).ok())
2013        .collect();
2014    build_fix_candidates_from_issue_values(sql, &issue_values)
2015}
2016
2017fn build_fix_candidates_from_issue_values(
2018    sql: &str,
2019    issue_values: &[serde_json::Value],
2020) -> Vec<FixCandidate> {
2021    let mut candidates = Vec::new();
2022    let sql_len = sql.len();
2023
2024    for issue in issue_values {
2025        let fallback_span = issue.get("span").and_then(json_span_offsets);
2026        let issue_rule_code = issue
2027            .get("code")
2028            .and_then(serde_json::Value::as_str)
2029            .map(|code| code.to_string());
2030        if issue_rule_code
2031            .as_deref()
2032            .is_some_and(|code| code.eq_ignore_ascii_case(issue_codes::LINT_AL_001))
2033        {
2034            // AL01 core-autofix edits can be malformed in complex statement shapes.
2035            // We generate robust AL01 candidates from spans separately.
2036            continue;
2037        }
2038        let Some(autofix) = issue.get("autofix").or_else(|| issue.get("autoFix")) else {
2039            continue;
2040        };
2041        collect_issue_autofix_candidates(
2042            autofix,
2043            fallback_span,
2044            sql_len,
2045            None,
2046            &issue_rule_code,
2047            &mut candidates,
2048        );
2049    }
2050
2051    candidates
2052}
2053
2054fn collect_issue_autofix_candidates(
2055    value: &serde_json::Value,
2056    fallback_span: Option<(usize, usize)>,
2057    sql_len: usize,
2058    inherited_applicability: Option<FixCandidateApplicability>,
2059    issue_rule_code: &Option<String>,
2060    out: &mut Vec<FixCandidate>,
2061) {
2062    match value {
2063        serde_json::Value::Array(items) => {
2064            for item in items {
2065                collect_issue_autofix_candidates(
2066                    item,
2067                    fallback_span,
2068                    sql_len,
2069                    inherited_applicability,
2070                    issue_rule_code,
2071                    out,
2072                );
2073            }
2074        }
2075        serde_json::Value::Object(_) => {
2076            let applicability = parse_issue_autofix_applicability(value)
2077                .or(inherited_applicability)
2078                .unwrap_or(FixCandidateApplicability::Safe);
2079
2080            if let Some(edit) = value.get("edit") {
2081                collect_issue_autofix_candidates(
2082                    edit,
2083                    fallback_span,
2084                    sql_len,
2085                    Some(applicability),
2086                    issue_rule_code,
2087                    out,
2088                );
2089            }
2090            if let Some(edits) = value
2091                .get("edits")
2092                .or_else(|| value.get("fixes"))
2093                .or_else(|| value.get("changes"))
2094            {
2095                collect_issue_autofix_candidates(
2096                    edits,
2097                    fallback_span,
2098                    sql_len,
2099                    Some(applicability),
2100                    issue_rule_code,
2101                    out,
2102                );
2103            }
2104
2105            if let Some((start, end)) = parse_issue_autofix_offsets(value, fallback_span) {
2106                if start <= end
2107                    && end <= sql_len
2108                    && value
2109                        .get("replacement")
2110                        .or_else(|| value.get("new_text"))
2111                        .or_else(|| value.get("newText"))
2112                        .or_else(|| value.get("text"))
2113                        .and_then(serde_json::Value::as_str)
2114                        .is_some()
2115                {
2116                    let replacement = value
2117                        .get("replacement")
2118                        .or_else(|| value.get("new_text"))
2119                        .or_else(|| value.get("newText"))
2120                        .or_else(|| value.get("text"))
2121                        .and_then(serde_json::Value::as_str)
2122                        .unwrap_or_default()
2123                        .to_string();
2124
2125                    out.push(FixCandidate {
2126                        start,
2127                        end,
2128                        replacement,
2129                        applicability,
2130                        source: FixCandidateSource::CoreAutofix,
2131                        rule_code: issue_rule_code.clone(),
2132                    });
2133                }
2134            }
2135        }
2136        _ => {}
2137    }
2138}
2139
2140fn parse_issue_autofix_offsets(
2141    value: &serde_json::Value,
2142    fallback_span: Option<(usize, usize)>,
2143) -> Option<(usize, usize)> {
2144    let object = value.as_object()?;
2145
2146    let mut start = json_usize_field(object, &["start", "start_byte", "startByte"]);
2147    let mut end = json_usize_field(object, &["end", "end_byte", "endByte"]);
2148
2149    if let Some((span_start, span_end)) = object.get("span").and_then(json_span_offsets) {
2150        if start.is_none() {
2151            start = Some(span_start);
2152        }
2153        if end.is_none() {
2154            end = Some(span_end);
2155        }
2156    }
2157
2158    if let Some((span_start, span_end)) = fallback_span {
2159        if start.is_none() {
2160            start = Some(span_start);
2161        }
2162        if end.is_none() {
2163            end = Some(span_end);
2164        }
2165    }
2166
2167    Some((start?, end?))
2168}
2169
2170fn json_span_offsets(value: &serde_json::Value) -> Option<(usize, usize)> {
2171    let object = value.as_object()?;
2172    let start = json_usize_field(object, &["start", "start_byte", "startByte"])?;
2173    let end = json_usize_field(object, &["end", "end_byte", "endByte"])?;
2174    Some((start, end))
2175}
2176
2177fn json_usize_field(
2178    object: &serde_json::Map<String, serde_json::Value>,
2179    keys: &[&str],
2180) -> Option<usize> {
2181    keys.iter().find_map(|key| {
2182        object.get(*key).and_then(|value| {
2183            value
2184                .as_u64()
2185                .and_then(|raw| usize::try_from(raw).ok())
2186                .or_else(|| value.as_str().and_then(|raw| raw.parse::<usize>().ok()))
2187        })
2188    })
2189}
2190
2191fn parse_issue_autofix_applicability(
2192    value: &serde_json::Value,
2193) -> Option<FixCandidateApplicability> {
2194    let object = value.as_object()?;
2195
2196    if object
2197        .get("display_only")
2198        .or_else(|| object.get("displayOnly"))
2199        .and_then(serde_json::Value::as_bool)
2200        == Some(true)
2201    {
2202        return Some(FixCandidateApplicability::DisplayOnly);
2203    }
2204    if object.get("unsafe").and_then(serde_json::Value::as_bool) == Some(true) {
2205        return Some(FixCandidateApplicability::Unsafe);
2206    }
2207
2208    let text = object
2209        .get("applicability")
2210        .or_else(|| object.get("safety"))
2211        .or_else(|| object.get("kind"))
2212        .or_else(|| object.get("mode"))
2213        .and_then(serde_json::Value::as_str)?;
2214    parse_issue_autofix_applicability_text(text)
2215}
2216
2217fn parse_issue_autofix_applicability_text(text: &str) -> Option<FixCandidateApplicability> {
2218    match text.trim().to_ascii_lowercase().as_str() {
2219        "safe" => Some(FixCandidateApplicability::Safe),
2220        "unsafe" => Some(FixCandidateApplicability::Unsafe),
2221        "display_only" | "display-only" | "displayonly" | "display" | "hint" | "suggestion" => {
2222            Some(FixCandidateApplicability::DisplayOnly)
2223        }
2224        _ => None,
2225    }
2226}
2227
2228fn plan_fix_candidates(
2229    sql: &str,
2230    mut candidates: Vec<FixCandidate>,
2231    protected_ranges: &[PatchProtectedRange],
2232    allow_unsafe: bool,
2233) -> PlannedFixes {
2234    if candidates.is_empty() {
2235        return PlannedFixes::default();
2236    }
2237
2238    candidates.sort_by(|left, right| {
2239        left.start
2240            .cmp(&right.start)
2241            .then_with(|| left.end.cmp(&right.end))
2242            .then_with(|| {
2243                left.applicability
2244                    .sort_key()
2245                    .cmp(&right.applicability.sort_key())
2246            })
2247            .then_with(|| {
2248                fix_candidate_source_priority(left).cmp(&fix_candidate_source_priority(right))
2249            })
2250            .then_with(|| left.rule_code.cmp(&right.rule_code))
2251            .then_with(|| left.replacement.cmp(&right.replacement))
2252    });
2253    candidates.dedup_by(|left, right| {
2254        left.start == right.start
2255            && left.end == right.end
2256            && left.replacement == right.replacement
2257            && left.applicability == right.applicability
2258            && left.source == right.source
2259            && left.rule_code == right.rule_code
2260    });
2261
2262    let patch_fixes: Vec<PatchFix> = candidates
2263        .into_iter()
2264        .enumerate()
2265        .map(|(idx, candidate)| {
2266            let rule_code = candidate
2267                .rule_code
2268                .clone()
2269                .unwrap_or_else(|| format!("PATCH_{:?}_{idx}", candidate.source));
2270            let source_priority = fix_candidate_source_priority(&candidate);
2271            let mut fix = PatchFix::new(
2272                rule_code,
2273                patch_applicability(candidate.applicability),
2274                vec![PatchEdit::replace(
2275                    candidate.start,
2276                    candidate.end,
2277                    candidate.replacement,
2278                )],
2279            );
2280            fix.priority = source_priority as i32;
2281            fix
2282        })
2283        .collect();
2284
2285    let mut allowed = vec![PatchApplicability::Safe];
2286    if allow_unsafe {
2287        allowed.push(PatchApplicability::Unsafe);
2288    }
2289
2290    let plan = plan_fixes(sql, patch_fixes, &allowed, protected_ranges);
2291    let mut skipped = FixSkippedCounts::default();
2292    for blocked in &plan.blocked {
2293        let reasons = &blocked.reasons;
2294        if reasons.iter().any(|reason| {
2295            matches!(
2296                reason,
2297                BlockedReason::ApplicabilityNotAllowed {
2298                    applicability: PatchApplicability::Unsafe
2299                }
2300            )
2301        }) {
2302            skipped.unsafe_skipped += 1;
2303            continue;
2304        }
2305        if reasons.iter().any(|reason| {
2306            matches!(
2307                reason,
2308                BlockedReason::ApplicabilityNotAllowed {
2309                    applicability: PatchApplicability::DisplayOnly
2310                }
2311            )
2312        }) {
2313            skipped.display_only += 1;
2314            continue;
2315        }
2316        if reasons
2317            .iter()
2318            .any(|reason| matches!(reason, BlockedReason::TouchesProtectedRange { .. }))
2319        {
2320            skipped.protected_range_blocked += 1;
2321            continue;
2322        }
2323        skipped.overlap_conflict_blocked += 1;
2324    }
2325
2326    PlannedFixes {
2327        edits: plan.accepted_edits(),
2328        skipped,
2329    }
2330}
2331
2332fn patch_applicability(applicability: FixCandidateApplicability) -> PatchApplicability {
2333    match applicability {
2334        FixCandidateApplicability::Safe => PatchApplicability::Safe,
2335        FixCandidateApplicability::Unsafe => PatchApplicability::Unsafe,
2336        FixCandidateApplicability::DisplayOnly => PatchApplicability::DisplayOnly,
2337    }
2338}
2339
2340fn apply_planned_edits(sql: &str, edits: &[PatchEdit]) -> String {
2341    apply_patch_edits(sql, edits)
2342}
2343
2344fn collect_comment_protected_ranges(
2345    sql: &str,
2346    dialect: Dialect,
2347    strict_safety_mode: bool,
2348) -> Vec<PatchProtectedRange> {
2349    if !strict_safety_mode {
2350        return Vec::new();
2351    }
2352
2353    derive_protected_ranges(sql, dialect)
2354        .into_iter()
2355        .filter(|range| matches!(range.kind, PatchProtectedRangeKind::TemplateTag))
2356        .collect()
2357}
2358
2359fn derive_localized_span_edits(original: &str, rewritten: &str) -> Vec<SpanEdit> {
2360    if original == rewritten {
2361        return Vec::new();
2362    }
2363
2364    let original_chars = original.chars().collect::<Vec<_>>();
2365    let rewritten_chars = rewritten.chars().collect::<Vec<_>>();
2366
2367    const MAX_DIFF_MATRIX_CELLS: usize = 2_500_000;
2368    let matrix_cells = (original_chars.len() + 1).saturating_mul(rewritten_chars.len() + 1);
2369    if matrix_cells > MAX_DIFF_MATRIX_CELLS {
2370        return vec![SpanEdit::replace(0, original.len(), rewritten)];
2371    }
2372
2373    let diff_steps = diff_steps_via_lcs(&original_chars, &rewritten_chars);
2374    if diff_steps.is_empty() {
2375        return Vec::new();
2376    }
2377
2378    let original_offsets = char_to_byte_offsets(original);
2379    let rewritten_offsets = char_to_byte_offsets(rewritten);
2380
2381    let mut edits = Vec::new();
2382    let mut original_char_idx = 0usize;
2383    let mut rewritten_char_idx = 0usize;
2384    let mut step_idx = 0usize;
2385
2386    while step_idx < diff_steps.len() {
2387        if matches!(diff_steps[step_idx], DiffStep::Equal) {
2388            original_char_idx += 1;
2389            rewritten_char_idx += 1;
2390            step_idx += 1;
2391            continue;
2392        }
2393
2394        let edit_original_start = original_char_idx;
2395        let edit_rewritten_start = rewritten_char_idx;
2396
2397        while step_idx < diff_steps.len() && !matches!(diff_steps[step_idx], DiffStep::Equal) {
2398            match diff_steps[step_idx] {
2399                DiffStep::Delete => original_char_idx += 1,
2400                DiffStep::Insert => rewritten_char_idx += 1,
2401                DiffStep::Equal => {}
2402            }
2403            step_idx += 1;
2404        }
2405
2406        let start = original_offsets[edit_original_start];
2407        let end = original_offsets[original_char_idx];
2408        let replacement_start = rewritten_offsets[edit_rewritten_start];
2409        let replacement_end = rewritten_offsets[rewritten_char_idx];
2410        edits.push(SpanEdit::replace(
2411            start,
2412            end,
2413            &rewritten[replacement_start..replacement_end],
2414        ));
2415    }
2416
2417    edits
2418}
2419
2420#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2421enum DiffStep {
2422    Equal,
2423    Delete,
2424    Insert,
2425}
2426
2427fn diff_steps_via_lcs(original: &[char], rewritten: &[char]) -> Vec<DiffStep> {
2428    if original.is_empty() {
2429        return vec![DiffStep::Insert; rewritten.len()];
2430    }
2431    if rewritten.is_empty() {
2432        return vec![DiffStep::Delete; original.len()];
2433    }
2434
2435    let cols = rewritten.len() + 1;
2436    let mut lcs = vec![0u32; (original.len() + 1) * cols];
2437
2438    for original_idx in 0..original.len() {
2439        for rewritten_idx in 0..rewritten.len() {
2440            let cell = (original_idx + 1) * cols + rewritten_idx + 1;
2441            lcs[cell] = if original[original_idx] == rewritten[rewritten_idx] {
2442                lcs[original_idx * cols + rewritten_idx] + 1
2443            } else {
2444                lcs[original_idx * cols + rewritten_idx + 1]
2445                    .max(lcs[(original_idx + 1) * cols + rewritten_idx])
2446            };
2447        }
2448    }
2449
2450    let mut steps_reversed = Vec::with_capacity(original.len() + rewritten.len());
2451    let mut original_idx = original.len();
2452    let mut rewritten_idx = rewritten.len();
2453
2454    while original_idx > 0 || rewritten_idx > 0 {
2455        if original_idx > 0
2456            && rewritten_idx > 0
2457            && original[original_idx - 1] == rewritten[rewritten_idx - 1]
2458        {
2459            steps_reversed.push(DiffStep::Equal);
2460            original_idx -= 1;
2461            rewritten_idx -= 1;
2462            continue;
2463        }
2464
2465        let left = if rewritten_idx > 0 {
2466            lcs[original_idx * cols + rewritten_idx - 1]
2467        } else {
2468            0
2469        };
2470        let up = if original_idx > 0 {
2471            lcs[(original_idx - 1) * cols + rewritten_idx]
2472        } else {
2473            0
2474        };
2475
2476        if rewritten_idx > 0 && (original_idx == 0 || left >= up) {
2477            steps_reversed.push(DiffStep::Insert);
2478            rewritten_idx -= 1;
2479        } else if original_idx > 0 {
2480            steps_reversed.push(DiffStep::Delete);
2481            original_idx -= 1;
2482        }
2483    }
2484
2485    steps_reversed.reverse();
2486    steps_reversed
2487}
2488
2489fn char_to_byte_offsets(text: &str) -> Vec<usize> {
2490    let mut offsets = Vec::with_capacity(text.chars().count() + 1);
2491    offsets.push(0);
2492    for (idx, ch) in text.char_indices() {
2493        offsets.push(idx + ch.len_utf8());
2494    }
2495    offsets
2496}
2497
2498fn table_alias_occurrences(sql: &str, dialect: Dialect) -> Option<Vec<TableAliasOccurrence>> {
2499    let statements = parse_sql_with_dialect(sql, dialect).ok()?;
2500    let tokens = alias_tokenize_with_offsets(sql, dialect)?;
2501
2502    let mut aliases = Vec::new();
2503    for statement in &statements {
2504        collect_table_alias_idents_in_statement(statement, &mut |ident| {
2505            aliases.push(ident.clone())
2506        });
2507    }
2508
2509    let mut occurrences = Vec::with_capacity(aliases.len());
2510    for alias in aliases {
2511        let Some((alias_start, _alias_end)) = alias_ident_span_offsets(sql, &alias) else {
2512            continue;
2513        };
2514        let previous_token = tokens
2515            .iter()
2516            .rev()
2517            .find(|token| token.end <= alias_start && !is_alias_trivia_token(&token.token));
2518
2519        let (explicit_as, as_start) = match previous_token {
2520            Some(token) if is_as_token(&token.token) => (true, Some(token.start)),
2521            _ => (false, None),
2522        };
2523
2524        occurrences.push(TableAliasOccurrence {
2525            alias_key: alias.value.to_ascii_lowercase(),
2526            alias_start,
2527            explicit_as,
2528            as_start,
2529        });
2530    }
2531
2532    Some(occurrences)
2533}
2534
2535fn alias_ident_span_offsets(sql: &str, ident: &Ident) -> Option<(usize, usize)> {
2536    let start = alias_line_col_to_offset(
2537        sql,
2538        ident.span.start.line as usize,
2539        ident.span.start.column as usize,
2540    )?;
2541    let end = alias_line_col_to_offset(
2542        sql,
2543        ident.span.end.line as usize,
2544        ident.span.end.column as usize,
2545    )?;
2546    Some((start, end))
2547}
2548
2549fn is_as_token(token: &Token) -> bool {
2550    matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case("AS"))
2551}
2552
2553#[derive(Clone)]
2554struct AliasLocatedToken {
2555    token: Token,
2556    start: usize,
2557    end: usize,
2558}
2559
2560fn alias_tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<AliasLocatedToken>> {
2561    let dialect = dialect.to_sqlparser_dialect();
2562    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
2563    let tokens = tokenizer.tokenize_with_location().ok()?;
2564
2565    let mut out = Vec::with_capacity(tokens.len());
2566    for token in tokens {
2567        let Some((start, end)) = alias_token_with_span_offsets(sql, &token) else {
2568            continue;
2569        };
2570        out.push(AliasLocatedToken {
2571            token: token.token,
2572            start,
2573            end,
2574        });
2575    }
2576
2577    Some(out)
2578}
2579
2580fn alias_token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
2581    let start = alias_line_col_to_offset(
2582        sql,
2583        token.span.start.line as usize,
2584        token.span.start.column as usize,
2585    )?;
2586    let end = alias_line_col_to_offset(
2587        sql,
2588        token.span.end.line as usize,
2589        token.span.end.column as usize,
2590    )?;
2591    Some((start, end))
2592}
2593
2594fn alias_line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
2595    if line == 0 || column == 0 {
2596        return None;
2597    }
2598
2599    let mut current_line = 1usize;
2600    let mut current_col = 1usize;
2601    for (offset, ch) in sql.char_indices() {
2602        if current_line == line && current_col == column {
2603            return Some(offset);
2604        }
2605        if ch == '\n' {
2606            current_line += 1;
2607            current_col = 1;
2608        } else {
2609            current_col += 1;
2610        }
2611    }
2612    if current_line == line && current_col == column {
2613        return Some(sql.len());
2614    }
2615    None
2616}
2617
2618fn is_alias_trivia_token(token: &Token) -> bool {
2619    matches!(
2620        token,
2621        Token::Whitespace(
2622            Whitespace::Space
2623                | Whitespace::Newline
2624                | Whitespace::Tab
2625                | Whitespace::SingleLineComment { .. }
2626                | Whitespace::MultiLineComment(_)
2627        )
2628    )
2629}
2630
2631fn collect_table_alias_idents_in_statement<F: FnMut(&Ident)>(
2632    statement: &Statement,
2633    visitor: &mut F,
2634) {
2635    match statement {
2636        Statement::Query(query) => collect_table_alias_idents_in_query(query, visitor),
2637        Statement::Insert(insert) => {
2638            if let Some(source) = &insert.source {
2639                collect_table_alias_idents_in_query(source, visitor);
2640            }
2641        }
2642        Statement::CreateView(CreateView { query, .. }) => {
2643            collect_table_alias_idents_in_query(query, visitor)
2644        }
2645        Statement::CreateTable(create) => {
2646            if let Some(query) = &create.query {
2647                collect_table_alias_idents_in_query(query, visitor);
2648            }
2649        }
2650        Statement::Merge(Merge { table, source, .. }) => {
2651            collect_table_alias_idents_in_table_factor(table, visitor);
2652            collect_table_alias_idents_in_table_factor(source, visitor);
2653        }
2654        _ => {}
2655    }
2656}
2657
2658fn collect_table_alias_idents_in_query<F: FnMut(&Ident)>(query: &Query, visitor: &mut F) {
2659    if let Some(with) = &query.with {
2660        for cte in &with.cte_tables {
2661            collect_table_alias_idents_in_query(&cte.query, visitor);
2662        }
2663    }
2664
2665    collect_table_alias_idents_in_set_expr(&query.body, visitor);
2666}
2667
2668fn collect_table_alias_idents_in_set_expr<F: FnMut(&Ident)>(set_expr: &SetExpr, visitor: &mut F) {
2669    match set_expr {
2670        SetExpr::Select(select) => {
2671            for table in &select.from {
2672                collect_table_alias_idents_in_table_with_joins(table, visitor);
2673            }
2674        }
2675        SetExpr::Query(query) => collect_table_alias_idents_in_query(query, visitor),
2676        SetExpr::SetOperation { left, right, .. } => {
2677            collect_table_alias_idents_in_set_expr(left, visitor);
2678            collect_table_alias_idents_in_set_expr(right, visitor);
2679        }
2680        SetExpr::Insert(statement)
2681        | SetExpr::Update(statement)
2682        | SetExpr::Delete(statement)
2683        | SetExpr::Merge(statement) => collect_table_alias_idents_in_statement(statement, visitor),
2684        _ => {}
2685    }
2686}
2687
2688fn collect_table_alias_idents_in_table_with_joins<F: FnMut(&Ident)>(
2689    table_with_joins: &TableWithJoins,
2690    visitor: &mut F,
2691) {
2692    collect_table_alias_idents_in_table_factor(&table_with_joins.relation, visitor);
2693    for join in &table_with_joins.joins {
2694        collect_table_alias_idents_in_table_factor(&join.relation, visitor);
2695    }
2696}
2697
2698fn collect_table_alias_idents_in_table_factor<F: FnMut(&Ident)>(
2699    table_factor: &TableFactor,
2700    visitor: &mut F,
2701) {
2702    if let Some(alias) = table_factor_alias_ident(table_factor) {
2703        visitor(alias);
2704    }
2705
2706    match table_factor {
2707        TableFactor::Derived { subquery, .. } => {
2708            collect_table_alias_idents_in_query(subquery, visitor)
2709        }
2710        TableFactor::NestedJoin {
2711            table_with_joins, ..
2712        } => collect_table_alias_idents_in_table_with_joins(table_with_joins, visitor),
2713        TableFactor::Pivot { table, .. }
2714        | TableFactor::Unpivot { table, .. }
2715        | TableFactor::MatchRecognize { table, .. } => {
2716            collect_table_alias_idents_in_table_factor(table, visitor)
2717        }
2718        _ => {}
2719    }
2720}
2721
2722#[cfg(test)]
2723fn is_ascii_whitespace_byte(byte: u8) -> bool {
2724    matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0x0b | 0x0c)
2725}
2726
2727#[cfg(test)]
2728fn is_ascii_ident_start(byte: u8) -> bool {
2729    byte.is_ascii_alphabetic() || byte == b'_'
2730}
2731
2732#[cfg(test)]
2733fn is_ascii_ident_continue(byte: u8) -> bool {
2734    byte.is_ascii_alphanumeric() || byte == b'_'
2735}
2736
2737#[cfg(test)]
2738fn skip_ascii_whitespace(bytes: &[u8], mut idx: usize) -> usize {
2739    while idx < bytes.len() && is_ascii_whitespace_byte(bytes[idx]) {
2740        idx += 1;
2741    }
2742    idx
2743}
2744
2745#[cfg(test)]
2746fn consume_ascii_identifier(bytes: &[u8], start: usize) -> Option<usize> {
2747    if start >= bytes.len() || !is_ascii_ident_start(bytes[start]) {
2748        return None;
2749    }
2750    let mut idx = start + 1;
2751    while idx < bytes.len() && is_ascii_ident_continue(bytes[idx]) {
2752        idx += 1;
2753    }
2754    Some(idx)
2755}
2756
2757#[cfg(test)]
2758fn is_word_boundary_for_keyword(bytes: &[u8], idx: usize) -> bool {
2759    idx == 0 || idx >= bytes.len() || !is_ascii_ident_continue(bytes[idx])
2760}
2761
2762#[cfg(test)]
2763fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> Option<usize> {
2764    let end = start.checked_add(keyword_upper.len())?;
2765    if end > bytes.len() {
2766        return None;
2767    }
2768    if !is_word_boundary_for_keyword(bytes, start.saturating_sub(1))
2769        || !is_word_boundary_for_keyword(bytes, end)
2770    {
2771        return None;
2772    }
2773    let matches = bytes[start..end]
2774        .iter()
2775        .zip(keyword_upper.iter())
2776        .all(|(actual, expected)| actual.to_ascii_uppercase() == *expected);
2777    if matches {
2778        Some(end)
2779    } else {
2780        None
2781    }
2782}
2783
2784#[cfg(test)]
2785fn parse_subquery_alias_suffix(suffix: &str) -> Option<String> {
2786    let bytes = suffix.as_bytes();
2787    let mut i = skip_ascii_whitespace(bytes, 0);
2788    if let Some(as_end) = match_ascii_keyword_at(bytes, i, b"AS") {
2789        let after_as = skip_ascii_whitespace(bytes, as_end);
2790        if after_as == as_end {
2791            return None;
2792        }
2793        i = after_as;
2794    }
2795
2796    let alias_start = i;
2797    let alias_end = consume_ascii_identifier(bytes, alias_start)?;
2798    i = skip_ascii_whitespace(bytes, alias_end);
2799    if i < bytes.len() && bytes[i] == b';' {
2800        i += 1;
2801        i = skip_ascii_whitespace(bytes, i);
2802    }
2803    if i != bytes.len() {
2804        return None;
2805    }
2806    Some(suffix[alias_start..alias_end].to_string())
2807}
2808
2809#[cfg(test)]
2810fn fix_subquery_to_cte(sql: &str) -> String {
2811    let bytes = sql.as_bytes();
2812    let mut i = skip_ascii_whitespace(bytes, 0);
2813    let Some(select_end) = match_ascii_keyword_at(bytes, i, b"SELECT") else {
2814        return sql.to_string();
2815    };
2816    i = skip_ascii_whitespace(bytes, select_end);
2817    if i == select_end || i >= bytes.len() || bytes[i] != b'*' {
2818        return sql.to_string();
2819    }
2820    i += 1;
2821    let from_start = skip_ascii_whitespace(bytes, i);
2822    if from_start == i {
2823        return sql.to_string();
2824    }
2825    let Some(from_end) = match_ascii_keyword_at(bytes, from_start, b"FROM") else {
2826        return sql.to_string();
2827    };
2828    let open_paren_idx = skip_ascii_whitespace(bytes, from_end);
2829    if open_paren_idx == from_end || open_paren_idx >= bytes.len() || bytes[open_paren_idx] != b'('
2830    {
2831        return sql.to_string();
2832    };
2833
2834    let Some(close_paren_idx) = find_matching_parenthesis_outside_quotes(sql, open_paren_idx)
2835    else {
2836        return sql.to_string();
2837    };
2838
2839    let subquery = sql[open_paren_idx + 1..close_paren_idx].trim();
2840    if !subquery.to_ascii_lowercase().starts_with("select") {
2841        return sql.to_string();
2842    }
2843
2844    let suffix = &sql[close_paren_idx + 1..];
2845    let Some(alias) = parse_subquery_alias_suffix(suffix) else {
2846        return sql.to_string();
2847    };
2848
2849    let mut rewritten = format!("WITH {alias} AS ({subquery}) SELECT * FROM {alias}");
2850    if suffix.trim_end().ends_with(';') {
2851        rewritten.push(';');
2852    }
2853    rewritten
2854}
2855
2856#[cfg(test)]
2857fn find_matching_parenthesis_outside_quotes(sql: &str, open_paren_idx: usize) -> Option<usize> {
2858    #[derive(Clone, Copy, PartialEq, Eq)]
2859    enum Mode {
2860        Outside,
2861        SingleQuote,
2862        DoubleQuote,
2863        BacktickQuote,
2864        BracketQuote,
2865    }
2866
2867    let bytes = sql.as_bytes();
2868    if open_paren_idx >= bytes.len() || bytes[open_paren_idx] != b'(' {
2869        return None;
2870    }
2871
2872    let mut depth = 0usize;
2873    let mut mode = Mode::Outside;
2874    let mut i = open_paren_idx;
2875
2876    while i < bytes.len() {
2877        let b = bytes[i];
2878        let next = bytes.get(i + 1).copied();
2879
2880        match mode {
2881            Mode::Outside => {
2882                if b == b'\'' {
2883                    mode = Mode::SingleQuote;
2884                    i += 1;
2885                    continue;
2886                }
2887                if b == b'"' {
2888                    mode = Mode::DoubleQuote;
2889                    i += 1;
2890                    continue;
2891                }
2892                if b == b'`' {
2893                    mode = Mode::BacktickQuote;
2894                    i += 1;
2895                    continue;
2896                }
2897                if b == b'[' {
2898                    mode = Mode::BracketQuote;
2899                    i += 1;
2900                    continue;
2901                }
2902                if b == b'(' {
2903                    depth += 1;
2904                    i += 1;
2905                    continue;
2906                }
2907                if b == b')' {
2908                    depth = depth.checked_sub(1)?;
2909                    if depth == 0 {
2910                        return Some(i);
2911                    }
2912                }
2913                i += 1;
2914            }
2915            Mode::SingleQuote => {
2916                if b == b'\'' {
2917                    if next == Some(b'\'') {
2918                        i += 2;
2919                    } else {
2920                        mode = Mode::Outside;
2921                        i += 1;
2922                    }
2923                } else {
2924                    i += 1;
2925                }
2926            }
2927            Mode::DoubleQuote => {
2928                if b == b'"' {
2929                    if next == Some(b'"') {
2930                        i += 2;
2931                    } else {
2932                        mode = Mode::Outside;
2933                        i += 1;
2934                    }
2935                } else {
2936                    i += 1;
2937                }
2938            }
2939            Mode::BacktickQuote => {
2940                if b == b'`' {
2941                    if next == Some(b'`') {
2942                        i += 2;
2943                    } else {
2944                        mode = Mode::Outside;
2945                        i += 1;
2946                    }
2947                } else {
2948                    i += 1;
2949                }
2950            }
2951            Mode::BracketQuote => {
2952                if b == b']' {
2953                    if next == Some(b']') {
2954                        i += 2;
2955                    } else {
2956                        mode = Mode::Outside;
2957                        i += 1;
2958                    }
2959                } else {
2960                    i += 1;
2961                }
2962            }
2963        }
2964    }
2965
2966    None
2967}
2968
2969fn fix_statement(stmt: &mut Statement, rule_filter: &RuleFilter) {
2970    match stmt {
2971        Statement::Query(query) => fix_query(query, rule_filter),
2972        Statement::Insert(insert) => {
2973            if let Some(source) = insert.source.as_mut() {
2974                fix_query(source, rule_filter);
2975            }
2976        }
2977        Statement::CreateView(CreateView { query, .. }) => fix_query(query, rule_filter),
2978        Statement::CreateTable(create) => {
2979            if let Some(query) = create.query.as_mut() {
2980                fix_query(query, rule_filter);
2981            }
2982        }
2983        _ => {}
2984    }
2985}
2986
2987fn fix_query(query: &mut Query, rule_filter: &RuleFilter) {
2988    if let Some(with) = query.with.as_mut() {
2989        for cte in &mut with.cte_tables {
2990            fix_query(&mut cte.query, rule_filter);
2991        }
2992    }
2993
2994    fix_set_expr(query.body.as_mut(), rule_filter);
2995    rewrite_simple_derived_subqueries_to_ctes(query, rule_filter);
2996
2997    if let Some(order_by) = query.order_by.as_mut() {
2998        fix_order_by(order_by, rule_filter);
2999    }
3000
3001    if let Some(limit_clause) = query.limit_clause.as_mut() {
3002        fix_limit_clause(limit_clause, rule_filter);
3003    }
3004
3005    if let Some(fetch) = query.fetch.as_mut() {
3006        if let Some(quantity) = fetch.quantity.as_mut() {
3007            fix_expr(quantity, rule_filter);
3008        }
3009    }
3010}
3011
3012fn fix_set_expr(body: &mut SetExpr, rule_filter: &RuleFilter) {
3013    match body {
3014        SetExpr::Select(select) => fix_select(select, rule_filter),
3015        SetExpr::Query(query) => fix_query(query, rule_filter),
3016        SetExpr::SetOperation { left, right, .. } => {
3017            fix_set_expr(left, rule_filter);
3018            fix_set_expr(right, rule_filter);
3019        }
3020        SetExpr::Values(values) => {
3021            for row in &mut values.rows {
3022                for expr in row {
3023                    fix_expr(expr, rule_filter);
3024                }
3025            }
3026        }
3027        SetExpr::Insert(stmt)
3028        | SetExpr::Update(stmt)
3029        | SetExpr::Delete(stmt)
3030        | SetExpr::Merge(stmt) => fix_statement(stmt, rule_filter),
3031        _ => {}
3032    }
3033}
3034
3035fn fix_select(select: &mut Select, rule_filter: &RuleFilter) {
3036    for item in &mut select.projection {
3037        match item {
3038            SelectItem::UnnamedExpr(expr) => {
3039                fix_expr(expr, rule_filter);
3040            }
3041            SelectItem::ExprWithAlias { expr, .. } => {
3042                fix_expr(expr, rule_filter);
3043            }
3044            SelectItem::QualifiedWildcard(SelectItemQualifiedWildcardKind::Expr(expr), _) => {
3045                fix_expr(expr, rule_filter);
3046            }
3047            _ => {}
3048        }
3049    }
3050
3051    for table_with_joins in &mut select.from {
3052        if rule_filter.allows(issue_codes::LINT_CV_008) {
3053            rewrite_right_join_to_left(table_with_joins);
3054        }
3055
3056        fix_table_factor(&mut table_with_joins.relation, rule_filter);
3057
3058        let mut left_ref = table_factor_reference_name(&table_with_joins.relation);
3059
3060        for join in &mut table_with_joins.joins {
3061            let right_ref = table_factor_reference_name(&join.relation);
3062            if rule_filter.allows(issue_codes::LINT_ST_007) {
3063                rewrite_using_join_constraint(
3064                    &mut join.join_operator,
3065                    left_ref.as_deref(),
3066                    right_ref.as_deref(),
3067                );
3068            }
3069
3070            fix_table_factor(&mut join.relation, rule_filter);
3071            fix_join_operator(&mut join.join_operator, rule_filter);
3072
3073            if right_ref.is_some() {
3074                left_ref = right_ref;
3075            }
3076        }
3077    }
3078
3079    if let Some(prewhere) = select.prewhere.as_mut() {
3080        fix_expr(prewhere, rule_filter);
3081    }
3082
3083    if let Some(selection) = select.selection.as_mut() {
3084        fix_expr(selection, rule_filter);
3085    }
3086
3087    if let Some(having) = select.having.as_mut() {
3088        fix_expr(having, rule_filter);
3089    }
3090
3091    if let Some(qualify) = select.qualify.as_mut() {
3092        fix_expr(qualify, rule_filter);
3093    }
3094
3095    if let GroupByExpr::Expressions(exprs, _) = &mut select.group_by {
3096        for expr in exprs {
3097            fix_expr(expr, rule_filter);
3098        }
3099    }
3100
3101    for expr in &mut select.cluster_by {
3102        fix_expr(expr, rule_filter);
3103    }
3104
3105    for expr in &mut select.distribute_by {
3106        fix_expr(expr, rule_filter);
3107    }
3108
3109    for expr in &mut select.sort_by {
3110        fix_expr(&mut expr.expr, rule_filter);
3111    }
3112
3113    for lateral_view in &mut select.lateral_views {
3114        fix_expr(&mut lateral_view.lateral_view, rule_filter);
3115    }
3116
3117    for connect_by_kind in &mut select.connect_by {
3118        match connect_by_kind {
3119            ConnectByKind::ConnectBy { relationships, .. } => {
3120                for relationship in relationships {
3121                    fix_expr(relationship, rule_filter);
3122                }
3123            }
3124            ConnectByKind::StartWith { condition, .. } => {
3125                fix_expr(condition, rule_filter);
3126            }
3127        }
3128    }
3129}
3130
3131fn rewrite_simple_derived_subqueries_to_ctes(query: &mut Query, rule_filter: &RuleFilter) {
3132    if !rule_filter.allows(issue_codes::LINT_ST_005) {
3133        return;
3134    }
3135
3136    let SetExpr::Select(select) = query.body.as_mut() else {
3137        return;
3138    };
3139
3140    let outer_source_names = select_source_names_upper(select);
3141    let mut used_cte_names: HashSet<String> = query
3142        .with
3143        .as_ref()
3144        .map(|with| {
3145            with.cte_tables
3146                .iter()
3147                .map(|cte| cte.alias.name.value.to_ascii_uppercase())
3148                .collect()
3149        })
3150        .unwrap_or_default();
3151    used_cte_names.extend(outer_source_names.iter().cloned());
3152
3153    let mut new_ctes = Vec::new();
3154
3155    for table_with_joins in &mut select.from {
3156        if rule_filter.st005_forbid_subquery_in.forbid_from() {
3157            if let Some(cte) = rewrite_derived_table_factor_to_cte(
3158                &mut table_with_joins.relation,
3159                &outer_source_names,
3160                &mut used_cte_names,
3161            ) {
3162                new_ctes.push(cte);
3163            }
3164        }
3165
3166        if rule_filter.st005_forbid_subquery_in.forbid_join() {
3167            for join in &mut table_with_joins.joins {
3168                if let Some(cte) = rewrite_derived_table_factor_to_cte(
3169                    &mut join.relation,
3170                    &outer_source_names,
3171                    &mut used_cte_names,
3172                ) {
3173                    new_ctes.push(cte);
3174                }
3175            }
3176        }
3177    }
3178
3179    if new_ctes.is_empty() {
3180        return;
3181    }
3182
3183    let with = query.with.get_or_insert_with(|| With {
3184        with_token: AttachedToken::empty(),
3185        recursive: false,
3186        cte_tables: Vec::new(),
3187    });
3188    with.cte_tables.extend(new_ctes);
3189}
3190
3191fn rewrite_derived_table_factor_to_cte(
3192    relation: &mut TableFactor,
3193    outer_source_names: &HashSet<String>,
3194    used_cte_names: &mut HashSet<String>,
3195) -> Option<Cte> {
3196    let (lateral, subquery, alias) = match relation {
3197        TableFactor::Derived {
3198            lateral,
3199            subquery,
3200            alias,
3201            ..
3202        } => (lateral, subquery, alias),
3203        _ => return None,
3204    };
3205
3206    if *lateral {
3207        return None;
3208    }
3209
3210    // Keep this rewrite conservative: only SELECT subqueries that do not
3211    // appear to reference outer sources.
3212    if !matches!(subquery.body.as_ref(), SetExpr::Select(_))
3213        || query_text_references_outer_sources(subquery, outer_source_names)
3214    {
3215        return None;
3216    }
3217
3218    let cte_alias = alias.clone().unwrap_or_else(|| TableAlias {
3219        explicit: false,
3220        name: Ident::new(next_generated_cte_name(used_cte_names)),
3221        columns: Vec::new(),
3222    });
3223    let cte_name_ident = cte_alias.name.clone();
3224    let cte_name_upper = cte_name_ident.value.to_ascii_uppercase();
3225    used_cte_names.insert(cte_name_upper);
3226
3227    let cte = Cte {
3228        alias: cte_alias,
3229        query: subquery.clone(),
3230        from: None,
3231        materialized: None,
3232        closing_paren_token: AttachedToken::empty(),
3233    };
3234
3235    *relation = TableFactor::Table {
3236        name: vec![cte_name_ident].into(),
3237        alias: None,
3238        args: None,
3239        with_hints: Vec::new(),
3240        version: None,
3241        with_ordinality: false,
3242        partitions: Vec::new(),
3243        json_path: None,
3244        sample: None,
3245        index_hints: Vec::new(),
3246    };
3247
3248    Some(cte)
3249}
3250
3251fn next_generated_cte_name(used_cte_names: &HashSet<String>) -> String {
3252    let mut index = 1usize;
3253    loop {
3254        let candidate = format!("cte_subquery_{index}");
3255        if !used_cte_names.contains(&candidate.to_ascii_uppercase()) {
3256            return candidate;
3257        }
3258        index += 1;
3259    }
3260}
3261
3262fn query_text_references_outer_sources(
3263    query: &Query,
3264    outer_source_names: &HashSet<String>,
3265) -> bool {
3266    if outer_source_names.is_empty() {
3267        return false;
3268    }
3269
3270    let rendered_upper = query.to_string().to_ascii_uppercase();
3271    outer_source_names
3272        .iter()
3273        .any(|name| rendered_upper.contains(&format!("{name}.")))
3274}
3275
3276fn select_source_names_upper(select: &Select) -> HashSet<String> {
3277    let mut names = HashSet::new();
3278    for table in &select.from {
3279        collect_source_names_from_table_factor(&table.relation, &mut names);
3280        for join in &table.joins {
3281            collect_source_names_from_table_factor(&join.relation, &mut names);
3282        }
3283    }
3284    names
3285}
3286
3287fn collect_source_names_from_table_factor(relation: &TableFactor, names: &mut HashSet<String>) {
3288    match relation {
3289        TableFactor::Table { name, alias, .. } => {
3290            if let Some(last) = name.0.last().and_then(|part| part.as_ident()) {
3291                names.insert(last.value.to_ascii_uppercase());
3292            }
3293            if let Some(alias) = alias {
3294                names.insert(alias.name.value.to_ascii_uppercase());
3295            }
3296        }
3297        TableFactor::Derived { alias, .. }
3298        | TableFactor::TableFunction { alias, .. }
3299        | TableFactor::Function { alias, .. }
3300        | TableFactor::UNNEST { alias, .. }
3301        | TableFactor::JsonTable { alias, .. }
3302        | TableFactor::OpenJsonTable { alias, .. }
3303        | TableFactor::NestedJoin { alias, .. }
3304        | TableFactor::Pivot { alias, .. }
3305        | TableFactor::Unpivot { alias, .. } => {
3306            if let Some(alias) = alias {
3307                names.insert(alias.name.value.to_ascii_uppercase());
3308            }
3309        }
3310        _ => {}
3311    }
3312}
3313
3314fn rewrite_right_join_to_left(table_with_joins: &mut TableWithJoins) {
3315    while let Some(index) = table_with_joins
3316        .joins
3317        .iter()
3318        .position(|join| rewritable_right_join(&join.join_operator))
3319    {
3320        rewrite_right_join_at_index(table_with_joins, index);
3321    }
3322}
3323
3324fn rewrite_right_join_at_index(table_with_joins: &mut TableWithJoins, index: usize) {
3325    let mut suffix = table_with_joins.joins.split_off(index);
3326    let mut join = suffix.remove(0);
3327
3328    let old_operator = std::mem::replace(
3329        &mut join.join_operator,
3330        JoinOperator::CrossJoin(JoinConstraint::None),
3331    );
3332    let Some(new_operator) = rewritten_left_join_operator(old_operator) else {
3333        table_with_joins.joins.push(join);
3334        table_with_joins.joins.append(&mut suffix);
3335        return;
3336    };
3337
3338    let previous_relation = std::mem::replace(&mut table_with_joins.relation, join.relation);
3339    let prefix_joins = std::mem::take(&mut table_with_joins.joins);
3340
3341    join.relation = if prefix_joins.is_empty() {
3342        previous_relation
3343    } else {
3344        TableFactor::NestedJoin {
3345            table_with_joins: Box::new(TableWithJoins {
3346                relation: previous_relation,
3347                joins: prefix_joins,
3348            }),
3349            alias: None,
3350        }
3351    };
3352    join.join_operator = new_operator;
3353
3354    table_with_joins.joins.push(join);
3355    table_with_joins.joins.append(&mut suffix);
3356}
3357
3358fn rewritable_right_join(operator: &JoinOperator) -> bool {
3359    matches!(
3360        operator,
3361        JoinOperator::Right(_)
3362            | JoinOperator::RightOuter(_)
3363            | JoinOperator::RightSemi(_)
3364            | JoinOperator::RightAnti(_)
3365    )
3366}
3367
3368fn rewritten_left_join_operator(operator: JoinOperator) -> Option<JoinOperator> {
3369    match operator {
3370        JoinOperator::Right(constraint) => Some(JoinOperator::Left(constraint)),
3371        JoinOperator::RightOuter(constraint) => Some(JoinOperator::LeftOuter(constraint)),
3372        JoinOperator::RightSemi(constraint) => Some(JoinOperator::LeftSemi(constraint)),
3373        JoinOperator::RightAnti(constraint) => Some(JoinOperator::LeftAnti(constraint)),
3374        _ => None,
3375    }
3376}
3377
3378fn table_factor_alias_ident(relation: &TableFactor) -> Option<&Ident> {
3379    let alias = match relation {
3380        TableFactor::Table { alias, .. }
3381        | TableFactor::Derived { alias, .. }
3382        | TableFactor::TableFunction { alias, .. }
3383        | TableFactor::Function { alias, .. }
3384        | TableFactor::UNNEST { alias, .. }
3385        | TableFactor::JsonTable { alias, .. }
3386        | TableFactor::OpenJsonTable { alias, .. }
3387        | TableFactor::NestedJoin { alias, .. }
3388        | TableFactor::Pivot { alias, .. }
3389        | TableFactor::Unpivot { alias, .. } => alias.as_ref(),
3390        _ => None,
3391    }?;
3392
3393    Some(&alias.name)
3394}
3395
3396fn table_factor_reference_name(relation: &TableFactor) -> Option<String> {
3397    match relation {
3398        TableFactor::Table { name, alias, .. } => {
3399            if let Some(alias) = alias {
3400                Some(alias.name.value.clone())
3401            } else {
3402                name.0
3403                    .last()
3404                    .and_then(|part| part.as_ident())
3405                    .map(|ident| ident.value.clone())
3406            }
3407        }
3408        _ => None,
3409    }
3410}
3411
3412fn rewrite_using_join_constraint(
3413    join_operator: &mut JoinOperator,
3414    left_ref: Option<&str>,
3415    right_ref: Option<&str>,
3416) {
3417    let (Some(left_ref), Some(right_ref)) = (left_ref, right_ref) else {
3418        return;
3419    };
3420
3421    let Some(constraint) = join_constraint_mut(join_operator) else {
3422        return;
3423    };
3424
3425    let JoinConstraint::Using(columns) = constraint else {
3426        return;
3427    };
3428
3429    if columns.is_empty() {
3430        return;
3431    }
3432
3433    let mut combined: Option<Expr> = None;
3434    for object_name in columns.iter() {
3435        let Some(column_ident) = object_name
3436            .0
3437            .last()
3438            .and_then(|part| part.as_ident())
3439            .cloned()
3440        else {
3441            continue;
3442        };
3443
3444        let equality = Expr::BinaryOp {
3445            left: Box::new(Expr::CompoundIdentifier(vec![
3446                Ident::new(left_ref),
3447                column_ident.clone(),
3448            ])),
3449            op: BinaryOperator::Eq,
3450            right: Box::new(Expr::CompoundIdentifier(vec![
3451                Ident::new(right_ref),
3452                column_ident,
3453            ])),
3454        };
3455
3456        combined = Some(match combined {
3457            Some(prev) => Expr::BinaryOp {
3458                left: Box::new(prev),
3459                op: BinaryOperator::And,
3460                right: Box::new(equality),
3461            },
3462            None => equality,
3463        });
3464    }
3465
3466    if let Some(on_expr) = combined {
3467        *constraint = JoinConstraint::On(on_expr);
3468    }
3469}
3470
3471fn fix_table_factor(relation: &mut TableFactor, rule_filter: &RuleFilter) {
3472    match relation {
3473        TableFactor::Table {
3474            args, with_hints, ..
3475        } => {
3476            if let Some(args) = args {
3477                for arg in &mut args.args {
3478                    fix_function_arg(arg, rule_filter);
3479                }
3480            }
3481            for hint in with_hints {
3482                fix_expr(hint, rule_filter);
3483            }
3484        }
3485        TableFactor::Derived { subquery, .. } => fix_query(subquery, rule_filter),
3486        TableFactor::TableFunction { expr, .. } => fix_expr(expr, rule_filter),
3487        TableFactor::Function { args, .. } => {
3488            for arg in args {
3489                fix_function_arg(arg, rule_filter);
3490            }
3491        }
3492        TableFactor::UNNEST { array_exprs, .. } => {
3493            for expr in array_exprs {
3494                fix_expr(expr, rule_filter);
3495            }
3496        }
3497        TableFactor::NestedJoin {
3498            table_with_joins, ..
3499        } => {
3500            if rule_filter.allows(issue_codes::LINT_CV_008) {
3501                rewrite_right_join_to_left(table_with_joins);
3502            }
3503
3504            fix_table_factor(&mut table_with_joins.relation, rule_filter);
3505
3506            let mut left_ref = table_factor_reference_name(&table_with_joins.relation);
3507
3508            for join in &mut table_with_joins.joins {
3509                let right_ref = table_factor_reference_name(&join.relation);
3510                if rule_filter.allows(issue_codes::LINT_ST_007) {
3511                    rewrite_using_join_constraint(
3512                        &mut join.join_operator,
3513                        left_ref.as_deref(),
3514                        right_ref.as_deref(),
3515                    );
3516                }
3517
3518                fix_table_factor(&mut join.relation, rule_filter);
3519                fix_join_operator(&mut join.join_operator, rule_filter);
3520
3521                if right_ref.is_some() {
3522                    left_ref = right_ref;
3523                }
3524            }
3525        }
3526        TableFactor::Pivot {
3527            table,
3528            aggregate_functions,
3529            value_column,
3530            default_on_null,
3531            ..
3532        } => {
3533            fix_table_factor(table, rule_filter);
3534            for func in aggregate_functions {
3535                fix_expr(&mut func.expr, rule_filter);
3536            }
3537            for expr in value_column {
3538                fix_expr(expr, rule_filter);
3539            }
3540            if let Some(expr) = default_on_null {
3541                fix_expr(expr, rule_filter);
3542            }
3543        }
3544        TableFactor::Unpivot {
3545            table,
3546            value,
3547            columns,
3548            ..
3549        } => {
3550            fix_table_factor(table, rule_filter);
3551            fix_expr(value, rule_filter);
3552            for column in columns {
3553                fix_expr(&mut column.expr, rule_filter);
3554            }
3555        }
3556        TableFactor::JsonTable { json_expr, .. } => fix_expr(json_expr, rule_filter),
3557        TableFactor::OpenJsonTable { json_expr, .. } => fix_expr(json_expr, rule_filter),
3558        _ => {}
3559    }
3560}
3561
3562fn fix_join_operator(op: &mut JoinOperator, rule_filter: &RuleFilter) {
3563    match op {
3564        JoinOperator::Join(constraint)
3565        | JoinOperator::Inner(constraint)
3566        | JoinOperator::Left(constraint)
3567        | JoinOperator::LeftOuter(constraint)
3568        | JoinOperator::Right(constraint)
3569        | JoinOperator::RightOuter(constraint)
3570        | JoinOperator::FullOuter(constraint)
3571        | JoinOperator::CrossJoin(constraint)
3572        | JoinOperator::Semi(constraint)
3573        | JoinOperator::LeftSemi(constraint)
3574        | JoinOperator::RightSemi(constraint)
3575        | JoinOperator::Anti(constraint)
3576        | JoinOperator::LeftAnti(constraint)
3577        | JoinOperator::RightAnti(constraint)
3578        | JoinOperator::StraightJoin(constraint) => fix_join_constraint(constraint, rule_filter),
3579        JoinOperator::AsOf {
3580            match_condition,
3581            constraint,
3582        } => {
3583            fix_expr(match_condition, rule_filter);
3584            fix_join_constraint(constraint, rule_filter);
3585        }
3586        JoinOperator::CrossApply | JoinOperator::OuterApply => {}
3587    }
3588}
3589
3590fn join_constraint_mut(join_operator: &mut JoinOperator) -> Option<&mut JoinConstraint> {
3591    match join_operator {
3592        JoinOperator::Join(constraint)
3593        | JoinOperator::Inner(constraint)
3594        | JoinOperator::Left(constraint)
3595        | JoinOperator::LeftOuter(constraint)
3596        | JoinOperator::Right(constraint)
3597        | JoinOperator::RightOuter(constraint)
3598        | JoinOperator::FullOuter(constraint)
3599        | JoinOperator::CrossJoin(constraint)
3600        | JoinOperator::Semi(constraint)
3601        | JoinOperator::LeftSemi(constraint)
3602        | JoinOperator::RightSemi(constraint)
3603        | JoinOperator::Anti(constraint)
3604        | JoinOperator::LeftAnti(constraint)
3605        | JoinOperator::RightAnti(constraint)
3606        | JoinOperator::StraightJoin(constraint) => Some(constraint),
3607        JoinOperator::AsOf { constraint, .. } => Some(constraint),
3608        JoinOperator::CrossApply | JoinOperator::OuterApply => None,
3609    }
3610}
3611
3612fn fix_join_constraint(constraint: &mut JoinConstraint, rule_filter: &RuleFilter) {
3613    if let JoinConstraint::On(expr) = constraint {
3614        fix_expr(expr, rule_filter);
3615    }
3616}
3617
3618fn fix_order_by(order_by: &mut OrderBy, rule_filter: &RuleFilter) {
3619    if let OrderByKind::Expressions(exprs) = &mut order_by.kind {
3620        for order_expr in exprs.iter_mut() {
3621            fix_expr(&mut order_expr.expr, rule_filter);
3622        }
3623    }
3624
3625    if let Some(interpolate) = order_by.interpolate.as_mut() {
3626        if let Some(exprs) = interpolate.exprs.as_mut() {
3627            for expr in exprs {
3628                if let Some(inner) = expr.expr.as_mut() {
3629                    fix_expr(inner, rule_filter);
3630                }
3631            }
3632        }
3633    }
3634}
3635
3636fn fix_limit_clause(limit_clause: &mut LimitClause, rule_filter: &RuleFilter) {
3637    match limit_clause {
3638        LimitClause::LimitOffset {
3639            limit,
3640            offset,
3641            limit_by,
3642        } => {
3643            if let Some(limit) = limit {
3644                fix_expr(limit, rule_filter);
3645            }
3646            if let Some(offset) = offset {
3647                fix_expr(&mut offset.value, rule_filter);
3648            }
3649            for expr in limit_by {
3650                fix_expr(expr, rule_filter);
3651            }
3652        }
3653        LimitClause::OffsetCommaLimit { offset, limit } => {
3654            fix_expr(offset, rule_filter);
3655            fix_expr(limit, rule_filter);
3656        }
3657    }
3658}
3659
3660fn fix_expr(expr: &mut Expr, rule_filter: &RuleFilter) {
3661    match expr {
3662        Expr::BinaryOp { left, right, .. } => {
3663            fix_expr(left, rule_filter);
3664            fix_expr(right, rule_filter);
3665        }
3666        Expr::UnaryOp { expr: inner, .. }
3667        | Expr::Nested(inner)
3668        | Expr::IsNull(inner)
3669        | Expr::IsNotNull(inner)
3670        | Expr::IsTrue(inner)
3671        | Expr::IsNotTrue(inner)
3672        | Expr::IsFalse(inner)
3673        | Expr::IsNotFalse(inner)
3674        | Expr::IsUnknown(inner)
3675        | Expr::IsNotUnknown(inner) => fix_expr(inner, rule_filter),
3676        Expr::Case {
3677            operand,
3678            conditions,
3679            else_result,
3680            ..
3681        } => {
3682            if let Some(operand) = operand.as_mut() {
3683                fix_expr(operand, rule_filter);
3684            }
3685            for case_when in conditions {
3686                fix_expr(&mut case_when.condition, rule_filter);
3687                fix_expr(&mut case_when.result, rule_filter);
3688            }
3689            if let Some(else_result) = else_result.as_mut() {
3690                fix_expr(else_result, rule_filter);
3691            }
3692        }
3693        Expr::Function(func) => fix_function(func, rule_filter),
3694        Expr::Cast { expr: inner, .. } => fix_expr(inner, rule_filter),
3695        Expr::InSubquery {
3696            expr: inner,
3697            subquery,
3698            ..
3699        } => {
3700            fix_expr(inner, rule_filter);
3701            fix_query(subquery, rule_filter);
3702        }
3703        Expr::Subquery(subquery) | Expr::Exists { subquery, .. } => {
3704            fix_query(subquery, rule_filter)
3705        }
3706        Expr::Between {
3707            expr: target,
3708            low,
3709            high,
3710            ..
3711        } => {
3712            fix_expr(target, rule_filter);
3713            fix_expr(low, rule_filter);
3714            fix_expr(high, rule_filter);
3715        }
3716        Expr::InList {
3717            expr: target, list, ..
3718        } => {
3719            fix_expr(target, rule_filter);
3720            for item in list {
3721                fix_expr(item, rule_filter);
3722            }
3723        }
3724        Expr::Tuple(items) => {
3725            for item in items {
3726                fix_expr(item, rule_filter);
3727            }
3728        }
3729        _ => {}
3730    }
3731
3732    // CV11 cast-style rewriting is now handled entirely by the core autofix
3733    // in cv_011.rs, which correctly supports first-seen consistent mode,
3734    // CONVERT conversions, and chained :: expressions.
3735
3736    if rule_filter.allows(issue_codes::LINT_ST_004) {
3737        if let Some(rewritten) = nested_case_rewrite(expr) {
3738            *expr = rewritten;
3739        }
3740    }
3741}
3742
3743fn fix_function(func: &mut Function, rule_filter: &RuleFilter) {
3744    if let FunctionArguments::List(arg_list) = &mut func.args {
3745        for arg in &mut arg_list.args {
3746            fix_function_arg(arg, rule_filter);
3747        }
3748        for clause in &mut arg_list.clauses {
3749            match clause {
3750                FunctionArgumentClause::OrderBy(order_by_exprs) => {
3751                    for order_by_expr in order_by_exprs {
3752                        fix_expr(&mut order_by_expr.expr, rule_filter);
3753                    }
3754                }
3755                FunctionArgumentClause::Limit(expr) => fix_expr(expr, rule_filter),
3756                _ => {}
3757            }
3758        }
3759    }
3760
3761    if let Some(filter) = func.filter.as_mut() {
3762        fix_expr(filter, rule_filter);
3763    }
3764
3765    for order_expr in &mut func.within_group {
3766        fix_expr(&mut order_expr.expr, rule_filter);
3767    }
3768}
3769
3770fn fix_function_arg(arg: &mut FunctionArg, rule_filter: &RuleFilter) {
3771    match arg {
3772        FunctionArg::Named { arg, .. }
3773        | FunctionArg::ExprNamed { arg, .. }
3774        | FunctionArg::Unnamed(arg) => {
3775            if let FunctionArgExpr::Expr(expr) = arg {
3776                fix_expr(expr, rule_filter);
3777            }
3778        }
3779    }
3780}
3781
3782fn nested_case_rewrite(expr: &Expr) -> Option<Expr> {
3783    let Expr::Case {
3784        case_token,
3785        operand: outer_operand,
3786        conditions: outer_conditions,
3787        else_result: Some(outer_else),
3788        end_token,
3789    } = expr
3790    else {
3791        return None;
3792    };
3793
3794    if outer_conditions.is_empty() {
3795        return None;
3796    }
3797
3798    let Expr::Case {
3799        operand: inner_operand,
3800        conditions: inner_conditions,
3801        else_result: inner_else,
3802        ..
3803    } = nested_case_expr(outer_else.as_ref())?
3804    else {
3805        return None;
3806    };
3807
3808    if inner_conditions.is_empty() {
3809        return None;
3810    }
3811
3812    if !case_operands_match(outer_operand.as_deref(), inner_operand.as_deref()) {
3813        return None;
3814    }
3815
3816    let mut merged_conditions = outer_conditions.clone();
3817    merged_conditions.extend(inner_conditions.iter().cloned());
3818
3819    Some(Expr::Case {
3820        case_token: case_token.clone(),
3821        operand: outer_operand.clone(),
3822        conditions: merged_conditions,
3823        else_result: inner_else.clone(),
3824        end_token: end_token.clone(),
3825    })
3826}
3827
3828fn nested_case_expr(expr: &Expr) -> Option<&Expr> {
3829    match expr {
3830        Expr::Case { .. } => Some(expr),
3831        Expr::Nested(inner) => nested_case_expr(inner),
3832        _ => None,
3833    }
3834}
3835
3836fn case_operands_match(outer: Option<&Expr>, inner: Option<&Expr>) -> bool {
3837    match (outer, inner) {
3838        (None, None) => true,
3839        (Some(left), Some(right)) => format!("{left}") == format!("{right}"),
3840        _ => false,
3841    }
3842}
3843
3844#[cfg(test)]
3845mod tests {
3846    use super::*;
3847    use flowscope_core::{
3848        analyze, issue_codes, AnalysisOptions, AnalyzeRequest, Dialect, LintConfig,
3849    };
3850
3851    fn default_lint_config() -> LintConfig {
3852        LintConfig {
3853            enabled: true,
3854            disabled_rules: vec![],
3855            rule_configs: std::collections::BTreeMap::new(),
3856        }
3857    }
3858
3859    fn lint_config_keep_only_rule(rule_code: &str, mut config: LintConfig) -> LintConfig {
3860        let disabled_rules = flowscope_core::linter::rules::all_rules(&default_lint_config())
3861            .into_iter()
3862            .map(|rule| rule.code().to_string())
3863            .filter(|code| !code.eq_ignore_ascii_case(rule_code))
3864            .collect();
3865        config.disabled_rules = disabled_rules;
3866        config
3867    }
3868
3869    fn lint_rule_count_with_config(sql: &str, code: &str, lint_config: &LintConfig) -> usize {
3870        let request = AnalyzeRequest {
3871            sql: sql.to_string(),
3872            files: None,
3873            dialect: Dialect::Generic,
3874            source_name: None,
3875            options: Some(AnalysisOptions {
3876                lint: Some(lint_config.clone()),
3877                ..Default::default()
3878            }),
3879            schema: None,
3880            #[cfg(feature = "templating")]
3881            template_config: None,
3882        };
3883
3884        analyze(&request)
3885            .issues
3886            .iter()
3887            .filter(|issue| issue.code == code)
3888            .count()
3889    }
3890
3891    fn lint_rule_count_with_config_in_dialect(
3892        sql: &str,
3893        code: &str,
3894        dialect: Dialect,
3895        lint_config: &LintConfig,
3896    ) -> usize {
3897        let request = AnalyzeRequest {
3898            sql: sql.to_string(),
3899            files: None,
3900            dialect,
3901            source_name: None,
3902            options: Some(AnalysisOptions {
3903                lint: Some(lint_config.clone()),
3904                ..Default::default()
3905            }),
3906            schema: None,
3907            #[cfg(feature = "templating")]
3908            template_config: None,
3909        };
3910
3911        analyze(&request)
3912            .issues
3913            .iter()
3914            .filter(|issue| issue.code == code)
3915            .count()
3916    }
3917
3918    fn lint_rule_count(sql: &str, code: &str) -> usize {
3919        lint_rule_count_with_config(sql, code, &default_lint_config())
3920    }
3921
3922    fn apply_fix_with_config(sql: &str, lint_config: &LintConfig) -> FixOutcome {
3923        apply_lint_fixes_with_lint_config(sql, Dialect::Generic, lint_config).expect("fix result")
3924    }
3925
3926    fn apply_core_only_fixes(sql: &str) -> FixOutcome {
3927        apply_lint_fixes_with_options(
3928            sql,
3929            Dialect::Generic,
3930            &default_lint_config(),
3931            FixOptions {
3932                include_unsafe_fixes: true,
3933                include_rewrite_candidates: false,
3934            },
3935        )
3936        .expect("fix result")
3937    }
3938
3939    fn sample_outcome(skipped_counts: FixSkippedCounts) -> FixOutcome {
3940        FixOutcome {
3941            sql: String::new(),
3942            counts: FixCounts::default(),
3943            changed: false,
3944            skipped_due_to_comments: false,
3945            skipped_due_to_regression: false,
3946            skipped_counts,
3947        }
3948    }
3949
3950    #[test]
3951    fn collect_fix_candidate_stats_always_counts_display_only_as_blocked() {
3952        let outcome = sample_outcome(FixSkippedCounts {
3953            unsafe_skipped: 1,
3954            protected_range_blocked: 2,
3955            overlap_conflict_blocked: 3,
3956            display_only: 4,
3957        });
3958
3959        let stats = collect_fix_candidate_stats(
3960            &outcome,
3961            LintFixRuntimeOptions {
3962                include_unsafe_fixes: false,
3963                legacy_ast_fixes: false,
3964            },
3965        );
3966
3967        assert_eq!(stats.skipped, 0);
3968        assert_eq!(stats.blocked, 10);
3969        assert_eq!(stats.blocked_unsafe, 1);
3970        assert_eq!(stats.blocked_display_only, 4);
3971        assert_eq!(stats.blocked_protected_range, 2);
3972        assert_eq!(stats.blocked_overlap_conflict, 3);
3973    }
3974
3975    #[test]
3976    fn collect_fix_candidate_stats_excludes_unsafe_when_unsafe_fixes_enabled() {
3977        let outcome = sample_outcome(FixSkippedCounts {
3978            unsafe_skipped: 2,
3979            protected_range_blocked: 1,
3980            overlap_conflict_blocked: 1,
3981            display_only: 3,
3982        });
3983
3984        let stats = collect_fix_candidate_stats(
3985            &outcome,
3986            LintFixRuntimeOptions {
3987                include_unsafe_fixes: true,
3988                legacy_ast_fixes: false,
3989            },
3990        );
3991
3992        assert_eq!(stats.blocked, 5);
3993        assert_eq!(stats.blocked_unsafe, 0);
3994        assert_eq!(stats.blocked_display_only, 3);
3995    }
3996
3997    #[test]
3998    fn mostly_unfixable_residual_detects_dominated_known_residuals() {
3999        let counts = std::collections::BTreeMap::from([
4000            (issue_codes::LINT_LT_005.to_string(), 140usize),
4001            (issue_codes::LINT_RF_002.to_string(), 116usize),
4002            (issue_codes::LINT_AL_003.to_string(), 43usize),
4003            (issue_codes::LINT_RF_004.to_string(), 2usize),
4004            (issue_codes::LINT_ST_009.to_string(), 1usize),
4005        ]);
4006        assert!(is_mostly_unfixable_residual(&counts));
4007    }
4008
4009    #[test]
4010    fn mostly_unfixable_residual_rejects_when_fixable_tail_is_material() {
4011        let counts = std::collections::BTreeMap::from([
4012            (issue_codes::LINT_LT_005.to_string(), 20usize),
4013            (issue_codes::LINT_RF_002.to_string(), 10usize),
4014            (issue_codes::LINT_ST_009.to_string(), 8usize),
4015            (issue_codes::LINT_LT_003.to_string(), 3usize),
4016        ]);
4017        assert!(!is_mostly_unfixable_residual(&counts));
4018    }
4019
4020    #[test]
4021    fn am005_outer_mode_full_join_fix_output() {
4022        let lint_config = LintConfig {
4023            enabled: true,
4024            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4025            rule_configs: std::collections::BTreeMap::from([(
4026                "ambiguous.join".to_string(),
4027                serde_json::json!({"fully_qualify_join_types": "outer"}),
4028            )]),
4029        };
4030        let sql = "SELECT a FROM t FULL JOIN u ON t.id = u.id";
4031        assert_eq!(
4032            lint_rule_count_with_config(
4033                "SELECT a FROM t FULL OUTER JOIN u ON t.id = u.id",
4034                issue_codes::LINT_AM_005,
4035                &lint_config,
4036            ),
4037            0
4038        );
4039        let out = apply_fix_with_config(sql, &lint_config);
4040        assert!(
4041            out.sql.to_ascii_uppercase().contains("FULL OUTER JOIN"),
4042            "expected FULL OUTER JOIN in fixed SQL, got: {}",
4043            out.sql
4044        );
4045        assert_eq!(fix_count_for_code(&out.counts, issue_codes::LINT_AM_005), 1);
4046    }
4047
4048    fn fix_count_for_code(counts: &FixCounts, code: &str) -> usize {
4049        counts.get(code)
4050    }
4051
4052    #[test]
4053    fn lint_rule_counts_includes_parse_errors() {
4054        let counts = lint_rule_counts("SELECT (", Dialect::Generic, &default_lint_config());
4055        assert!(
4056            counts.get(issue_codes::PARSE_ERROR).copied().unwrap_or(0) > 0,
4057            "invalid SQL should contribute PARSE_ERROR to regression counts"
4058        );
4059    }
4060
4061    #[test]
4062    fn parse_error_regression_is_detected_even_with_lint_improvements() {
4063        let before = std::collections::BTreeMap::from([(issue_codes::LINT_ST_005.to_string(), 1)]);
4064        let after = std::collections::BTreeMap::from([(issue_codes::PARSE_ERROR.to_string(), 1)]);
4065        let removed = FixCounts::from_removed(&before, &after);
4066
4067        assert_eq!(
4068            removed.total(),
4069            1,
4070            "lint-only comparison can still look improved"
4071        );
4072        assert!(
4073            parse_errors_increased(&before, &after),
4074            "introduced parse errors must force regression"
4075        );
4076    }
4077
4078    #[test]
4079    fn lint_improvements_can_mask_total_violation_regressions() {
4080        let before = std::collections::BTreeMap::from([
4081            (issue_codes::LINT_LT_002.to_string(), 2usize),
4082            (issue_codes::LINT_LT_001.to_string(), 0usize),
4083        ]);
4084        let after = std::collections::BTreeMap::from([
4085            (issue_codes::LINT_LT_002.to_string(), 1usize),
4086            (issue_codes::LINT_LT_001.to_string(), 2usize),
4087        ]);
4088        let removed = FixCounts::from_removed(&before, &after);
4089        let before_total: usize = before.values().sum();
4090        let after_total: usize = after.values().sum();
4091
4092        assert_eq!(
4093            removed.total(),
4094            1,
4095            "a rule-level improvement can still be observed"
4096        );
4097        assert!(
4098            after_total > before_total,
4099            "strict regression guard must reject net-violation increases"
4100        );
4101    }
4102
4103    #[test]
4104    fn lt03_improvement_allows_lt05_tradeoff_at_equal_totals() {
4105        let before = std::collections::BTreeMap::from([
4106            (issue_codes::LINT_LT_003.to_string(), 1usize),
4107            (issue_codes::LINT_LT_005.to_string(), 5usize),
4108        ]);
4109        let after = std::collections::BTreeMap::from([
4110            (issue_codes::LINT_LT_003.to_string(), 0usize),
4111            (issue_codes::LINT_LT_005.to_string(), 6usize),
4112        ]);
4113        let core_rules = std::collections::HashSet::from([
4114            issue_codes::LINT_LT_003.to_string(),
4115            issue_codes::LINT_LT_005.to_string(),
4116        ]);
4117
4118        assert!(
4119            !core_autofix_rules_not_improved(&before, &after, &core_rules),
4120            "LT03 improvements should be allowed to trade against LT05 at equal totals"
4121        );
4122    }
4123
4124    #[test]
4125    fn lt05_tradeoff_is_not_allowed_without_lt03_improvement() {
4126        let before = std::collections::BTreeMap::from([
4127            (issue_codes::LINT_LT_003.to_string(), 1usize),
4128            (issue_codes::LINT_LT_005.to_string(), 5usize),
4129        ]);
4130        let after = std::collections::BTreeMap::from([
4131            (issue_codes::LINT_LT_003.to_string(), 1usize),
4132            (issue_codes::LINT_LT_005.to_string(), 6usize),
4133        ]);
4134        let core_rules = std::collections::HashSet::from([
4135            issue_codes::LINT_LT_003.to_string(),
4136            issue_codes::LINT_LT_005.to_string(),
4137        ]);
4138
4139        assert!(
4140            core_autofix_rules_not_improved(&before, &after, &core_rules),
4141            "without LT03 improvement, LT05 worsening remains blocked"
4142        );
4143    }
4144
4145    fn assert_rule_case(
4146        sql: &str,
4147        code: &str,
4148        expected_before: usize,
4149        expected_after: usize,
4150        expected_fix_count: usize,
4151    ) {
4152        let before = lint_rule_count(sql, code);
4153        assert_eq!(
4154            before, expected_before,
4155            "unexpected initial lint count for {code} in SQL: {sql}"
4156        );
4157
4158        let out = apply_core_only_fixes(sql);
4159        assert!(
4160            !out.skipped_due_to_comments,
4161            "test SQL should not be skipped"
4162        );
4163        assert_eq!(
4164            fix_count_for_code(&out.counts, code),
4165            expected_fix_count,
4166            "unexpected fix count for {code} in SQL: {sql}"
4167        );
4168
4169        if expected_fix_count > 0 {
4170            assert!(out.changed, "expected SQL to change for {code}: {sql}");
4171        }
4172
4173        let after = lint_rule_count(&out.sql, code);
4174        assert_eq!(
4175            after, expected_after,
4176            "unexpected lint count after fix for {code}. SQL: {}",
4177            out.sql
4178        );
4179
4180        let second_pass = apply_core_only_fixes(&out.sql);
4181        assert_eq!(
4182            fix_count_for_code(&second_pass.counts, code),
4183            0,
4184            "expected idempotent second pass for {code}"
4185        );
4186    }
4187
4188    fn assert_rule_case_with_config(
4189        sql: &str,
4190        code: &str,
4191        expected_before: usize,
4192        expected_after: usize,
4193        expected_fix_count: usize,
4194        lint_config: &LintConfig,
4195    ) {
4196        let before = lint_rule_count_with_config(sql, code, lint_config);
4197        assert_eq!(
4198            before, expected_before,
4199            "unexpected initial lint count for {code} in SQL: {sql}"
4200        );
4201
4202        let out = apply_fix_with_config(sql, lint_config);
4203        assert!(
4204            !out.skipped_due_to_comments,
4205            "test SQL should not be skipped"
4206        );
4207        assert_eq!(
4208            fix_count_for_code(&out.counts, code),
4209            expected_fix_count,
4210            "unexpected fix count for {code} in SQL: {sql}"
4211        );
4212
4213        if expected_fix_count > 0 {
4214            assert!(out.changed, "expected SQL to change for {code}: {sql}");
4215        }
4216
4217        let after = lint_rule_count_with_config(&out.sql, code, lint_config);
4218        assert_eq!(
4219            after, expected_after,
4220            "unexpected lint count after fix for {code}. SQL: {}",
4221            out.sql
4222        );
4223
4224        let second_pass = apply_fix_with_config(&out.sql, lint_config);
4225        assert_eq!(
4226            fix_count_for_code(&second_pass.counts, code),
4227            0,
4228            "expected idempotent second pass for {code}"
4229        );
4230    }
4231
4232    #[test]
4233    fn sqlfluff_am003_cases_are_fixed() {
4234        let cases = [
4235            ("SELECT DISTINCT col FROM t GROUP BY col", 1, 0, 1),
4236            (
4237                "SELECT * FROM (SELECT DISTINCT a FROM t GROUP BY a) AS sub",
4238                1,
4239                0,
4240                1,
4241            ),
4242            (
4243                "WITH cte AS (SELECT DISTINCT a FROM t GROUP BY a) SELECT * FROM cte",
4244                1,
4245                0,
4246                1,
4247            ),
4248            (
4249                "CREATE VIEW v AS SELECT DISTINCT a FROM t GROUP BY a",
4250                1,
4251                0,
4252                1,
4253            ),
4254            (
4255                "INSERT INTO target SELECT DISTINCT a FROM t GROUP BY a",
4256                1,
4257                0,
4258                1,
4259            ),
4260            (
4261                "SELECT a FROM t UNION ALL SELECT DISTINCT b FROM t2 GROUP BY b",
4262                1,
4263                0,
4264                1,
4265            ),
4266            ("SELECT a, b FROM t", 0, 0, 0),
4267        ];
4268
4269        for (sql, before, after, fix_count) in cases {
4270            assert_rule_case(sql, issue_codes::LINT_AM_001, before, after, fix_count);
4271        }
4272    }
4273
4274    #[test]
4275    fn sqlfluff_am001_cases_are_fixed_or_unchanged() {
4276        let lint_config = LintConfig {
4277            enabled: true,
4278            disabled_rules: vec![issue_codes::LINT_LT_011.to_string()],
4279            rule_configs: std::collections::BTreeMap::new(),
4280        };
4281        let cases = [
4282            (
4283                "SELECT a, b FROM tbl UNION SELECT c, d FROM tbl1",
4284                1,
4285                0,
4286                1,
4287                Some("DISTINCT SELECT"),
4288            ),
4289            (
4290                "SELECT a, b FROM tbl UNION ALL SELECT c, d FROM tbl1",
4291                0,
4292                0,
4293                0,
4294                None,
4295            ),
4296            (
4297                "SELECT a, b FROM tbl UNION DISTINCT SELECT c, d FROM tbl1",
4298                0,
4299                0,
4300                0,
4301                None,
4302            ),
4303            (
4304                "select a, b from tbl union select c, d from tbl1",
4305                1,
4306                0,
4307                1,
4308                Some("DISTINCT SELECT"),
4309            ),
4310        ];
4311
4312        for (sql, before, after, fix_count, expected_text) in cases {
4313            assert_rule_case_with_config(
4314                sql,
4315                issue_codes::LINT_AM_002,
4316                before,
4317                after,
4318                fix_count,
4319                &lint_config,
4320            );
4321
4322            if let Some(expected) = expected_text {
4323                let out = apply_fix_with_config(sql, &lint_config);
4324                assert!(
4325                    out.sql.to_ascii_uppercase().contains(expected),
4326                    "expected {expected:?} in fixed SQL, got: {}",
4327                    out.sql
4328                );
4329            }
4330        }
4331    }
4332
4333    #[test]
4334    fn sqlfluff_am005_cases_are_fixed_or_unchanged() {
4335        let cases = [
4336            (
4337                "SELECT * FROM t ORDER BY a, b DESC",
4338                1,
4339                0,
4340                1,
4341                Some("ORDER BY A ASC, B DESC"),
4342            ),
4343            (
4344                "SELECT * FROM t ORDER BY a DESC, b",
4345                1,
4346                0,
4347                1,
4348                Some("ORDER BY A DESC, B ASC"),
4349            ),
4350            (
4351                "SELECT * FROM t ORDER BY a DESC, b NULLS LAST",
4352                1,
4353                0,
4354                1,
4355                Some("ORDER BY A DESC, B ASC NULLS LAST"),
4356            ),
4357            ("SELECT * FROM t ORDER BY a, b", 0, 0, 0, None),
4358            ("SELECT * FROM t ORDER BY a ASC, b DESC", 0, 0, 0, None),
4359        ];
4360
4361        for (sql, before, after, fix_count, expected_text) in cases {
4362            assert_rule_case(sql, issue_codes::LINT_AM_003, before, after, fix_count);
4363
4364            if let Some(expected) = expected_text {
4365                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4366                assert!(
4367                    out.sql.to_ascii_uppercase().contains(expected),
4368                    "expected {expected:?} in fixed SQL, got: {}",
4369                    out.sql
4370                );
4371            }
4372        }
4373    }
4374
4375    #[test]
4376    fn sqlfluff_am006_cases_are_fixed_or_unchanged() {
4377        let cases = [
4378            (
4379                "SELECT a FROM t JOIN u ON t.id = u.id",
4380                1,
4381                0,
4382                1,
4383                Some("INNER JOIN"),
4384            ),
4385            (
4386                "SELECT a FROM t JOIN u ON t.id = u.id JOIN v ON u.id = v.id",
4387                2,
4388                0,
4389                2,
4390                Some("INNER JOIN U"),
4391            ),
4392            ("SELECT a FROM t INNER JOIN u ON t.id = u.id", 0, 0, 0, None),
4393            ("SELECT a FROM t LEFT JOIN u ON t.id = u.id", 0, 0, 0, None),
4394        ];
4395
4396        for (sql, before, after, fix_count, expected_text) in cases {
4397            assert_rule_case(sql, issue_codes::LINT_AM_005, before, after, fix_count);
4398
4399            if let Some(expected) = expected_text {
4400                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4401                assert!(
4402                    out.sql.to_ascii_uppercase().contains(expected),
4403                    "expected {expected:?} in fixed SQL, got: {}",
4404                    out.sql
4405                );
4406            }
4407        }
4408    }
4409
4410    #[test]
4411    fn sqlfluff_am005_outer_and_both_configs_are_fixed() {
4412        let outer_config = LintConfig {
4413            enabled: true,
4414            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4415            rule_configs: std::collections::BTreeMap::from([(
4416                "ambiguous.join".to_string(),
4417                serde_json::json!({"fully_qualify_join_types": "outer"}),
4418            )]),
4419        };
4420        let both_config = LintConfig {
4421            enabled: true,
4422            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4423            rule_configs: std::collections::BTreeMap::from([(
4424                "ambiguous.join".to_string(),
4425                serde_json::json!({"fully_qualify_join_types": "both"}),
4426            )]),
4427        };
4428
4429        let outer_cases = [
4430            (
4431                "SELECT a FROM t LEFT JOIN u ON t.id = u.id",
4432                1,
4433                0,
4434                1,
4435                Some("LEFT OUTER JOIN"),
4436            ),
4437            (
4438                "SELECT a FROM t RIGHT JOIN u ON t.id = u.id",
4439                1,
4440                0,
4441                1,
4442                Some("RIGHT OUTER JOIN"),
4443            ),
4444            (
4445                "SELECT a FROM t FULL JOIN u ON t.id = u.id",
4446                1,
4447                0,
4448                1,
4449                Some("FULL OUTER JOIN"),
4450            ),
4451            (
4452                "SELECT a FROM t full join u ON t.id = u.id",
4453                1,
4454                0,
4455                1,
4456                Some("FULL OUTER JOIN"),
4457            ),
4458            ("SELECT a FROM t JOIN u ON t.id = u.id", 0, 0, 0, None),
4459        ];
4460        for (sql, before, after, fix_count, expected_text) in outer_cases {
4461            assert_rule_case_with_config(
4462                sql,
4463                issue_codes::LINT_AM_005,
4464                before,
4465                after,
4466                fix_count,
4467                &outer_config,
4468            );
4469            if let Some(expected) = expected_text {
4470                let out = apply_fix_with_config(sql, &outer_config);
4471                assert!(
4472                    out.sql.to_ascii_uppercase().contains(expected),
4473                    "expected {expected:?} in fixed SQL, got: {}",
4474                    out.sql
4475                );
4476            }
4477        }
4478
4479        let both_cases = [
4480            (
4481                "SELECT a FROM t JOIN u ON t.id = u.id",
4482                1,
4483                0,
4484                1,
4485                Some("INNER JOIN"),
4486            ),
4487            (
4488                "SELECT a FROM t LEFT JOIN u ON t.id = u.id",
4489                1,
4490                0,
4491                1,
4492                Some("LEFT OUTER JOIN"),
4493            ),
4494            (
4495                "SELECT a FROM t FULL JOIN u ON t.id = u.id",
4496                1,
4497                0,
4498                1,
4499                Some("FULL OUTER JOIN"),
4500            ),
4501        ];
4502        for (sql, before, after, fix_count, expected_text) in both_cases {
4503            assert_rule_case_with_config(
4504                sql,
4505                issue_codes::LINT_AM_005,
4506                before,
4507                after,
4508                fix_count,
4509                &both_config,
4510            );
4511            if let Some(expected) = expected_text {
4512                let out = apply_fix_with_config(sql, &both_config);
4513                assert!(
4514                    out.sql.to_ascii_uppercase().contains(expected),
4515                    "expected {expected:?} in fixed SQL, got: {}",
4516                    out.sql
4517                );
4518            }
4519        }
4520    }
4521
4522    #[test]
4523    fn sqlfluff_am009_cases_are_fixed_or_unchanged() {
4524        let cases = [
4525            (
4526                "SELECT foo.a, bar.b FROM foo INNER JOIN bar",
4527                1,
4528                0,
4529                1,
4530                Some("CROSS JOIN BAR"),
4531            ),
4532            (
4533                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar",
4534                1,
4535                0,
4536                1,
4537                Some("CROSS JOIN BAR"),
4538            ),
4539            (
4540                "SELECT foo.a, bar.b FROM foo JOIN bar WHERE foo.a = bar.a OR foo.x = 3",
4541                0,
4542                0,
4543                0,
4544                None,
4545            ),
4546            ("SELECT foo.a, bar.b FROM foo CROSS JOIN bar", 0, 0, 0, None),
4547            (
4548                "SELECT foo.id, bar.id FROM foo LEFT JOIN bar USING (id)",
4549                0,
4550                0,
4551                0,
4552                None,
4553            ),
4554        ];
4555
4556        for (sql, before, after, fix_count, expected_text) in cases {
4557            assert_rule_case(sql, issue_codes::LINT_AM_008, before, after, fix_count);
4558
4559            if let Some(expected) = expected_text {
4560                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4561                assert!(
4562                    out.sql.to_ascii_uppercase().contains(expected),
4563                    "expected {expected:?} in fixed SQL, got: {}",
4564                    out.sql
4565                );
4566            }
4567        }
4568    }
4569
4570    #[test]
4571    fn sqlfluff_al007_force_enabled_single_table_alias_is_fixed() {
4572        let lint_config = LintConfig {
4573            enabled: true,
4574            disabled_rules: vec![],
4575            rule_configs: std::collections::BTreeMap::from([(
4576                "aliasing.forbid".to_string(),
4577                serde_json::json!({"force_enable": true}),
4578            )]),
4579        };
4580        let sql = "SELECT u.id FROM users u";
4581        assert_rule_case_with_config(sql, issue_codes::LINT_AL_007, 1, 0, 1, &lint_config);
4582
4583        let out = apply_fix_with_config(sql, &lint_config);
4584        let fixed_upper = out.sql.to_ascii_uppercase();
4585        assert!(
4586            fixed_upper.contains("FROM USERS"),
4587            "expected table alias to be removed: {}",
4588            out.sql
4589        );
4590        assert!(
4591            !fixed_upper.contains("FROM USERS U"),
4592            "expected unnecessary table alias to be removed: {}",
4593            out.sql
4594        );
4595        assert!(
4596            fixed_upper.contains("USERS.ID"),
4597            "expected references to use table name after alias removal: {}",
4598            out.sql
4599        );
4600    }
4601
4602    #[test]
4603    fn sqlfluff_al009_fix_respects_case_sensitive_mode() {
4604        let lint_config = LintConfig {
4605            enabled: true,
4606            // Disable CP_002 so identifier lowercasing does not turn `A` into `a`,
4607            // which would create a new AL_009 self-alias violation.
4608            disabled_rules: vec![issue_codes::LINT_CP_002.to_string()],
4609            rule_configs: std::collections::BTreeMap::from([(
4610                "aliasing.self_alias.column".to_string(),
4611                serde_json::json!({"alias_case_check": "case_sensitive"}),
4612            )]),
4613        };
4614        let sql = "SELECT a AS A FROM t";
4615        assert_rule_case_with_config(sql, issue_codes::LINT_AL_009, 0, 0, 0, &lint_config);
4616
4617        let out = apply_fix_with_config(sql, &lint_config);
4618        assert!(
4619            out.sql.contains("AS A"),
4620            "case-sensitive mode should keep case-mismatched alias: {}",
4621            out.sql
4622        );
4623    }
4624
4625    #[test]
4626    fn sqlfluff_al009_ast_fix_keeps_table_aliases() {
4627        let lint_config = LintConfig {
4628            enabled: true,
4629            disabled_rules: vec![issue_codes::LINT_AL_007.to_string()],
4630            rule_configs: std::collections::BTreeMap::new(),
4631        };
4632        let sql = "SELECT t.a AS a FROM t AS t";
4633        assert_rule_case_with_config(sql, issue_codes::LINT_AL_009, 1, 0, 1, &lint_config);
4634
4635        let out = apply_fix_with_config(sql, &lint_config);
4636        let fixed_upper = out.sql.to_ascii_uppercase();
4637        assert!(
4638            fixed_upper.contains("FROM T AS T"),
4639            "AL09 fix should not remove table alias declarations: {}",
4640            out.sql
4641        );
4642        assert!(
4643            !fixed_upper.contains("T.A AS A"),
4644            "expected only column self-alias to be removed: {}",
4645            out.sql
4646        );
4647    }
4648
4649    #[test]
4650    fn sqlfluff_st002_unnecessary_case_fix_cases() {
4651        let cases = [
4652            // Bool coalesce: CASE WHEN cond THEN TRUE ELSE FALSE END → coalesce(cond, false)
4653            (
4654                "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t",
4655                1,
4656                0,
4657                1,
4658                Some("COALESCE(X > 0, FALSE)"),
4659            ),
4660            // Negated bool: CASE WHEN cond THEN FALSE ELSE TRUE END → not coalesce(cond, false)
4661            (
4662                "SELECT CASE WHEN x > 0 THEN false ELSE true END FROM t",
4663                1,
4664                0,
4665                1,
4666                Some("NOT COALESCE(X > 0, FALSE)"),
4667            ),
4668            // Null coalesce: CASE WHEN x IS NULL THEN y ELSE x END → coalesce(x, y)
4669            (
4670                "SELECT CASE WHEN x IS NULL THEN 0 ELSE x END FROM t",
4671                1,
4672                0,
4673                1,
4674                Some("COALESCE(X, 0)"),
4675            ),
4676            // Not flagged: regular searched CASE (not an unnecessary pattern)
4677            (
4678                "SELECT CASE WHEN x = 1 THEN 'a' WHEN x = 2 THEN 'b' END FROM t",
4679                0,
4680                0,
4681                0,
4682                None,
4683            ),
4684        ];
4685
4686        for (sql, before, after, fix_count, expected_text) in cases {
4687            assert_rule_case(sql, issue_codes::LINT_ST_002, before, after, fix_count);
4688
4689            if let Some(expected) = expected_text {
4690                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4691                assert!(
4692                    out.sql.to_ascii_uppercase().contains(expected),
4693                    "expected {expected:?} in fixed SQL, got: {}",
4694                    out.sql
4695                );
4696            }
4697        }
4698    }
4699
4700    #[test]
4701    fn sqlfluff_st006_cases_are_fixed_or_unchanged() {
4702        let cases = [
4703            ("SELECT a + 1, a FROM t", 1, 0, 1, Some("A,\n    A + 1")),
4704            (
4705                "SELECT a + 1, b + 2, a FROM t",
4706                1,
4707                0,
4708                1,
4709                Some("A,\n    A + 1,\n    B + 2"),
4710            ),
4711            (
4712                "SELECT a + 1, b AS b_alias FROM t",
4713                1,
4714                0,
4715                1,
4716                Some("B AS B_ALIAS,\n    A + 1"),
4717            ),
4718            ("SELECT a, b + 1 FROM t", 0, 0, 0, None),
4719            ("SELECT a + 1, b + 2 FROM t", 0, 0, 0, None),
4720        ];
4721
4722        for (sql, before, after, fix_count, expected_text) in cases {
4723            assert_rule_case(sql, issue_codes::LINT_ST_006, before, after, fix_count);
4724
4725            if let Some(expected) = expected_text {
4726                let out = apply_core_only_fixes(sql);
4727                assert!(
4728                    out.sql.to_ascii_uppercase().contains(expected),
4729                    "expected {expected:?} in fixed SQL, got: {}",
4730                    out.sql
4731                );
4732            }
4733        }
4734    }
4735
4736    #[test]
4737    fn sqlfluff_st008_cases_are_fixed_or_unchanged() {
4738        let cases = [
4739            (
4740                "SELECT DISTINCT(a) FROM t",
4741                1,
4742                0,
4743                1,
4744                Some("SELECT DISTINCT A"),
4745            ),
4746            ("SELECT DISTINCT a FROM t", 0, 0, 0, None),
4747            ("SELECT a FROM t", 0, 0, 0, None),
4748        ];
4749
4750        for (sql, before, after, fix_count, expected_text) in cases {
4751            assert_rule_case(sql, issue_codes::LINT_ST_008, before, after, fix_count);
4752
4753            if let Some(expected) = expected_text {
4754                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4755                assert!(
4756                    out.sql.to_ascii_uppercase().contains(expected),
4757                    "expected {expected:?} in fixed SQL, got: {}",
4758                    out.sql
4759                );
4760            }
4761        }
4762    }
4763
4764    #[test]
4765    fn sqlfluff_st009_cases_are_fixed_or_unchanged() {
4766        let cases = [
4767            (
4768                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON bar.a = foo.a",
4769                1,
4770                0,
4771                1,
4772                Some("ON FOO.A = BAR.A"),
4773            ),
4774            (
4775                "SELECT foo.a, foo.b, bar.c FROM foo LEFT JOIN bar ON bar.a = foo.a AND bar.b = foo.b",
4776                1,
4777                1,
4778                0,
4779                None,
4780            ),
4781            (
4782                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON foo.a = bar.a",
4783                0,
4784                0,
4785                0,
4786                None,
4787            ),
4788            (
4789                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON bar.b = a",
4790                0,
4791                0,
4792                0,
4793                None,
4794            ),
4795            (
4796                "SELECT foo.a, bar.b FROM foo AS x LEFT JOIN bar AS y ON y.a = x.a",
4797                1,
4798                0,
4799                1,
4800                Some("ON X.A = Y.A"),
4801            ),
4802        ];
4803
4804        for (sql, before, after, fix_count, expected_text) in cases {
4805            if before == after && fix_count == 0 {
4806                let initial = lint_rule_count(sql, issue_codes::LINT_ST_009);
4807                assert_eq!(
4808                    initial,
4809                    before,
4810                    "unexpected initial lint count for {} in SQL: {}",
4811                    issue_codes::LINT_ST_009,
4812                    sql
4813                );
4814
4815                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4816                assert_eq!(
4817                    fix_count_for_code(&out.counts, issue_codes::LINT_ST_009),
4818                    0,
4819                    "unexpected fix count for {} in SQL: {}",
4820                    issue_codes::LINT_ST_009,
4821                    sql
4822                );
4823                let after_count = lint_rule_count(&out.sql, issue_codes::LINT_ST_009);
4824                assert_eq!(
4825                    after_count,
4826                    after,
4827                    "unexpected lint count after fix for {}. SQL: {}",
4828                    issue_codes::LINT_ST_009,
4829                    out.sql
4830                );
4831            } else {
4832                assert_rule_case(sql, issue_codes::LINT_ST_009, before, after, fix_count);
4833            }
4834
4835            if let Some(expected) = expected_text {
4836                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4837                assert!(
4838                    out.sql.to_ascii_uppercase().contains(expected),
4839                    "expected {expected:?} in fixed SQL, got: {}",
4840                    out.sql
4841                );
4842            }
4843        }
4844    }
4845
4846    #[test]
4847    fn sqlfluff_st007_cases_are_fixed_or_unchanged() {
4848        let cases = [
4849            (
4850                "SELECT * FROM a JOIN b USING (id)",
4851                1,
4852                0,
4853                1,
4854                Some("ON A.ID = B.ID"),
4855            ),
4856            (
4857                "SELECT * FROM a AS x JOIN b AS y USING (id)",
4858                1,
4859                0,
4860                1,
4861                Some("ON X.ID = Y.ID"),
4862            ),
4863            (
4864                "SELECT * FROM a JOIN b USING (id, tenant_id)",
4865                1,
4866                0,
4867                1,
4868                Some("ON A.ID = B.ID AND A.TENANT_ID = B.TENANT_ID"),
4869            ),
4870            ("SELECT * FROM a JOIN b ON a.id = b.id", 0, 0, 0, None),
4871        ];
4872
4873        for (sql, before, after, fix_count, expected_text) in cases {
4874            assert_rule_case(sql, issue_codes::LINT_ST_007, before, after, fix_count);
4875
4876            if let Some(expected) = expected_text {
4877                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4878                assert!(
4879                    out.sql.to_ascii_uppercase().contains(expected),
4880                    "expected {expected:?} in fixed SQL, got: {}",
4881                    out.sql
4882                );
4883            }
4884        }
4885    }
4886
4887    #[test]
4888    fn sqlfluff_st004_cases_are_fixed_or_unchanged() {
4889        let cases = [
4890            (
4891                "SELECT CASE WHEN species = 'Rat' THEN 'Squeak' ELSE CASE WHEN species = 'Dog' THEN 'Woof' END END AS sound FROM mytable",
4892                1,
4893                1,
4894                0,
4895            ),
4896            (
4897                "SELECT CASE WHEN species = 'Rat' THEN 'Squeak' ELSE CASE WHEN species = 'Dog' THEN 'Woof' WHEN species = 'Mouse' THEN 'Squeak' ELSE 'Other' END END AS sound FROM mytable",
4898                1,
4899                1,
4900                0,
4901            ),
4902            (
4903                "SELECT CASE WHEN species = 'Rat' THEN CASE WHEN colour = 'Black' THEN 'Growl' WHEN colour = 'Grey' THEN 'Squeak' END END AS sound FROM mytable",
4904                0,
4905                0,
4906                0,
4907            ),
4908            (
4909                "SELECT CASE WHEN day_of_month IN (11, 12, 13) THEN 'TH' ELSE CASE MOD(day_of_month, 10) WHEN 1 THEN 'ST' WHEN 2 THEN 'ND' WHEN 3 THEN 'RD' ELSE 'TH' END END AS ordinal_suffix FROM calendar",
4910                0,
4911                0,
4912                0,
4913            ),
4914            (
4915                "SELECT CASE x WHEN 0 THEN 'zero' WHEN 5 THEN 'five' ELSE CASE x WHEN 10 THEN 'ten' WHEN 20 THEN 'twenty' ELSE 'other' END END FROM tab_a",
4916                1,
4917                1,
4918                0,
4919            ),
4920        ];
4921
4922        for (sql, before, after, fix_count) in cases {
4923            assert_rule_case(sql, issue_codes::LINT_ST_004, before, after, fix_count);
4924        }
4925    }
4926
4927    #[test]
4928    fn sqlfluff_cv003_cases_are_fixed_or_unchanged() {
4929        let cases = [
4930            ("SELECT a FROM foo WHERE a IS NULL", 0, 0, 0, None),
4931            ("SELECT a FROM foo WHERE a IS NOT NULL", 0, 0, 0, None),
4932            (
4933                "SELECT a FROM foo WHERE a <> NULL",
4934                1,
4935                0,
4936                1,
4937                Some("WHERE A IS NOT NULL"),
4938            ),
4939            (
4940                "SELECT a FROM foo WHERE a <> NULL AND b != NULL AND c = 'foo'",
4941                2,
4942                0,
4943                2,
4944                Some("A IS NOT NULL AND B IS NOT NULL"),
4945            ),
4946            (
4947                "SELECT a FROM foo WHERE a = NULL",
4948                1,
4949                0,
4950                1,
4951                Some("WHERE A IS NULL"),
4952            ),
4953            (
4954                "SELECT a FROM foo WHERE a=NULL",
4955                1,
4956                0,
4957                1,
4958                Some("WHERE A IS NULL"),
4959            ),
4960            (
4961                "SELECT a FROM foo WHERE a = b OR (c > d OR e = NULL)",
4962                1,
4963                0,
4964                1,
4965                Some("OR E IS NULL"),
4966            ),
4967            ("UPDATE table1 SET col = NULL WHERE col = ''", 0, 0, 0, None),
4968        ];
4969
4970        for (sql, before, after, fix_count, expected_text) in cases {
4971            assert_rule_case(sql, issue_codes::LINT_CV_005, before, after, fix_count);
4972
4973            if let Some(expected) = expected_text {
4974                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4975                assert!(
4976                    out.sql.to_ascii_uppercase().contains(expected),
4977                    "expected {expected:?} in fixed SQL, got: {}",
4978                    out.sql
4979                );
4980            }
4981        }
4982    }
4983
4984    #[test]
4985    fn sqlfluff_cv001_cases_are_fixed_or_unchanged() {
4986        let cases = [
4987            ("SELECT coalesce(foo, 0) AS bar FROM baz", 0, 0, 0),
4988            ("SELECT ifnull(foo, 0) AS bar FROM baz", 1, 0, 1),
4989            ("SELECT nvl(foo, 0) AS bar FROM baz", 1, 0, 1),
4990            (
4991                "SELECT CASE WHEN x IS NULL THEN 'default' ELSE x END FROM t",
4992                0,
4993                0,
4994                0,
4995            ),
4996        ];
4997
4998        for (sql, before, after, fix_count) in cases {
4999            assert_rule_case(sql, issue_codes::LINT_CV_002, before, after, fix_count);
5000        }
5001    }
5002
5003    #[test]
5004    fn sqlfluff_cv003_trailing_comma_cases_are_fixed_or_unchanged() {
5005        let cases = [
5006            ("SELECT a, FROM t", 1, 0, 1),
5007            ("SELECT a , FROM t", 1, 0, 1),
5008            ("SELECT a FROM t", 0, 0, 0),
5009        ];
5010
5011        for (sql, before, after, fix_count) in cases {
5012            assert_rule_case(sql, issue_codes::LINT_CV_003, before, after, fix_count);
5013        }
5014    }
5015
5016    #[test]
5017    fn sqlfluff_cv001_not_equal_style_cases_are_fixed_or_unchanged() {
5018        let cases = [
5019            ("SELECT * FROM t WHERE a <> b AND c != d", 1, 0, 1),
5020            ("SELECT * FROM t WHERE a != b", 0, 0, 0),
5021        ];
5022
5023        for (sql, before, after, fix_count) in cases {
5024            assert_rule_case(sql, issue_codes::LINT_CV_001, before, after, fix_count);
5025        }
5026    }
5027
5028    #[test]
5029    fn sqlfluff_cv008_cases_are_fixed_or_unchanged() {
5030        let cases: [(&str, usize, usize, usize, Option<&str>); 4] = [
5031            ("SELECT * FROM a RIGHT JOIN b ON a.id = b.id", 1, 1, 0, None),
5032            (
5033                "SELECT a.id FROM a JOIN b ON a.id = b.id RIGHT JOIN c ON b.id = c.id",
5034                1,
5035                1,
5036                0,
5037                None,
5038            ),
5039            (
5040                "SELECT a.id FROM a RIGHT JOIN b ON a.id = b.id RIGHT JOIN c ON b.id = c.id",
5041                2,
5042                2,
5043                0,
5044                None,
5045            ),
5046            ("SELECT * FROM a LEFT JOIN b ON a.id = b.id", 0, 0, 0, None),
5047        ];
5048
5049        for (sql, before, after, fix_count, expected_text) in cases {
5050            assert_rule_case(sql, issue_codes::LINT_CV_008, before, after, fix_count);
5051
5052            if let Some(expected) = expected_text {
5053                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5054                assert!(
5055                    out.sql.to_ascii_uppercase().contains(expected),
5056                    "expected {expected:?} in fixed SQL, got: {}",
5057                    out.sql
5058                );
5059            }
5060        }
5061    }
5062
5063    #[test]
5064    fn sqlfluff_cv007_cases_are_fixed_or_unchanged() {
5065        let cases = [
5066            ("(SELECT 1)", 1, 0, 1),
5067            ("((SELECT 1))", 1, 0, 1),
5068            ("SELECT 1", 0, 0, 0),
5069        ];
5070
5071        for (sql, before, after, fix_count) in cases {
5072            assert_rule_case(sql, issue_codes::LINT_CV_007, before, after, fix_count);
5073        }
5074    }
5075
5076    #[test]
5077    fn cv007_fix_respects_disabled_rules() {
5078        let sql = "(SELECT 1)\n";
5079        let out = apply_lint_fixes(
5080            sql,
5081            Dialect::Generic,
5082            &[issue_codes::LINT_CV_007.to_string()],
5083        )
5084        .expect("fix result");
5085        assert_eq!(out.sql, sql);
5086        assert_eq!(out.counts.get(issue_codes::LINT_CV_007), 0);
5087    }
5088
5089    #[test]
5090    fn cv010_fix_converts_double_to_single_in_bigquery() {
5091        let lint_config = LintConfig {
5092            enabled: true,
5093            disabled_rules: vec![],
5094            rule_configs: std::collections::BTreeMap::from([(
5095                "convention.quoted_literals".to_string(),
5096                serde_json::json!({"preferred_quoted_literal_style": "single_quotes"}),
5097            )]),
5098        };
5099        // In BigQuery, both "abc" and 'abc' are string literals.
5100        let sql = "SELECT \"abc\"";
5101        let before = lint_rule_count_with_config_in_dialect(
5102            sql,
5103            issue_codes::LINT_CV_010,
5104            Dialect::Bigquery,
5105            &lint_config,
5106        );
5107        assert_eq!(
5108            before, 1,
5109            "CV10 should flag double-quoted string in BigQuery with single_quotes preference"
5110        );
5111
5112        let out = apply_lint_fixes_with_lint_config(sql, Dialect::Bigquery, &lint_config)
5113            .expect("fix result");
5114        assert!(
5115            out.sql.contains("'abc'"),
5116            "expected double-quoted string to be converted to single-quoted: {}",
5117            out.sql
5118        );
5119    }
5120
5121    #[test]
5122    fn cv011_cast_preference_rewrites_double_colon_style() {
5123        let lint_config = LintConfig {
5124            enabled: true,
5125            disabled_rules: vec![],
5126            rule_configs: std::collections::BTreeMap::from([(
5127                "convention.casting_style".to_string(),
5128                serde_json::json!({"preferred_type_casting_style": "cast"}),
5129            )]),
5130        };
5131        let sql = "SELECT amount::INT FROM t";
5132        assert_rule_case_with_config(sql, issue_codes::LINT_CV_011, 1, 0, 1, &lint_config);
5133
5134        let out = apply_fix_with_config(sql, &lint_config);
5135        assert!(
5136            out.sql.to_ascii_uppercase().contains("CAST(AMOUNT AS INT)"),
5137            "expected CAST(...) rewrite for CV_011 fix: {}",
5138            out.sql
5139        );
5140    }
5141
5142    #[test]
5143    fn cv011_shorthand_preference_rewrites_cast_style_when_safe() {
5144        let lint_config = LintConfig {
5145            enabled: true,
5146            disabled_rules: vec![],
5147            rule_configs: std::collections::BTreeMap::from([(
5148                "LINT_CV_011".to_string(),
5149                serde_json::json!({"preferred_type_casting_style": "shorthand"}),
5150            )]),
5151        };
5152        let sql = "SELECT CAST(amount AS INT) FROM t";
5153        assert_rule_case_with_config(sql, issue_codes::LINT_CV_011, 1, 0, 1, &lint_config);
5154
5155        let out = apply_fix_with_config(sql, &lint_config);
5156        assert!(
5157            out.sql.to_ascii_uppercase().contains("AMOUNT::INT"),
5158            "expected :: rewrite for CV_011 fix: {}",
5159            out.sql
5160        );
5161    }
5162
5163    #[test]
5164    fn sqlfluff_st012_cases_are_fixed_or_unchanged() {
5165        let cases = [
5166            ("SELECT 1;;", 1, 0, 1),
5167            ("SELECT 1;\n \t ;", 1, 0, 1),
5168            ("SELECT 1;", 0, 0, 0),
5169        ];
5170
5171        for (sql, before, after, fix_count) in cases {
5172            assert_rule_case(sql, issue_codes::LINT_ST_012, before, after, fix_count);
5173        }
5174    }
5175
5176    #[test]
5177    fn sqlfluff_st002_cases_are_fixed_or_unchanged() {
5178        let cases = [
5179            ("SELECT CASE WHEN x > 1 THEN 'a' ELSE NULL END FROM t", 1, 0, 1),
5180            (
5181                "SELECT CASE name WHEN 'cat' THEN 'meow' WHEN 'dog' THEN 'woof' ELSE NULL END FROM t",
5182                1,
5183                0,
5184                1,
5185            ),
5186            (
5187                "SELECT CASE WHEN x = 1 THEN 'a' WHEN x = 2 THEN 'b' WHEN x = 3 THEN 'c' ELSE NULL END FROM t",
5188                1,
5189                0,
5190                1,
5191            ),
5192            (
5193                "SELECT CASE WHEN x > 0 THEN CASE WHEN y > 0 THEN 'pos' ELSE NULL END ELSE NULL END FROM t",
5194                2,
5195                0,
5196                2,
5197            ),
5198            (
5199                "SELECT * FROM t WHERE (CASE WHEN x > 0 THEN 1 ELSE NULL END) IS NOT NULL",
5200                1,
5201                0,
5202                1,
5203            ),
5204            (
5205                "WITH cte AS (SELECT CASE WHEN x > 0 THEN 'yes' ELSE NULL END AS flag FROM t) SELECT * FROM cte",
5206                1,
5207                0,
5208                1,
5209            ),
5210            ("SELECT CASE WHEN x > 1 THEN 'a' END FROM t", 0, 0, 0),
5211            (
5212                "SELECT CASE name WHEN 'cat' THEN 'meow' ELSE UPPER(name) END FROM t",
5213                0,
5214                0,
5215                0,
5216            ),
5217            ("SELECT CASE WHEN x > 1 THEN 'a' ELSE 'b' END FROM t", 0, 0, 0),
5218        ];
5219
5220        for (sql, before, after, fix_count) in cases {
5221            assert_rule_case(sql, issue_codes::LINT_ST_001, before, after, fix_count);
5222        }
5223    }
5224
5225    #[test]
5226    fn count_style_cases_are_fixed_or_unchanged() {
5227        let cases = [
5228            ("SELECT COUNT(1) FROM t", 1, 0, 1),
5229            (
5230                "SELECT col FROM t GROUP BY col HAVING COUNT(1) > 5",
5231                1,
5232                0,
5233                1,
5234            ),
5235            (
5236                "SELECT * FROM t WHERE id IN (SELECT COUNT(1) FROM t2 GROUP BY col)",
5237                1,
5238                0,
5239                1,
5240            ),
5241            ("SELECT COUNT(1), COUNT(1) FROM t", 2, 0, 2),
5242            (
5243                "WITH cte AS (SELECT COUNT(1) AS cnt FROM t) SELECT * FROM cte",
5244                1,
5245                0,
5246                1,
5247            ),
5248            ("SELECT COUNT(*) FROM t", 0, 0, 0),
5249            ("SELECT COUNT(id) FROM t", 0, 0, 0),
5250            ("SELECT COUNT(0) FROM t", 1, 0, 1),
5251            ("SELECT COUNT(01) FROM t", 1, 0, 1),
5252            ("SELECT COUNT(DISTINCT id) FROM t", 0, 0, 0),
5253        ];
5254
5255        for (sql, before, after, fix_count) in cases {
5256            assert_rule_case(sql, issue_codes::LINT_CV_004, before, after, fix_count);
5257        }
5258    }
5259
5260    #[test]
5261    fn safe_mode_blocks_template_tag_edits_but_applies_non_template_fixes() {
5262        let sql = "SELECT '{{foo}}' AS templated, COUNT(1) FROM t";
5263        let out = apply_lint_fixes_with_options(
5264            sql,
5265            Dialect::Generic,
5266            &default_lint_config(),
5267            FixOptions {
5268                include_unsafe_fixes: false,
5269                include_rewrite_candidates: true,
5270            },
5271        )
5272        .expect("fix result");
5273
5274        assert!(
5275            out.sql.contains("{{foo}}"),
5276            "template tag bytes should be preserved in safe mode: {}",
5277            out.sql
5278        );
5279        assert!(
5280            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5281            "non-template safe fixes should still apply: {}",
5282            out.sql
5283        );
5284        assert!(
5285            out.skipped_counts.protected_range_blocked > 0,
5286            "template-tag edits should be blocked in safe mode"
5287        );
5288    }
5289
5290    #[test]
5291    fn unsafe_mode_allows_template_tag_edits() {
5292        let sql = "SELECT '{{foo}}' AS templated, COUNT(1) FROM t";
5293        let out = apply_lint_fixes_with_options(
5294            sql,
5295            Dialect::Generic,
5296            &default_lint_config(),
5297            FixOptions {
5298                include_unsafe_fixes: true,
5299                include_rewrite_candidates: false,
5300            },
5301        )
5302        .expect("fix result");
5303
5304        assert!(
5305            out.sql.contains("{{ foo }}"),
5306            "unsafe mode should allow template-tag formatting fixes: {}",
5307            out.sql
5308        );
5309        assert!(
5310            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5311            "other fixes should still apply: {}",
5312            out.sql
5313        );
5314    }
5315
5316    #[test]
5317    fn comments_are_not_globally_skipped() {
5318        let sql = "-- keep this comment\nSELECT COUNT(1) FROM t";
5319        let out = apply_lint_fixes_with_options(
5320            sql,
5321            Dialect::Generic,
5322            &default_lint_config(),
5323            FixOptions {
5324                include_unsafe_fixes: false,
5325                include_rewrite_candidates: false,
5326            },
5327        )
5328        .expect("fix result");
5329        assert!(
5330            !out.skipped_due_to_comments,
5331            "comment presence should not skip all fixes"
5332        );
5333        assert!(
5334            out.sql.contains("-- keep this comment"),
5335            "comment text must be preserved: {}",
5336            out.sql
5337        );
5338        assert!(
5339            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5340            "non-comment region should still be fixable: {}",
5341            out.sql
5342        );
5343    }
5344
5345    #[test]
5346    fn mysql_hash_comments_are_not_globally_skipped() {
5347        let sql = "# keep this comment\nSELECT COUNT(1) FROM t";
5348        let out = apply_lint_fixes_with_options(
5349            sql,
5350            Dialect::Mysql,
5351            &default_lint_config(),
5352            FixOptions {
5353                include_unsafe_fixes: false,
5354                include_rewrite_candidates: false,
5355            },
5356        )
5357        .expect("fix result");
5358        assert!(
5359            !out.skipped_due_to_comments,
5360            "comment presence should not skip all fixes"
5361        );
5362        assert!(
5363            out.sql.contains("# keep this comment"),
5364            "comment text must be preserved: {}",
5365            out.sql
5366        );
5367        assert!(
5368            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5369            "non-comment region should still be fixable: {}",
5370            out.sql
5371        );
5372    }
5373
5374    #[test]
5375    fn does_not_treat_double_quoted_comment_markers_as_comments() {
5376        let sql = "SELECT \"a--b\", \"x/*y\" FROM t";
5377        assert!(!contains_comment_markers(sql, Dialect::Generic));
5378    }
5379
5380    #[test]
5381    fn does_not_treat_backtick_or_bracketed_markers_as_comments() {
5382        let sql = "SELECT `a--b`, [x/*y] FROM t";
5383        assert!(!contains_comment_markers(sql, Dialect::Mysql));
5384    }
5385
5386    #[test]
5387    fn fix_mode_does_not_skip_double_quoted_markers() {
5388        let sql = "SELECT \"a--b\", COUNT(1) FROM t";
5389        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5390        assert!(!out.skipped_due_to_comments);
5391    }
5392
5393    #[test]
5394    fn fix_mode_does_not_skip_backtick_markers() {
5395        let sql = "SELECT `a--b`, COUNT(1) FROM t";
5396        let out = apply_lint_fixes(sql, Dialect::Mysql, &[]).expect("fix result");
5397        assert!(!out.skipped_due_to_comments);
5398    }
5399
5400    #[test]
5401    fn planner_blocks_protected_ranges_and_applies_non_overlapping_edits() {
5402        let sql = "SELECT '{{foo}}' AS templated, 1";
5403        let protected = collect_comment_protected_ranges(sql, Dialect::Generic, true);
5404        let template_idx = sql.find("{{foo}}").expect("template exists");
5405        let one_idx = sql.rfind('1').expect("digit exists");
5406
5407        let planned = plan_fix_candidates(
5408            sql,
5409            vec![
5410                FixCandidate {
5411                    start: template_idx,
5412                    end: template_idx + "{{foo}}".len(),
5413                    replacement: String::new(),
5414                    applicability: FixCandidateApplicability::Safe,
5415                    source: FixCandidateSource::PrimaryRewrite,
5416                    rule_code: None,
5417                },
5418                FixCandidate {
5419                    start: one_idx,
5420                    end: one_idx + 1,
5421                    replacement: "2".to_string(),
5422                    applicability: FixCandidateApplicability::Safe,
5423                    source: FixCandidateSource::PrimaryRewrite,
5424                    rule_code: None,
5425                },
5426            ],
5427            &protected,
5428            false,
5429        );
5430
5431        let applied = apply_planned_edits(sql, &planned.edits);
5432        assert!(
5433            applied.contains("{{foo}}"),
5434            "template span should remain protected: {applied}"
5435        );
5436        assert!(
5437            applied.ends_with("2"),
5438            "expected non-overlapping edit: {applied}"
5439        );
5440        assert_eq!(planned.skipped.protected_range_blocked, 1);
5441    }
5442
5443    #[test]
5444    fn planner_is_deterministic_for_conflicting_candidates() {
5445        let sql = "SELECT 1";
5446        let one_idx = sql.rfind('1').expect("digit exists");
5447
5448        let left_first = plan_fix_candidates(
5449            sql,
5450            vec![
5451                FixCandidate {
5452                    start: one_idx,
5453                    end: one_idx + 1,
5454                    replacement: "9".to_string(),
5455                    applicability: FixCandidateApplicability::Safe,
5456                    source: FixCandidateSource::PrimaryRewrite,
5457                    rule_code: None,
5458                },
5459                FixCandidate {
5460                    start: one_idx,
5461                    end: one_idx + 1,
5462                    replacement: "2".to_string(),
5463                    applicability: FixCandidateApplicability::Safe,
5464                    source: FixCandidateSource::PrimaryRewrite,
5465                    rule_code: None,
5466                },
5467            ],
5468            &[],
5469            false,
5470        );
5471        let right_first = plan_fix_candidates(
5472            sql,
5473            vec![
5474                FixCandidate {
5475                    start: one_idx,
5476                    end: one_idx + 1,
5477                    replacement: "2".to_string(),
5478                    applicability: FixCandidateApplicability::Safe,
5479                    source: FixCandidateSource::PrimaryRewrite,
5480                    rule_code: None,
5481                },
5482                FixCandidate {
5483                    start: one_idx,
5484                    end: one_idx + 1,
5485                    replacement: "9".to_string(),
5486                    applicability: FixCandidateApplicability::Safe,
5487                    source: FixCandidateSource::PrimaryRewrite,
5488                    rule_code: None,
5489                },
5490            ],
5491            &[],
5492            false,
5493        );
5494
5495        let left_sql = apply_planned_edits(sql, &left_first.edits);
5496        let right_sql = apply_planned_edits(sql, &right_first.edits);
5497        assert_eq!(left_sql, "SELECT 2");
5498        assert_eq!(left_sql, right_sql);
5499        assert_eq!(left_first.skipped.overlap_conflict_blocked, 1);
5500        assert_eq!(right_first.skipped.overlap_conflict_blocked, 1);
5501    }
5502
5503    #[test]
5504    fn core_autofix_candidates_are_collected_and_applied() {
5505        let sql = "SELECT 1";
5506        let one_idx = sql.rfind('1').expect("digit exists");
5507        let issues = vec![serde_json::json!({
5508            "code": issue_codes::LINT_CV_004,
5509            "span": { "start": one_idx, "end": one_idx + 1 },
5510            "autofix": {
5511                "applicability": "safe",
5512                "edits": [
5513                    {
5514                        "start": one_idx,
5515                        "end": one_idx + 1,
5516                        "replacement": "2"
5517                    }
5518                ]
5519            }
5520        })];
5521        let candidates = build_fix_candidates_from_issue_values(sql, &issues);
5522
5523        assert_eq!(candidates.len(), 1);
5524        let planned = plan_fix_candidates(sql, candidates, &[], false);
5525        let applied = apply_planned_edits(sql, &planned.edits);
5526        assert_eq!(applied, "SELECT 2");
5527    }
5528
5529    #[test]
5530    fn st002_core_autofix_candidates_apply_cleanly_in_safe_mode() {
5531        let sql = "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t\n";
5532        let issues = lint_issues(sql, Dialect::Generic, &default_lint_config());
5533        let candidates = build_fix_candidates_from_issue_autofixes(sql, &issues);
5534        assert!(
5535            candidates
5536                .iter()
5537                .any(|candidate| candidate.rule_code.as_deref() == Some(issue_codes::LINT_ST_002)),
5538            "expected ST002 core candidate from lint issues: {candidates:?}"
5539        );
5540
5541        let protected = collect_comment_protected_ranges(sql, Dialect::Generic, true);
5542        let planned = plan_fix_candidates(sql, candidates, &protected, false);
5543        let applied = apply_planned_edits(sql, &planned.edits);
5544        assert_eq!(
5545            applied, "SELECT coalesce(x > 0, false) FROM t\n",
5546            "unexpected ST002 planned edits with skipped={:?}",
5547            planned.skipped
5548        );
5549    }
5550
5551    #[test]
5552    fn incremental_core_plan_applies_st009_even_when_not_top_priority() {
5553        let sql = "select foo.a, bar.b from foo left join bar on bar.a = foo.a";
5554        let lint_config = default_lint_config();
5555        let before_counts = lint_rule_counts(sql, Dialect::Generic, &lint_config);
5556        assert_eq!(
5557            before_counts
5558                .get(issue_codes::LINT_ST_009)
5559                .copied()
5560                .unwrap_or(0),
5561            1
5562        );
5563
5564        let out = try_incremental_core_fix_plan(
5565            sql,
5566            Dialect::Generic,
5567            &lint_config,
5568            &before_counts,
5569            None,
5570            false,
5571            24,
5572            usize::MAX,
5573        )
5574        .expect("expected incremental ST009 fix");
5575        assert!(
5576            out.sql.contains("foo.a = bar.a"),
5577            "expected ST009 join condition reorder, got: {}",
5578            out.sql
5579        );
5580
5581        let after_counts = lint_rule_counts(&out.sql, Dialect::Generic, &lint_config);
5582        assert_eq!(
5583            after_counts
5584                .get(issue_codes::LINT_ST_009)
5585                .copied()
5586                .unwrap_or(0),
5587            0
5588        );
5589    }
5590
5591    #[test]
5592    fn cached_pre_lint_state_matches_uncached_next_pass_behavior() {
5593        let sql = "SELECT 1 UNION SELECT 2";
5594        let lint_config = default_lint_config();
5595        let fix_options = FixOptions {
5596            include_unsafe_fixes: false,
5597            include_rewrite_candidates: false,
5598        };
5599
5600        let first_pass = apply_lint_fixes_with_options_and_lint_state(
5601            sql,
5602            Dialect::Generic,
5603            &lint_config,
5604            fix_options,
5605            None,
5606        )
5607        .expect("first fix pass");
5608
5609        let second_cached = apply_lint_fixes_with_options_and_lint_state(
5610            &first_pass.outcome.sql,
5611            Dialect::Generic,
5612            &lint_config,
5613            fix_options,
5614            Some(first_pass.post_lint_state.clone()),
5615        )
5616        .expect("second cached pass");
5617        let second_uncached = apply_lint_fixes_with_options_and_lint_state(
5618            &first_pass.outcome.sql,
5619            Dialect::Generic,
5620            &lint_config,
5621            fix_options,
5622            None,
5623        )
5624        .expect("second uncached pass");
5625
5626        assert_eq!(second_cached.outcome.sql, second_uncached.outcome.sql);
5627        assert_eq!(second_cached.outcome.counts, second_uncached.outcome.counts);
5628        assert_eq!(
5629            second_cached.outcome.changed,
5630            second_uncached.outcome.changed
5631        );
5632        assert_eq!(
5633            second_cached.outcome.skipped_due_to_regression,
5634            second_uncached.outcome.skipped_due_to_regression
5635        );
5636    }
5637
5638    #[test]
5639    fn cp03_templated_case_emits_core_autofix_candidate() {
5640        let sql = "SELECT\n    {{ \"greatest(a, b)\" }},\n    GREATEST(i, j)\n";
5641        let config = lint_config_keep_only_rule(
5642            issue_codes::LINT_CP_003,
5643            LintConfig {
5644                enabled: true,
5645                disabled_rules: vec![],
5646                rule_configs: std::collections::BTreeMap::from([(
5647                    "core".to_string(),
5648                    serde_json::json!({"ignore_templated_areas": false}),
5649                )]),
5650            },
5651        );
5652        let issues = lint_issues(sql, Dialect::Ansi, &config);
5653        assert!(
5654            issues
5655                .iter()
5656                .any(|issue| { issue.code == issue_codes::LINT_CP_003 && issue.autofix.is_some() }),
5657            "expected CP03 issue with autofix metadata, got issues={issues:?}"
5658        );
5659
5660        let candidates = build_fix_candidates_from_issue_autofixes(sql, &issues);
5661        assert!(
5662            candidates.iter().any(|candidate| {
5663                candidate.rule_code.as_deref() == Some(issue_codes::LINT_CP_003)
5664                    && &sql[candidate.start..candidate.end] == "GREATEST"
5665                    && candidate.replacement == "greatest"
5666            }),
5667            "expected CP03 GREATEST candidate, got candidates={candidates:?}"
5668        );
5669    }
5670
5671    #[test]
5672    fn planner_prefers_core_autofix_over_rewrite_conflicts() {
5673        let sql = "SELECT 1";
5674        let one_idx = sql.rfind('1').expect("digit exists");
5675        let core_issue = serde_json::json!({
5676            "code": issue_codes::LINT_CV_004,
5677            "autofix": {
5678                "start": one_idx,
5679                "end": one_idx + 1,
5680                "replacement": "9",
5681                "applicability": "safe"
5682            }
5683        });
5684        let core_candidate = build_fix_candidates_from_issue_values(sql, &[core_issue])[0].clone();
5685        let rewrite_candidate = FixCandidate {
5686            start: one_idx,
5687            end: one_idx + 1,
5688            replacement: "2".to_string(),
5689            applicability: FixCandidateApplicability::Safe,
5690            source: FixCandidateSource::PrimaryRewrite,
5691            rule_code: None,
5692        };
5693
5694        let left_first = plan_fix_candidates(
5695            sql,
5696            vec![rewrite_candidate.clone(), core_candidate.clone()],
5697            &[],
5698            false,
5699        );
5700        let right_first =
5701            plan_fix_candidates(sql, vec![core_candidate, rewrite_candidate], &[], false);
5702
5703        let left_sql = apply_planned_edits(sql, &left_first.edits);
5704        let right_sql = apply_planned_edits(sql, &right_first.edits);
5705        assert_eq!(left_sql, "SELECT 9");
5706        assert_eq!(left_sql, right_sql);
5707        assert_eq!(left_first.skipped.overlap_conflict_blocked, 1);
5708        assert_eq!(right_first.skipped.overlap_conflict_blocked, 1);
5709    }
5710
5711    #[test]
5712    fn rewrite_mode_falls_back_to_core_plan_when_core_rule_is_not_improved() {
5713        // Consistent mode normalizes to whichever style appears first.
5714        // `<>` is first, so the fix normalizes `!=` to `<>`.
5715        let sql = "SELECT * FROM t WHERE a <> b AND c != d";
5716        let out = apply_lint_fixes_with_options(
5717            sql,
5718            Dialect::Generic,
5719            &default_lint_config(),
5720            FixOptions {
5721                include_unsafe_fixes: true,
5722                include_rewrite_candidates: true,
5723            },
5724        )
5725        .expect("fix result");
5726
5727        assert_eq!(fix_count_for_code(&out.counts, issue_codes::LINT_CV_001), 1);
5728        assert!(
5729            out.sql.contains("a <> b"),
5730            "expected CV001 style fix: {}",
5731            out.sql
5732        );
5733        assert!(
5734            out.sql.contains("c <> d"),
5735            "expected CV001 style fix: {}",
5736            out.sql
5737        );
5738        assert!(
5739            !out.sql.contains("!="),
5740            "expected no bang-style operator: {}",
5741            out.sql
5742        );
5743    }
5744
5745    #[test]
5746    fn core_autofix_applicability_is_mapped_to_existing_planner_logic() {
5747        let sql = "SELECT 1";
5748        let one_idx = sql.rfind('1').expect("digit exists");
5749        let issues = vec![
5750            serde_json::json!({
5751                "code": issue_codes::LINT_ST_005,
5752                "autofix": {
5753                    "start": one_idx,
5754                    "end": one_idx + 1,
5755                    "replacement": "2",
5756                    "applicability": "unsafe"
5757                }
5758            }),
5759            serde_json::json!({
5760                "code": issue_codes::LINT_ST_005,
5761                "autofix": {
5762                    "start": one_idx,
5763                    "end": one_idx + 1,
5764                    "replacement": "3",
5765                    "applicability": "display_only"
5766                }
5767            }),
5768        ];
5769        let candidates = build_fix_candidates_from_issue_values(sql, &issues);
5770
5771        assert_eq!(
5772            candidates[0].applicability,
5773            FixCandidateApplicability::Unsafe
5774        );
5775        assert_eq!(
5776            candidates[1].applicability,
5777            FixCandidateApplicability::DisplayOnly
5778        );
5779
5780        let planned_safe = plan_fix_candidates(sql, candidates.clone(), &[], false);
5781        assert_eq!(apply_planned_edits(sql, &planned_safe.edits), sql);
5782        assert_eq!(planned_safe.skipped.unsafe_skipped, 1);
5783        assert_eq!(planned_safe.skipped.display_only, 1);
5784
5785        let planned_unsafe = plan_fix_candidates(sql, candidates, &[], true);
5786        assert_eq!(apply_planned_edits(sql, &planned_unsafe.edits), "SELECT 2");
5787        assert_eq!(planned_unsafe.skipped.display_only, 1);
5788    }
5789
5790    #[test]
5791    fn planner_tracks_unsafe_and_display_only_skips() {
5792        let sql = "SELECT 1";
5793        let one_idx = sql.rfind('1').expect("digit exists");
5794        let planned = plan_fix_candidates(
5795            sql,
5796            vec![
5797                FixCandidate {
5798                    start: one_idx,
5799                    end: one_idx + 1,
5800                    replacement: "2".to_string(),
5801                    applicability: FixCandidateApplicability::Unsafe,
5802                    source: FixCandidateSource::UnsafeFallback,
5803                    rule_code: None,
5804                },
5805                FixCandidate {
5806                    start: 0,
5807                    end: 0,
5808                    replacement: String::new(),
5809                    applicability: FixCandidateApplicability::DisplayOnly,
5810                    source: FixCandidateSource::DisplayHint,
5811                    rule_code: None,
5812                },
5813            ],
5814            &[],
5815            false,
5816        );
5817        let applied = apply_planned_edits(sql, &planned.edits);
5818        assert_eq!(applied, sql);
5819        assert_eq!(planned.skipped.unsafe_skipped, 1);
5820        assert_eq!(planned.skipped.display_only, 1);
5821    }
5822
5823    #[test]
5824    fn does_not_collapse_independent_select_statements() {
5825        let sql = "SELECT 1; SELECT 2;";
5826        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5827        assert!(
5828            !out.sql.to_ascii_uppercase().contains("DISTINCT SELECT"),
5829            "auto-fix must preserve statement boundaries: {}",
5830            out.sql
5831        );
5832        let parsed = parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("parse fixed sql");
5833        assert_eq!(
5834            parsed.len(),
5835            2,
5836            "auto-fix should preserve two independent statements"
5837        );
5838    }
5839
5840    #[test]
5841    fn subquery_to_cte_text_fix_applies() {
5842        let fixed = fix_subquery_to_cte("SELECT * FROM (SELECT 1) sub");
5843        assert_eq!(fixed, "WITH sub AS (SELECT 1) SELECT * FROM sub");
5844    }
5845
5846    #[test]
5847    fn st005_core_autofix_applies_in_unsafe_mode_with_from_config() {
5848        let sql = "SELECT * FROM (SELECT 1) sub";
5849        let lint_config = LintConfig {
5850            enabled: true,
5851            disabled_rules: vec![],
5852            rule_configs: std::collections::BTreeMap::from([(
5853                "structure.subquery".to_string(),
5854                serde_json::json!({"forbid_subquery_in": "from"}),
5855            )]),
5856        };
5857
5858        let fixed = apply_lint_fixes_with_options(
5859            sql,
5860            Dialect::Generic,
5861            &lint_config,
5862            FixOptions {
5863                include_unsafe_fixes: true,
5864                include_rewrite_candidates: false,
5865            },
5866        )
5867        .expect("fix result")
5868        .sql;
5869        assert!(
5870            fixed.to_ascii_uppercase().contains("WITH SUB AS"),
5871            "expected unsafe core ST005 autofix to rewrite to CTE, got: {fixed}"
5872        );
5873    }
5874
5875    #[test]
5876    fn subquery_to_cte_text_fix_handles_nested_parentheses() {
5877        let fixed = fix_subquery_to_cte("SELECT * FROM (SELECT COUNT(*) FROM t) sub");
5878        assert_eq!(
5879            fixed,
5880            "WITH sub AS (SELECT COUNT(*) FROM t) SELECT * FROM sub"
5881        );
5882        parse_sql_with_dialect(&fixed, Dialect::Generic).expect("fixed SQL should parse");
5883    }
5884
5885    #[test]
5886    fn st005_ast_fix_rewrites_simple_join_derived_subquery_to_cte() {
5887        let lint_config = LintConfig {
5888            enabled: true,
5889            disabled_rules: vec![issue_codes::LINT_AM_005.to_string()],
5890            rule_configs: std::collections::BTreeMap::new(),
5891        };
5892        let sql = "SELECT t.id FROM t JOIN (SELECT id FROM u) sub ON t.id = sub.id";
5893        assert_rule_case_with_config(sql, issue_codes::LINT_ST_005, 1, 0, 1, &lint_config);
5894
5895        let out = apply_fix_with_config(sql, &lint_config);
5896        assert!(
5897            out.sql.to_ascii_uppercase().contains("WITH SUB AS"),
5898            "expected AST ST_005 rewrite to emit CTE: {}",
5899            out.sql
5900        );
5901    }
5902
5903    #[test]
5904    fn st005_ast_fix_rewrites_simple_from_derived_subquery_with_config() {
5905        let lint_config = LintConfig {
5906            enabled: true,
5907            disabled_rules: vec![],
5908            rule_configs: std::collections::BTreeMap::from([(
5909                "structure.subquery".to_string(),
5910                serde_json::json!({"forbid_subquery_in": "from"}),
5911            )]),
5912        };
5913        let sql = "SELECT sub.id FROM (SELECT id FROM u) sub";
5914        assert_rule_case_with_config(sql, issue_codes::LINT_ST_005, 1, 0, 1, &lint_config);
5915
5916        let out = apply_fix_with_config(sql, &lint_config);
5917        assert!(
5918            out.sql.to_ascii_uppercase().contains("WITH SUB AS"),
5919            "expected FROM-derived ST_005 rewrite to emit CTE: {}",
5920            out.sql
5921        );
5922    }
5923
5924    #[test]
5925    fn consecutive_semicolon_fix_ignores_string_literal_content() {
5926        let sql = "SELECT 'a;;b' AS txt;;";
5927        let out = apply_lint_fixes_with_options(
5928            sql,
5929            Dialect::Generic,
5930            &default_lint_config(),
5931            FixOptions {
5932                include_unsafe_fixes: true,
5933                include_rewrite_candidates: false,
5934            },
5935        )
5936        .expect("fix result");
5937        assert!(
5938            out.sql.contains("'a;;b'"),
5939            "string literal content must be preserved: {}",
5940            out.sql
5941        );
5942        assert!(
5943            out.sql.trim_end().ends_with(';') && !out.sql.trim_end().ends_with(";;"),
5944            "trailing semicolon run should be collapsed to one terminator: {}",
5945            out.sql
5946        );
5947    }
5948
5949    #[test]
5950    fn consecutive_semicolon_fix_collapses_whitespace_separated_runs() {
5951        let out = apply_lint_fixes_with_options(
5952            "SELECT 1;\n \t ;",
5953            Dialect::Generic,
5954            &default_lint_config(),
5955            FixOptions {
5956                include_unsafe_fixes: true,
5957                include_rewrite_candidates: false,
5958            },
5959        )
5960        .expect("fix result");
5961        assert_eq!(out.sql.matches(';').count(), 1);
5962    }
5963
5964    #[test]
5965    fn lint_fix_subquery_with_function_call_is_parseable() {
5966        let sql = "SELECT * FROM (SELECT COUNT(*) FROM t) sub";
5967        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5968        assert!(
5969            !out.skipped_due_to_regression,
5970            "function-call subquery rewrite should not be treated as regression: {}",
5971            out.sql
5972        );
5973        parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("fixed SQL should parse");
5974    }
5975
5976    #[test]
5977    fn distinct_parentheses_fix_preserves_valid_sql() {
5978        let sql = "SELECT DISTINCT(a) FROM t";
5979        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5980        assert!(
5981            !out.sql.contains("a)"),
5982            "unexpected dangling parenthesis after fix: {}",
5983            out.sql
5984        );
5985        parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("fixed SQL should parse");
5986    }
5987
5988    #[test]
5989    fn not_equal_fix_does_not_rewrite_string_literals() {
5990        let sql = "SELECT '<>' AS x, a<>b, c!=d FROM t";
5991        let out = apply_lint_fixes_with_options(
5992            sql,
5993            Dialect::Generic,
5994            &default_lint_config(),
5995            FixOptions {
5996                include_unsafe_fixes: false,
5997                include_rewrite_candidates: false,
5998            },
5999        )
6000        .expect("fix result");
6001        assert!(
6002            out.sql.contains("'<>'"),
6003            "string literal should remain unchanged: {}",
6004            out.sql
6005        );
6006        let compact: String = out.sql.chars().filter(|ch| !ch.is_whitespace()).collect();
6007        let has_c_style = compact.contains("a!=b") && compact.contains("c!=d");
6008        let has_ansi_style = compact.contains("a<>b") && compact.contains("c<>d");
6009        assert!(
6010            has_c_style || has_ansi_style || compact.contains("a<>b") && compact.contains("c!=d"),
6011            "operator usage outside string literals should remain intact: {}",
6012            out.sql
6013        );
6014    }
6015
6016    #[test]
6017    fn spacing_fixes_do_not_rewrite_single_quoted_literals() {
6018        let operator_fixed = apply_lint_fixes_with_options(
6019            "SELECT payload->>'id', 'x=y' FROM t",
6020            Dialect::Generic,
6021            &default_lint_config(),
6022            FixOptions {
6023                include_unsafe_fixes: false,
6024                include_rewrite_candidates: false,
6025            },
6026        )
6027        .expect("operator spacing fix result")
6028        .sql;
6029        assert!(
6030            operator_fixed.contains("'x=y'"),
6031            "operator spacing must not mutate literals: {operator_fixed}"
6032        );
6033        assert!(
6034            operator_fixed.contains("payload ->>"),
6035            "operator spacing should still apply: {operator_fixed}"
6036        );
6037
6038        let comma_fixed = apply_lint_fixes_with_options(
6039            "SELECT a,b, 'x,y' FROM t",
6040            Dialect::Generic,
6041            &default_lint_config(),
6042            FixOptions {
6043                include_unsafe_fixes: false,
6044                include_rewrite_candidates: false,
6045            },
6046        )
6047        .expect("comma spacing fix result")
6048        .sql;
6049        assert!(
6050            comma_fixed.contains("'x,y'"),
6051            "comma spacing must not mutate literals: {comma_fixed}"
6052        );
6053        assert!(
6054            !comma_fixed.contains("a,b"),
6055            "comma spacing should still apply: {comma_fixed}"
6056        );
6057    }
6058
6059    #[test]
6060    fn keyword_newline_fix_does_not_rewrite_literals_or_quoted_identifiers() {
6061        let sql = "SELECT COUNT(1), 'hello FROM world', \"x WHERE y\" FROM t WHERE a = 1";
6062        let fixed = apply_lint_fixes(sql, Dialect::Generic, &[])
6063            .expect("fix result")
6064            .sql;
6065        assert!(
6066            fixed.contains("'hello FROM world'"),
6067            "single-quoted literal should remain unchanged: {fixed}"
6068        );
6069        assert!(
6070            fixed.contains("\"x WHERE y\""),
6071            "double-quoted identifier should remain unchanged: {fixed}"
6072        );
6073        assert!(
6074            !fixed.contains("hello\nFROM world"),
6075            "keyword newline fix must not inject newlines into literals: {fixed}"
6076        );
6077    }
6078
6079    #[test]
6080    fn cp04_fix_reduces_literal_capitalisation_violations() {
6081        // Per-identifier: true and False both violate upper → 2 violations, 2 fixes.
6082        assert_rule_case(
6083            "SELECT NULL, true, False FROM t",
6084            issue_codes::LINT_CP_004,
6085            2,
6086            0,
6087            2,
6088        );
6089    }
6090
6091    #[test]
6092    fn cp05_fix_reduces_type_capitalisation_violations() {
6093        // Per-identifier: VarChar violates upper (INT is already correct) → 1 violation.
6094        assert_rule_case(
6095            "CREATE TABLE t (a INT, b VarChar(10));",
6096            issue_codes::LINT_CP_005,
6097            1,
6098            0,
6099            1,
6100        );
6101    }
6102
6103    #[test]
6104    fn cv06_fix_adds_missing_final_terminator() {
6105        assert_rule_case("SELECT 1 ;", issue_codes::LINT_CV_006, 1, 0, 1);
6106    }
6107
6108    #[test]
6109    fn lt03_fix_moves_trailing_operator_to_leading_position() {
6110        assert_rule_case("SELECT a +\n b FROM t", issue_codes::LINT_LT_003, 1, 0, 1);
6111    }
6112
6113    #[test]
6114    fn lt04_fix_moves_comma_around_templated_columns_in_ansi() {
6115        let leading_sql = "SELECT\n    c1,\n    {{ \"c2\" }} AS days_since\nFROM logs";
6116        let leading_config = lint_config_keep_only_rule(
6117            issue_codes::LINT_LT_004,
6118            LintConfig {
6119                enabled: true,
6120                disabled_rules: vec![],
6121                rule_configs: std::collections::BTreeMap::from([(
6122                    "layout.commas".to_string(),
6123                    serde_json::json!({"line_position": "leading"}),
6124                )]),
6125            },
6126        );
6127        let leading_issues = lint_issues(leading_sql, Dialect::Ansi, &leading_config);
6128        let leading_lt04 = leading_issues
6129            .iter()
6130            .find(|issue| issue.code == issue_codes::LINT_LT_004)
6131            .expect("expected LT04 issue before fix");
6132        assert!(
6133            leading_lt04.autofix.is_some(),
6134            "expected LT04 issue to carry autofix metadata in fix pipeline"
6135        );
6136        let leading_out = apply_lint_fixes_with_options(
6137            leading_sql,
6138            Dialect::Ansi,
6139            &leading_config,
6140            FixOptions {
6141                include_unsafe_fixes: true,
6142                include_rewrite_candidates: false,
6143            },
6144        )
6145        .expect("fix result");
6146        assert!(
6147            !leading_out.skipped_due_to_regression,
6148            "LT04 leading templated fix should not be treated as regression"
6149        );
6150        assert_eq!(
6151            leading_out.sql,
6152            "SELECT\n    c1\n    , {{ \"c2\" }} AS days_since\nFROM logs"
6153        );
6154
6155        let trailing_sql = "SELECT\n    {{ \"c1\" }}\n    , c2 AS days_since\nFROM logs";
6156        let trailing_config =
6157            lint_config_keep_only_rule(issue_codes::LINT_LT_004, default_lint_config());
6158        let trailing_out = apply_lint_fixes_with_options(
6159            trailing_sql,
6160            Dialect::Ansi,
6161            &trailing_config,
6162            FixOptions {
6163                include_unsafe_fixes: true,
6164                include_rewrite_candidates: false,
6165            },
6166        )
6167        .expect("fix result");
6168        assert!(
6169            !trailing_out.skipped_due_to_regression,
6170            "LT04 trailing templated fix should not be treated as regression"
6171        );
6172        assert_eq!(
6173            trailing_out.sql,
6174            "SELECT\n    {{ \"c1\" }},\n    c2 AS days_since\nFROM logs"
6175        );
6176    }
6177    #[test]
6178    fn rf004_core_autofix_respects_rule_filter() {
6179        let sql = "select a from users as select\n";
6180
6181        let out_rf_disabled = apply_lint_fixes(
6182            sql,
6183            Dialect::Generic,
6184            &[issue_codes::LINT_RF_004.to_string()],
6185        )
6186        .expect("fix result");
6187        assert_eq!(
6188            out_rf_disabled.sql, sql,
6189            "excluding RF_004 should block alias-keyword core autofix"
6190        );
6191
6192        let out_al_disabled = apply_lint_fixes(
6193            sql,
6194            Dialect::Generic,
6195            &[issue_codes::LINT_AL_005.to_string()],
6196        )
6197        .expect("fix result");
6198        assert!(
6199            out_al_disabled.sql.contains("alias_select"),
6200            "excluding AL_005 must not block RF_004 core autofix: {}",
6201            out_al_disabled.sql
6202        );
6203    }
6204
6205    #[test]
6206    fn rf003_core_autofix_respects_rule_filter() {
6207        let sql = "select a.id, id2 from a\n";
6208
6209        let rf_disabled_config = LintConfig {
6210            enabled: true,
6211            disabled_rules: vec![issue_codes::LINT_RF_003.to_string()],
6212            rule_configs: std::collections::BTreeMap::new(),
6213        };
6214        let out_rf_disabled = apply_lint_fixes_with_options(
6215            sql,
6216            Dialect::Generic,
6217            &rf_disabled_config,
6218            FixOptions {
6219                include_unsafe_fixes: true,
6220                include_rewrite_candidates: false,
6221            },
6222        )
6223        .expect("fix result");
6224        assert!(
6225            !out_rf_disabled.sql.contains("a.id2"),
6226            "excluding RF_003 should block reference qualification core autofix: {}",
6227            out_rf_disabled.sql
6228        );
6229
6230        let al_disabled_config = LintConfig {
6231            enabled: true,
6232            disabled_rules: vec![issue_codes::LINT_AL_005.to_string()],
6233            rule_configs: std::collections::BTreeMap::new(),
6234        };
6235        let out_al_disabled = apply_lint_fixes_with_options(
6236            sql,
6237            Dialect::Generic,
6238            &al_disabled_config,
6239            FixOptions {
6240                include_unsafe_fixes: true,
6241                include_rewrite_candidates: false,
6242            },
6243        )
6244        .expect("fix result");
6245        assert!(
6246            out_al_disabled.sql.contains("a.id2"),
6247            "excluding AL_005 must not block RF_003 core autofix: {}",
6248            out_al_disabled.sql
6249        );
6250    }
6251
6252    #[test]
6253    fn al001_fix_still_improves_with_fix_mode() {
6254        let sql = "SELECT * FROM a x JOIN b y ON x.id = y.id";
6255        assert_rule_case(sql, issue_codes::LINT_AL_001, 2, 0, 2);
6256
6257        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6258        let upper = out.sql.to_ascii_uppercase();
6259        assert!(
6260            upper.contains("FROM A AS X"),
6261            "expected explicit alias in fixed SQL, got: {}",
6262            out.sql
6263        );
6264        assert!(
6265            upper.contains("JOIN B AS Y"),
6266            "expected explicit alias in fixed SQL, got: {}",
6267            out.sql
6268        );
6269    }
6270
6271    #[test]
6272    fn al001_fix_does_not_synthesize_missing_aliases() {
6273        let sql = "SELECT COUNT(1) FROM users";
6274        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6275
6276        assert!(
6277            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6278            "expected non-AL001 fix to apply: {}",
6279            out.sql
6280        );
6281        assert!(
6282            !out.sql.to_ascii_uppercase().contains(" AS T"),
6283            "AL001 fixer must not generate synthetic aliases: {}",
6284            out.sql
6285        );
6286    }
6287
6288    #[test]
6289    fn al001_disabled_preserves_implicit_aliases_when_other_rules_fix() {
6290        let sql = "select count(1) from a x join b y on x.id = y.id";
6291        let out = apply_lint_fixes(
6292            sql,
6293            Dialect::Generic,
6294            &[issue_codes::LINT_AL_001.to_string()],
6295        )
6296        .expect("fix result");
6297
6298        assert!(
6299            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6300            "expected non-AL001 fix to apply: {}",
6301            out.sql
6302        );
6303        assert!(
6304            out.sql.to_ascii_uppercase().contains("FROM A X"),
6305            "implicit alias should be preserved when AL001 is disabled: {}",
6306            out.sql
6307        );
6308        assert!(
6309            out.sql.to_ascii_uppercase().contains("JOIN B Y"),
6310            "implicit alias should be preserved when AL001 is disabled: {}",
6311            out.sql
6312        );
6313        assert!(
6314            lint_rule_count(&out.sql, issue_codes::LINT_AL_001) > 0,
6315            "AL001 violations should remain when the rule is disabled: {}",
6316            out.sql
6317        );
6318    }
6319
6320    #[test]
6321    fn al001_implicit_config_rewrites_explicit_aliases() {
6322        let lint_config = LintConfig {
6323            enabled: true,
6324            disabled_rules: vec![],
6325            rule_configs: std::collections::BTreeMap::from([(
6326                issue_codes::LINT_AL_001.to_string(),
6327                serde_json::json!({"aliasing": "implicit"}),
6328            )]),
6329        };
6330
6331        let sql = "SELECT COUNT(1) FROM a AS x JOIN b AS y ON x.id = y.id";
6332        assert_eq!(
6333            lint_rule_count_with_config(sql, issue_codes::LINT_AL_001, &lint_config),
6334            2,
6335            "explicit aliases should violate AL001 under implicit mode"
6336        );
6337
6338        let out = apply_fix_with_config(sql, &lint_config);
6339        assert!(
6340            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6341            "expected non-AL001 fix to apply: {}",
6342            out.sql
6343        );
6344        assert!(
6345            !out.sql.to_ascii_uppercase().contains(" AS X"),
6346            "implicit-mode AL001 should remove explicit aliases: {}",
6347            out.sql
6348        );
6349        assert!(
6350            !out.sql.to_ascii_uppercase().contains(" AS Y"),
6351            "implicit-mode AL001 should remove explicit aliases: {}",
6352            out.sql
6353        );
6354        assert_eq!(
6355            lint_rule_count_with_config(&out.sql, issue_codes::LINT_AL_001, &lint_config),
6356            0,
6357            "AL001 should be resolved under implicit mode: {}",
6358            out.sql
6359        );
6360    }
6361
6362    #[test]
6363    fn table_alias_occurrences_handles_with_insert_select_aliases() {
6364        let sql = r#"
6365WITH params AS (
6366    SELECT now() - interval '1 day' AS period_start, now() AS period_end
6367),
6368overall AS (
6369    SELECT route, nav_type, mark FROM metrics.page_performance
6370),
6371device_breakdown AS (
6372    SELECT route, nav_type, mark FROM (
6373        SELECT route, nav_type, mark FROM metrics.page_performance
6374    ) sub
6375),
6376network_breakdown AS (
6377    SELECT route, nav_type, mark FROM (
6378        SELECT route, nav_type, mark FROM metrics.page_performance
6379    ) sub
6380),
6381version_breakdown AS (
6382    SELECT route, nav_type, mark FROM (
6383        SELECT route, nav_type, mark FROM metrics.page_performance
6384    ) sub
6385)
6386INSERT INTO metrics.page_performance_summary (route, period_start, period_end, nav_type, mark)
6387SELECT o.route, p.period_start, p.period_end, o.nav_type, o.mark
6388FROM overall o
6389CROSS JOIN params p
6390LEFT JOIN device_breakdown d ON d.route = o.route
6391LEFT JOIN network_breakdown n ON n.route = o.route
6392LEFT JOIN version_breakdown v ON v.route = o.route
6393ON CONFLICT (route, period_start, nav_type, mark) DO UPDATE SET
6394    period_end = EXCLUDED.period_end;
6395"#;
6396
6397        let occurrences = table_alias_occurrences(sql, Dialect::Postgres)
6398            .expect("alias occurrences should parse");
6399        let implicit_count = occurrences
6400            .iter()
6401            .filter(|alias| !alias.explicit_as)
6402            .count();
6403        assert!(
6404            implicit_count >= 8,
6405            "expected implicit aliases in CTE+INSERT query, got {}: {:?}",
6406            implicit_count,
6407            occurrences
6408                .iter()
6409                .map(|alias| (&alias.alias_key, alias.explicit_as))
6410                .collect::<Vec<_>>()
6411        );
6412    }
6413
6414    #[test]
6415    fn excluded_rule_is_not_rewritten_when_other_rules_are_fixed() {
6416        let sql = "SELECT COUNT(1) FROM t WHERE a<>b";
6417        let disabled = vec![issue_codes::LINT_CV_001.to_string()];
6418        let out = apply_lint_fixes(sql, Dialect::Generic, &disabled).expect("fix result");
6419        assert!(
6420            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6421            "expected COUNT style fix: {}",
6422            out.sql
6423        );
6424        assert!(
6425            out.sql.contains("<>"),
6426            "excluded CV_005 should remain '<>' (not '!='): {}",
6427            out.sql
6428        );
6429        assert!(
6430            !out.sql.contains("!="),
6431            "excluded CV_005 should not be rewritten to '!=': {}",
6432            out.sql
6433        );
6434    }
6435
6436    #[test]
6437    fn references_quoting_fix_keeps_reserved_identifier_quotes() {
6438        let sql = "SELECT \"FROM\" FROM t UNION SELECT 2";
6439        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6440        assert!(
6441            out.sql.contains("\"FROM\""),
6442            "reserved identifier must remain quoted: {}",
6443            out.sql
6444        );
6445    }
6446
6447    #[test]
6448    fn references_quoting_fix_unquotes_case_insensitive_dialect() {
6449        // In a case-insensitive dialect (Generic), mixed-case quoted identifiers
6450        // are unnecessarily quoted because case doesn't matter.
6451        let sql = "SELECT \"CamelCase\" FROM t UNION SELECT 2";
6452        let out = apply_lint_fixes(
6453            sql,
6454            Dialect::Generic,
6455            &[issue_codes::LINT_LT_011.to_string()],
6456        )
6457        .expect("fix result");
6458        assert!(
6459            out.sql.contains("CamelCase") && !out.sql.contains("\"CamelCase\""),
6460            "case-insensitive dialect should unquote: {}",
6461            out.sql
6462        );
6463        assert!(
6464            out.sql.to_ascii_uppercase().contains("DISTINCT SELECT"),
6465            "expected another fix to persist output: {}",
6466            out.sql
6467        );
6468    }
6469
6470    #[test]
6471    fn references_quoting_fix_keeps_case_sensitive_identifier_quotes() {
6472        // In Postgres (lowercase casefold), mixed-case identifiers must stay
6473        // quoted because unquoting would fold to lowercase.
6474        let sql = "SELECT \"CamelCase\" FROM t UNION SELECT 2";
6475        let out = apply_lint_fixes(
6476            sql,
6477            Dialect::Postgres,
6478            &[issue_codes::LINT_LT_011.to_string()],
6479        )
6480        .expect("fix result");
6481        assert!(
6482            out.sql.contains("\"CamelCase\""),
6483            "case-sensitive identifier must remain quoted: {}",
6484            out.sql
6485        );
6486    }
6487
6488    #[test]
6489    fn sqlfluff_fix_rule_smoke_cases_reduce_target_violations() {
6490        let cases = vec![
6491            (
6492                issue_codes::LINT_AL_001,
6493                "SELECT * FROM a x JOIN b y ON x.id = y.id",
6494            ),
6495            (
6496                issue_codes::LINT_AL_005,
6497                "SELECT u.name FROM users u JOIN orders o ON users.id = orders.user_id",
6498            ),
6499            (issue_codes::LINT_AL_009, "SELECT a AS a FROM t"),
6500            (issue_codes::LINT_AM_002, "SELECT 1 UNION SELECT 2"),
6501            (
6502                issue_codes::LINT_AM_003,
6503                "SELECT * FROM t ORDER BY a, b DESC",
6504            ),
6505            (
6506                issue_codes::LINT_AM_005,
6507                "SELECT * FROM a JOIN b ON a.id = b.id",
6508            ),
6509            (
6510                issue_codes::LINT_AM_008,
6511                "SELECT foo.a, bar.b FROM foo INNER JOIN bar",
6512            ),
6513            (issue_codes::LINT_CP_001, "SELECT a from t"),
6514            (issue_codes::LINT_CP_002, "SELECT Col, col FROM t"),
6515            (issue_codes::LINT_CP_003, "SELECT COUNT(*), count(name) FROM t"),
6516            (issue_codes::LINT_CP_004, "SELECT NULL, true FROM t"),
6517            (
6518                issue_codes::LINT_CP_005,
6519                "CREATE TABLE t (a INT, b varchar(10))",
6520            ),
6521            (
6522                issue_codes::LINT_CV_001,
6523                "SELECT * FROM t WHERE a <> b AND c != d",
6524            ),
6525            (
6526                issue_codes::LINT_CV_002,
6527                "SELECT IFNULL(x, 'default') FROM t",
6528            ),
6529            (issue_codes::LINT_CV_003, "SELECT a, FROM t"),
6530            (issue_codes::LINT_CV_004, "SELECT COUNT(1) FROM t"),
6531            (issue_codes::LINT_CV_005, "SELECT * FROM t WHERE a = NULL"),
6532            (issue_codes::LINT_CV_006, "SELECT 1 ;"),
6533            (issue_codes::LINT_CV_007, "(SELECT 1)"),
6534            (
6535                issue_codes::LINT_CV_012,
6536                "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id",
6537            ),
6538            (issue_codes::LINT_JJ_001, "SELECT '{{foo}}' AS templated"),
6539            (issue_codes::LINT_LT_001, "SELECT payload->>'id' FROM t"),
6540            (issue_codes::LINT_LT_002, "SELECT a\n   , b\nFROM t"),
6541            (issue_codes::LINT_LT_003, "SELECT a +\n b FROM t"),
6542            (issue_codes::LINT_LT_004, "SELECT a,b FROM t"),
6543            (issue_codes::LINT_LT_006, "SELECT COUNT (1) FROM t"),
6544            (
6545                issue_codes::LINT_LT_007,
6546                "WITH cte AS (\n  SELECT 1) SELECT * FROM cte",
6547            ),
6548            (issue_codes::LINT_LT_009, "SELECT a,b,c,d,e FROM t"),
6549            (issue_codes::LINT_LT_010, "SELECT\nDISTINCT a\nFROM t"),
6550            (
6551                issue_codes::LINT_LT_011,
6552                "SELECT 1 UNION SELECT 2\nUNION SELECT 3",
6553            ),
6554            (issue_codes::LINT_LT_012, "SELECT 1\nFROM t"),
6555            (issue_codes::LINT_LT_013, "\n\nSELECT 1"),
6556            (issue_codes::LINT_LT_014, "SELECT a FROM t\nWHERE a=1"),
6557            (issue_codes::LINT_LT_015, "SELECT 1\n\n\nFROM t"),
6558            (issue_codes::LINT_RF_003, "SELECT a.id, id2 FROM a"),
6559            (issue_codes::LINT_RF_006, "SELECT \"good_name\" FROM t"),
6560            (
6561                issue_codes::LINT_ST_001,
6562                "SELECT CASE WHEN x > 1 THEN 'a' ELSE NULL END FROM t",
6563            ),
6564            (
6565                issue_codes::LINT_ST_004,
6566                "SELECT CASE WHEN species = 'Rat' THEN 'Squeak' ELSE CASE WHEN species = 'Dog' THEN 'Woof' END END FROM mytable",
6567            ),
6568            (
6569                issue_codes::LINT_ST_002,
6570                "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t",
6571            ),
6572            (
6573                issue_codes::LINT_ST_005,
6574                "SELECT * FROM t JOIN (SELECT * FROM u) sub ON t.id = sub.id",
6575            ),
6576            (issue_codes::LINT_ST_006, "SELECT a + 1, a FROM t"),
6577            (
6578                issue_codes::LINT_ST_007,
6579                "SELECT * FROM a JOIN b USING (id)",
6580            ),
6581            (issue_codes::LINT_ST_008, "SELECT DISTINCT(a) FROM t"),
6582            (
6583                issue_codes::LINT_ST_009,
6584                "SELECT * FROM a x JOIN b y ON y.id = x.id",
6585            ),
6586            (issue_codes::LINT_ST_012, "SELECT 1;;"),
6587        ];
6588
6589        for (code, sql) in cases {
6590            let before = lint_rule_count(sql, code);
6591            assert!(before > 0, "expected {code} to trigger before fix: {sql}");
6592            let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6593            assert!(
6594                !out.skipped_due_to_comments,
6595                "test SQL should not be skipped: {sql}"
6596            );
6597            let after = lint_rule_count(&out.sql, code);
6598            assert!(
6599                after < before || out.sql != sql,
6600                "expected {code} count to decrease or SQL to be rewritten. before={before} after={after}\ninput={sql}\noutput={}",
6601                out.sql
6602            );
6603        }
6604    }
6605
6606    // --- CV_012: implicit WHERE join → explicit ON ---
6607
6608    #[test]
6609    fn cv012_simple_where_join_to_on() {
6610        let sql = "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id";
6611        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6612        let lower = out.sql.to_ascii_lowercase();
6613        assert!(
6614            lower.contains(" on ") && !lower.contains("where"),
6615            "expected JOIN ON without WHERE: {}",
6616            out.sql
6617        );
6618    }
6619
6620    #[test]
6621    fn cv012_mixed_where_keeps_non_join_predicates() {
6622        let sql = "SELECT a.x FROM a JOIN b WHERE a.id = b.id AND a.val > 10";
6623        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6624        let lower = out.sql.to_ascii_lowercase();
6625        assert!(lower.contains(" on "), "expected JOIN ON: {}", out.sql);
6626        assert!(
6627            lower.contains("where"),
6628            "expected remaining WHERE: {}",
6629            out.sql
6630        );
6631    }
6632
6633    #[test]
6634    fn cv012_multi_join_chain() {
6635        let sql = "SELECT * FROM a JOIN b JOIN c WHERE a.id = b.id AND b.id = c.id";
6636        let out = apply_lint_fixes(
6637            sql,
6638            Dialect::Generic,
6639            &[issue_codes::LINT_AM_005.to_string()],
6640        )
6641        .expect("fix");
6642        let lower = out.sql.to_ascii_lowercase();
6643        // Both joins should get ON clauses.
6644        let on_count = lower.matches(" on ").count();
6645        assert!(on_count >= 2, "expected at least 2 ON clauses: {}", out.sql);
6646        assert!(
6647            !lower.contains("where"),
6648            "all predicates should be extracted: {}",
6649            out.sql
6650        );
6651    }
6652
6653    #[test]
6654    fn cv012_preserves_explicit_on() {
6655        let sql = "SELECT * FROM a JOIN b ON a.id = b.id";
6656        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6657        assert_eq!(
6658            lint_rule_count(sql, issue_codes::LINT_CV_012),
6659            0,
6660            "explicit ON should not trigger CV_012"
6661        );
6662        let lower = out.sql.to_ascii_lowercase();
6663        assert!(
6664            lower.contains("on a.id = b.id"),
6665            "ON clause should be preserved: {}",
6666            out.sql
6667        );
6668    }
6669
6670    #[test]
6671    fn cv012_idempotent() {
6672        let sql = "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id";
6673        let lint_config = LintConfig {
6674            enabled: true,
6675            disabled_rules: vec![issue_codes::LINT_LT_014.to_string()],
6676            rule_configs: std::collections::BTreeMap::new(),
6677        };
6678        let out1 = apply_lint_fixes_with_options(
6679            sql,
6680            Dialect::Generic,
6681            &lint_config,
6682            FixOptions {
6683                include_unsafe_fixes: true,
6684                include_rewrite_candidates: false,
6685            },
6686        )
6687        .expect("fix");
6688        let out2 = apply_lint_fixes_with_options(
6689            &out1.sql,
6690            Dialect::Generic,
6691            &lint_config,
6692            FixOptions {
6693                include_unsafe_fixes: true,
6694                include_rewrite_candidates: false,
6695            },
6696        )
6697        .expect("fix2");
6698        assert_eq!(
6699            out1.sql.trim_end(),
6700            out2.sql.trim_end(),
6701            "second pass should be idempotent aside from trailing-whitespace normalization"
6702        );
6703    }
6704}