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 { query, .. } => collect_table_alias_idents_in_query(query, visitor),
2643        Statement::CreateTable(create) => {
2644            if let Some(query) = &create.query {
2645                collect_table_alias_idents_in_query(query, visitor);
2646            }
2647        }
2648        Statement::Merge { table, source, .. } => {
2649            collect_table_alias_idents_in_table_factor(table, visitor);
2650            collect_table_alias_idents_in_table_factor(source, visitor);
2651        }
2652        _ => {}
2653    }
2654}
2655
2656fn collect_table_alias_idents_in_query<F: FnMut(&Ident)>(query: &Query, visitor: &mut F) {
2657    if let Some(with) = &query.with {
2658        for cte in &with.cte_tables {
2659            collect_table_alias_idents_in_query(&cte.query, visitor);
2660        }
2661    }
2662
2663    collect_table_alias_idents_in_set_expr(&query.body, visitor);
2664}
2665
2666fn collect_table_alias_idents_in_set_expr<F: FnMut(&Ident)>(set_expr: &SetExpr, visitor: &mut F) {
2667    match set_expr {
2668        SetExpr::Select(select) => {
2669            for table in &select.from {
2670                collect_table_alias_idents_in_table_with_joins(table, visitor);
2671            }
2672        }
2673        SetExpr::Query(query) => collect_table_alias_idents_in_query(query, visitor),
2674        SetExpr::SetOperation { left, right, .. } => {
2675            collect_table_alias_idents_in_set_expr(left, visitor);
2676            collect_table_alias_idents_in_set_expr(right, visitor);
2677        }
2678        SetExpr::Insert(statement)
2679        | SetExpr::Update(statement)
2680        | SetExpr::Delete(statement)
2681        | SetExpr::Merge(statement) => collect_table_alias_idents_in_statement(statement, visitor),
2682        _ => {}
2683    }
2684}
2685
2686fn collect_table_alias_idents_in_table_with_joins<F: FnMut(&Ident)>(
2687    table_with_joins: &TableWithJoins,
2688    visitor: &mut F,
2689) {
2690    collect_table_alias_idents_in_table_factor(&table_with_joins.relation, visitor);
2691    for join in &table_with_joins.joins {
2692        collect_table_alias_idents_in_table_factor(&join.relation, visitor);
2693    }
2694}
2695
2696fn collect_table_alias_idents_in_table_factor<F: FnMut(&Ident)>(
2697    table_factor: &TableFactor,
2698    visitor: &mut F,
2699) {
2700    if let Some(alias) = table_factor_alias_ident(table_factor) {
2701        visitor(alias);
2702    }
2703
2704    match table_factor {
2705        TableFactor::Derived { subquery, .. } => {
2706            collect_table_alias_idents_in_query(subquery, visitor)
2707        }
2708        TableFactor::NestedJoin {
2709            table_with_joins, ..
2710        } => collect_table_alias_idents_in_table_with_joins(table_with_joins, visitor),
2711        TableFactor::Pivot { table, .. }
2712        | TableFactor::Unpivot { table, .. }
2713        | TableFactor::MatchRecognize { table, .. } => {
2714            collect_table_alias_idents_in_table_factor(table, visitor)
2715        }
2716        _ => {}
2717    }
2718}
2719
2720#[cfg(test)]
2721fn is_ascii_whitespace_byte(byte: u8) -> bool {
2722    matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0x0b | 0x0c)
2723}
2724
2725#[cfg(test)]
2726fn is_ascii_ident_start(byte: u8) -> bool {
2727    byte.is_ascii_alphabetic() || byte == b'_'
2728}
2729
2730#[cfg(test)]
2731fn is_ascii_ident_continue(byte: u8) -> bool {
2732    byte.is_ascii_alphanumeric() || byte == b'_'
2733}
2734
2735#[cfg(test)]
2736fn skip_ascii_whitespace(bytes: &[u8], mut idx: usize) -> usize {
2737    while idx < bytes.len() && is_ascii_whitespace_byte(bytes[idx]) {
2738        idx += 1;
2739    }
2740    idx
2741}
2742
2743#[cfg(test)]
2744fn consume_ascii_identifier(bytes: &[u8], start: usize) -> Option<usize> {
2745    if start >= bytes.len() || !is_ascii_ident_start(bytes[start]) {
2746        return None;
2747    }
2748    let mut idx = start + 1;
2749    while idx < bytes.len() && is_ascii_ident_continue(bytes[idx]) {
2750        idx += 1;
2751    }
2752    Some(idx)
2753}
2754
2755#[cfg(test)]
2756fn is_word_boundary_for_keyword(bytes: &[u8], idx: usize) -> bool {
2757    idx == 0 || idx >= bytes.len() || !is_ascii_ident_continue(bytes[idx])
2758}
2759
2760#[cfg(test)]
2761fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> Option<usize> {
2762    let end = start.checked_add(keyword_upper.len())?;
2763    if end > bytes.len() {
2764        return None;
2765    }
2766    if !is_word_boundary_for_keyword(bytes, start.saturating_sub(1))
2767        || !is_word_boundary_for_keyword(bytes, end)
2768    {
2769        return None;
2770    }
2771    let matches = bytes[start..end]
2772        .iter()
2773        .zip(keyword_upper.iter())
2774        .all(|(actual, expected)| actual.to_ascii_uppercase() == *expected);
2775    if matches {
2776        Some(end)
2777    } else {
2778        None
2779    }
2780}
2781
2782#[cfg(test)]
2783fn parse_subquery_alias_suffix(suffix: &str) -> Option<String> {
2784    let bytes = suffix.as_bytes();
2785    let mut i = skip_ascii_whitespace(bytes, 0);
2786    if let Some(as_end) = match_ascii_keyword_at(bytes, i, b"AS") {
2787        let after_as = skip_ascii_whitespace(bytes, as_end);
2788        if after_as == as_end {
2789            return None;
2790        }
2791        i = after_as;
2792    }
2793
2794    let alias_start = i;
2795    let alias_end = consume_ascii_identifier(bytes, alias_start)?;
2796    i = skip_ascii_whitespace(bytes, alias_end);
2797    if i < bytes.len() && bytes[i] == b';' {
2798        i += 1;
2799        i = skip_ascii_whitespace(bytes, i);
2800    }
2801    if i != bytes.len() {
2802        return None;
2803    }
2804    Some(suffix[alias_start..alias_end].to_string())
2805}
2806
2807#[cfg(test)]
2808fn fix_subquery_to_cte(sql: &str) -> String {
2809    let bytes = sql.as_bytes();
2810    let mut i = skip_ascii_whitespace(bytes, 0);
2811    let Some(select_end) = match_ascii_keyword_at(bytes, i, b"SELECT") else {
2812        return sql.to_string();
2813    };
2814    i = skip_ascii_whitespace(bytes, select_end);
2815    if i == select_end || i >= bytes.len() || bytes[i] != b'*' {
2816        return sql.to_string();
2817    }
2818    i += 1;
2819    let from_start = skip_ascii_whitespace(bytes, i);
2820    if from_start == i {
2821        return sql.to_string();
2822    }
2823    let Some(from_end) = match_ascii_keyword_at(bytes, from_start, b"FROM") else {
2824        return sql.to_string();
2825    };
2826    let open_paren_idx = skip_ascii_whitespace(bytes, from_end);
2827    if open_paren_idx == from_end || open_paren_idx >= bytes.len() || bytes[open_paren_idx] != b'('
2828    {
2829        return sql.to_string();
2830    };
2831
2832    let Some(close_paren_idx) = find_matching_parenthesis_outside_quotes(sql, open_paren_idx)
2833    else {
2834        return sql.to_string();
2835    };
2836
2837    let subquery = sql[open_paren_idx + 1..close_paren_idx].trim();
2838    if !subquery.to_ascii_lowercase().starts_with("select") {
2839        return sql.to_string();
2840    }
2841
2842    let suffix = &sql[close_paren_idx + 1..];
2843    let Some(alias) = parse_subquery_alias_suffix(suffix) else {
2844        return sql.to_string();
2845    };
2846
2847    let mut rewritten = format!("WITH {alias} AS ({subquery}) SELECT * FROM {alias}");
2848    if suffix.trim_end().ends_with(';') {
2849        rewritten.push(';');
2850    }
2851    rewritten
2852}
2853
2854#[cfg(test)]
2855fn find_matching_parenthesis_outside_quotes(sql: &str, open_paren_idx: usize) -> Option<usize> {
2856    #[derive(Clone, Copy, PartialEq, Eq)]
2857    enum Mode {
2858        Outside,
2859        SingleQuote,
2860        DoubleQuote,
2861        BacktickQuote,
2862        BracketQuote,
2863    }
2864
2865    let bytes = sql.as_bytes();
2866    if open_paren_idx >= bytes.len() || bytes[open_paren_idx] != b'(' {
2867        return None;
2868    }
2869
2870    let mut depth = 0usize;
2871    let mut mode = Mode::Outside;
2872    let mut i = open_paren_idx;
2873
2874    while i < bytes.len() {
2875        let b = bytes[i];
2876        let next = bytes.get(i + 1).copied();
2877
2878        match mode {
2879            Mode::Outside => {
2880                if b == b'\'' {
2881                    mode = Mode::SingleQuote;
2882                    i += 1;
2883                    continue;
2884                }
2885                if b == b'"' {
2886                    mode = Mode::DoubleQuote;
2887                    i += 1;
2888                    continue;
2889                }
2890                if b == b'`' {
2891                    mode = Mode::BacktickQuote;
2892                    i += 1;
2893                    continue;
2894                }
2895                if b == b'[' {
2896                    mode = Mode::BracketQuote;
2897                    i += 1;
2898                    continue;
2899                }
2900                if b == b'(' {
2901                    depth += 1;
2902                    i += 1;
2903                    continue;
2904                }
2905                if b == b')' {
2906                    depth = depth.checked_sub(1)?;
2907                    if depth == 0 {
2908                        return Some(i);
2909                    }
2910                }
2911                i += 1;
2912            }
2913            Mode::SingleQuote => {
2914                if b == b'\'' {
2915                    if next == Some(b'\'') {
2916                        i += 2;
2917                    } else {
2918                        mode = Mode::Outside;
2919                        i += 1;
2920                    }
2921                } else {
2922                    i += 1;
2923                }
2924            }
2925            Mode::DoubleQuote => {
2926                if b == b'"' {
2927                    if next == Some(b'"') {
2928                        i += 2;
2929                    } else {
2930                        mode = Mode::Outside;
2931                        i += 1;
2932                    }
2933                } else {
2934                    i += 1;
2935                }
2936            }
2937            Mode::BacktickQuote => {
2938                if b == b'`' {
2939                    if next == Some(b'`') {
2940                        i += 2;
2941                    } else {
2942                        mode = Mode::Outside;
2943                        i += 1;
2944                    }
2945                } else {
2946                    i += 1;
2947                }
2948            }
2949            Mode::BracketQuote => {
2950                if b == b']' {
2951                    if next == Some(b']') {
2952                        i += 2;
2953                    } else {
2954                        mode = Mode::Outside;
2955                        i += 1;
2956                    }
2957                } else {
2958                    i += 1;
2959                }
2960            }
2961        }
2962    }
2963
2964    None
2965}
2966
2967fn fix_statement(stmt: &mut Statement, rule_filter: &RuleFilter) {
2968    match stmt {
2969        Statement::Query(query) => fix_query(query, rule_filter),
2970        Statement::Insert(insert) => {
2971            if let Some(source) = insert.source.as_mut() {
2972                fix_query(source, rule_filter);
2973            }
2974        }
2975        Statement::CreateView { query, .. } => fix_query(query, rule_filter),
2976        Statement::CreateTable(create) => {
2977            if let Some(query) = create.query.as_mut() {
2978                fix_query(query, rule_filter);
2979            }
2980        }
2981        _ => {}
2982    }
2983}
2984
2985fn fix_query(query: &mut Query, rule_filter: &RuleFilter) {
2986    if let Some(with) = query.with.as_mut() {
2987        for cte in &mut with.cte_tables {
2988            fix_query(&mut cte.query, rule_filter);
2989        }
2990    }
2991
2992    fix_set_expr(query.body.as_mut(), rule_filter);
2993    rewrite_simple_derived_subqueries_to_ctes(query, rule_filter);
2994
2995    if let Some(order_by) = query.order_by.as_mut() {
2996        fix_order_by(order_by, rule_filter);
2997    }
2998
2999    if let Some(limit_clause) = query.limit_clause.as_mut() {
3000        fix_limit_clause(limit_clause, rule_filter);
3001    }
3002
3003    if let Some(fetch) = query.fetch.as_mut() {
3004        if let Some(quantity) = fetch.quantity.as_mut() {
3005            fix_expr(quantity, rule_filter);
3006        }
3007    }
3008}
3009
3010fn fix_set_expr(body: &mut SetExpr, rule_filter: &RuleFilter) {
3011    match body {
3012        SetExpr::Select(select) => fix_select(select, rule_filter),
3013        SetExpr::Query(query) => fix_query(query, rule_filter),
3014        SetExpr::SetOperation { left, right, .. } => {
3015            fix_set_expr(left, rule_filter);
3016            fix_set_expr(right, rule_filter);
3017        }
3018        SetExpr::Values(values) => {
3019            for row in &mut values.rows {
3020                for expr in row {
3021                    fix_expr(expr, rule_filter);
3022                }
3023            }
3024        }
3025        SetExpr::Insert(stmt)
3026        | SetExpr::Update(stmt)
3027        | SetExpr::Delete(stmt)
3028        | SetExpr::Merge(stmt) => fix_statement(stmt, rule_filter),
3029        _ => {}
3030    }
3031}
3032
3033fn fix_select(select: &mut Select, rule_filter: &RuleFilter) {
3034    for item in &mut select.projection {
3035        match item {
3036            SelectItem::UnnamedExpr(expr) => {
3037                fix_expr(expr, rule_filter);
3038            }
3039            SelectItem::ExprWithAlias { expr, .. } => {
3040                fix_expr(expr, rule_filter);
3041            }
3042            SelectItem::QualifiedWildcard(SelectItemQualifiedWildcardKind::Expr(expr), _) => {
3043                fix_expr(expr, rule_filter);
3044            }
3045            _ => {}
3046        }
3047    }
3048
3049    for table_with_joins in &mut select.from {
3050        if rule_filter.allows(issue_codes::LINT_CV_008) {
3051            rewrite_right_join_to_left(table_with_joins);
3052        }
3053
3054        fix_table_factor(&mut table_with_joins.relation, rule_filter);
3055
3056        let mut left_ref = table_factor_reference_name(&table_with_joins.relation);
3057
3058        for join in &mut table_with_joins.joins {
3059            let right_ref = table_factor_reference_name(&join.relation);
3060            if rule_filter.allows(issue_codes::LINT_ST_007) {
3061                rewrite_using_join_constraint(
3062                    &mut join.join_operator,
3063                    left_ref.as_deref(),
3064                    right_ref.as_deref(),
3065                );
3066            }
3067
3068            fix_table_factor(&mut join.relation, rule_filter);
3069            fix_join_operator(&mut join.join_operator, rule_filter);
3070
3071            if right_ref.is_some() {
3072                left_ref = right_ref;
3073            }
3074        }
3075    }
3076
3077    if let Some(prewhere) = select.prewhere.as_mut() {
3078        fix_expr(prewhere, rule_filter);
3079    }
3080
3081    if let Some(selection) = select.selection.as_mut() {
3082        fix_expr(selection, rule_filter);
3083    }
3084
3085    if let Some(having) = select.having.as_mut() {
3086        fix_expr(having, rule_filter);
3087    }
3088
3089    if let Some(qualify) = select.qualify.as_mut() {
3090        fix_expr(qualify, rule_filter);
3091    }
3092
3093    if let GroupByExpr::Expressions(exprs, _) = &mut select.group_by {
3094        for expr in exprs {
3095            fix_expr(expr, rule_filter);
3096        }
3097    }
3098
3099    for expr in &mut select.cluster_by {
3100        fix_expr(expr, rule_filter);
3101    }
3102
3103    for expr in &mut select.distribute_by {
3104        fix_expr(expr, rule_filter);
3105    }
3106
3107    for expr in &mut select.sort_by {
3108        fix_expr(&mut expr.expr, rule_filter);
3109    }
3110
3111    for lateral_view in &mut select.lateral_views {
3112        fix_expr(&mut lateral_view.lateral_view, rule_filter);
3113    }
3114
3115    if let Some(connect_by) = select.connect_by.as_mut() {
3116        fix_expr(&mut connect_by.condition, rule_filter);
3117        for relationship in &mut connect_by.relationships {
3118            fix_expr(relationship, rule_filter);
3119        }
3120    }
3121}
3122
3123fn rewrite_simple_derived_subqueries_to_ctes(query: &mut Query, rule_filter: &RuleFilter) {
3124    if !rule_filter.allows(issue_codes::LINT_ST_005) {
3125        return;
3126    }
3127
3128    let SetExpr::Select(select) = query.body.as_mut() else {
3129        return;
3130    };
3131
3132    let outer_source_names = select_source_names_upper(select);
3133    let mut used_cte_names: HashSet<String> = query
3134        .with
3135        .as_ref()
3136        .map(|with| {
3137            with.cte_tables
3138                .iter()
3139                .map(|cte| cte.alias.name.value.to_ascii_uppercase())
3140                .collect()
3141        })
3142        .unwrap_or_default();
3143    used_cte_names.extend(outer_source_names.iter().cloned());
3144
3145    let mut new_ctes = Vec::new();
3146
3147    for table_with_joins in &mut select.from {
3148        if rule_filter.st005_forbid_subquery_in.forbid_from() {
3149            if let Some(cte) = rewrite_derived_table_factor_to_cte(
3150                &mut table_with_joins.relation,
3151                &outer_source_names,
3152                &mut used_cte_names,
3153            ) {
3154                new_ctes.push(cte);
3155            }
3156        }
3157
3158        if rule_filter.st005_forbid_subquery_in.forbid_join() {
3159            for join in &mut table_with_joins.joins {
3160                if let Some(cte) = rewrite_derived_table_factor_to_cte(
3161                    &mut join.relation,
3162                    &outer_source_names,
3163                    &mut used_cte_names,
3164                ) {
3165                    new_ctes.push(cte);
3166                }
3167            }
3168        }
3169    }
3170
3171    if new_ctes.is_empty() {
3172        return;
3173    }
3174
3175    let with = query.with.get_or_insert_with(|| With {
3176        with_token: AttachedToken::empty(),
3177        recursive: false,
3178        cte_tables: Vec::new(),
3179    });
3180    with.cte_tables.extend(new_ctes);
3181}
3182
3183fn rewrite_derived_table_factor_to_cte(
3184    relation: &mut TableFactor,
3185    outer_source_names: &HashSet<String>,
3186    used_cte_names: &mut HashSet<String>,
3187) -> Option<Cte> {
3188    let (lateral, subquery, alias) = match relation {
3189        TableFactor::Derived {
3190            lateral,
3191            subquery,
3192            alias,
3193        } => (lateral, subquery, alias),
3194        _ => return None,
3195    };
3196
3197    if *lateral {
3198        return None;
3199    }
3200
3201    // Keep this rewrite conservative: only SELECT subqueries that do not
3202    // appear to reference outer sources.
3203    if !matches!(subquery.body.as_ref(), SetExpr::Select(_))
3204        || query_text_references_outer_sources(subquery, outer_source_names)
3205    {
3206        return None;
3207    }
3208
3209    let cte_alias = alias.clone().unwrap_or_else(|| TableAlias {
3210        name: Ident::new(next_generated_cte_name(used_cte_names)),
3211        columns: Vec::new(),
3212    });
3213    let cte_name_ident = cte_alias.name.clone();
3214    let cte_name_upper = cte_name_ident.value.to_ascii_uppercase();
3215    used_cte_names.insert(cte_name_upper);
3216
3217    let cte = Cte {
3218        alias: cte_alias,
3219        query: subquery.clone(),
3220        from: None,
3221        materialized: None,
3222        closing_paren_token: AttachedToken::empty(),
3223    };
3224
3225    *relation = TableFactor::Table {
3226        name: vec![cte_name_ident].into(),
3227        alias: None,
3228        args: None,
3229        with_hints: Vec::new(),
3230        version: None,
3231        with_ordinality: false,
3232        partitions: Vec::new(),
3233        json_path: None,
3234        sample: None,
3235        index_hints: Vec::new(),
3236    };
3237
3238    Some(cte)
3239}
3240
3241fn next_generated_cte_name(used_cte_names: &HashSet<String>) -> String {
3242    let mut index = 1usize;
3243    loop {
3244        let candidate = format!("cte_subquery_{index}");
3245        if !used_cte_names.contains(&candidate.to_ascii_uppercase()) {
3246            return candidate;
3247        }
3248        index += 1;
3249    }
3250}
3251
3252fn query_text_references_outer_sources(
3253    query: &Query,
3254    outer_source_names: &HashSet<String>,
3255) -> bool {
3256    if outer_source_names.is_empty() {
3257        return false;
3258    }
3259
3260    let rendered_upper = query.to_string().to_ascii_uppercase();
3261    outer_source_names
3262        .iter()
3263        .any(|name| rendered_upper.contains(&format!("{name}.")))
3264}
3265
3266fn select_source_names_upper(select: &Select) -> HashSet<String> {
3267    let mut names = HashSet::new();
3268    for table in &select.from {
3269        collect_source_names_from_table_factor(&table.relation, &mut names);
3270        for join in &table.joins {
3271            collect_source_names_from_table_factor(&join.relation, &mut names);
3272        }
3273    }
3274    names
3275}
3276
3277fn collect_source_names_from_table_factor(relation: &TableFactor, names: &mut HashSet<String>) {
3278    match relation {
3279        TableFactor::Table { name, alias, .. } => {
3280            if let Some(last) = name.0.last().and_then(|part| part.as_ident()) {
3281                names.insert(last.value.to_ascii_uppercase());
3282            }
3283            if let Some(alias) = alias {
3284                names.insert(alias.name.value.to_ascii_uppercase());
3285            }
3286        }
3287        TableFactor::Derived { alias, .. }
3288        | TableFactor::TableFunction { alias, .. }
3289        | TableFactor::Function { alias, .. }
3290        | TableFactor::UNNEST { alias, .. }
3291        | TableFactor::JsonTable { alias, .. }
3292        | TableFactor::OpenJsonTable { alias, .. }
3293        | TableFactor::NestedJoin { alias, .. }
3294        | TableFactor::Pivot { alias, .. }
3295        | TableFactor::Unpivot { alias, .. } => {
3296            if let Some(alias) = alias {
3297                names.insert(alias.name.value.to_ascii_uppercase());
3298            }
3299        }
3300        _ => {}
3301    }
3302}
3303
3304fn rewrite_right_join_to_left(table_with_joins: &mut TableWithJoins) {
3305    while let Some(index) = table_with_joins
3306        .joins
3307        .iter()
3308        .position(|join| rewritable_right_join(&join.join_operator))
3309    {
3310        rewrite_right_join_at_index(table_with_joins, index);
3311    }
3312}
3313
3314fn rewrite_right_join_at_index(table_with_joins: &mut TableWithJoins, index: usize) {
3315    let mut suffix = table_with_joins.joins.split_off(index);
3316    let mut join = suffix.remove(0);
3317
3318    let old_operator = std::mem::replace(
3319        &mut join.join_operator,
3320        JoinOperator::CrossJoin(JoinConstraint::None),
3321    );
3322    let Some(new_operator) = rewritten_left_join_operator(old_operator) else {
3323        table_with_joins.joins.push(join);
3324        table_with_joins.joins.append(&mut suffix);
3325        return;
3326    };
3327
3328    let previous_relation = std::mem::replace(&mut table_with_joins.relation, join.relation);
3329    let prefix_joins = std::mem::take(&mut table_with_joins.joins);
3330
3331    join.relation = if prefix_joins.is_empty() {
3332        previous_relation
3333    } else {
3334        TableFactor::NestedJoin {
3335            table_with_joins: Box::new(TableWithJoins {
3336                relation: previous_relation,
3337                joins: prefix_joins,
3338            }),
3339            alias: None,
3340        }
3341    };
3342    join.join_operator = new_operator;
3343
3344    table_with_joins.joins.push(join);
3345    table_with_joins.joins.append(&mut suffix);
3346}
3347
3348fn rewritable_right_join(operator: &JoinOperator) -> bool {
3349    matches!(
3350        operator,
3351        JoinOperator::Right(_)
3352            | JoinOperator::RightOuter(_)
3353            | JoinOperator::RightSemi(_)
3354            | JoinOperator::RightAnti(_)
3355    )
3356}
3357
3358fn rewritten_left_join_operator(operator: JoinOperator) -> Option<JoinOperator> {
3359    match operator {
3360        JoinOperator::Right(constraint) => Some(JoinOperator::Left(constraint)),
3361        JoinOperator::RightOuter(constraint) => Some(JoinOperator::LeftOuter(constraint)),
3362        JoinOperator::RightSemi(constraint) => Some(JoinOperator::LeftSemi(constraint)),
3363        JoinOperator::RightAnti(constraint) => Some(JoinOperator::LeftAnti(constraint)),
3364        _ => None,
3365    }
3366}
3367
3368fn table_factor_alias_ident(relation: &TableFactor) -> Option<&Ident> {
3369    let alias = match relation {
3370        TableFactor::Table { alias, .. }
3371        | TableFactor::Derived { alias, .. }
3372        | TableFactor::TableFunction { alias, .. }
3373        | TableFactor::Function { alias, .. }
3374        | TableFactor::UNNEST { alias, .. }
3375        | TableFactor::JsonTable { alias, .. }
3376        | TableFactor::OpenJsonTable { alias, .. }
3377        | TableFactor::NestedJoin { alias, .. }
3378        | TableFactor::Pivot { alias, .. }
3379        | TableFactor::Unpivot { alias, .. } => alias.as_ref(),
3380        _ => None,
3381    }?;
3382
3383    Some(&alias.name)
3384}
3385
3386fn table_factor_reference_name(relation: &TableFactor) -> Option<String> {
3387    match relation {
3388        TableFactor::Table { name, alias, .. } => {
3389            if let Some(alias) = alias {
3390                Some(alias.name.value.clone())
3391            } else {
3392                name.0
3393                    .last()
3394                    .and_then(|part| part.as_ident())
3395                    .map(|ident| ident.value.clone())
3396            }
3397        }
3398        _ => None,
3399    }
3400}
3401
3402fn rewrite_using_join_constraint(
3403    join_operator: &mut JoinOperator,
3404    left_ref: Option<&str>,
3405    right_ref: Option<&str>,
3406) {
3407    let (Some(left_ref), Some(right_ref)) = (left_ref, right_ref) else {
3408        return;
3409    };
3410
3411    let Some(constraint) = join_constraint_mut(join_operator) else {
3412        return;
3413    };
3414
3415    let JoinConstraint::Using(columns) = constraint else {
3416        return;
3417    };
3418
3419    if columns.is_empty() {
3420        return;
3421    }
3422
3423    let mut combined: Option<Expr> = None;
3424    for object_name in columns.iter() {
3425        let Some(column_ident) = object_name
3426            .0
3427            .last()
3428            .and_then(|part| part.as_ident())
3429            .cloned()
3430        else {
3431            continue;
3432        };
3433
3434        let equality = Expr::BinaryOp {
3435            left: Box::new(Expr::CompoundIdentifier(vec![
3436                Ident::new(left_ref),
3437                column_ident.clone(),
3438            ])),
3439            op: BinaryOperator::Eq,
3440            right: Box::new(Expr::CompoundIdentifier(vec![
3441                Ident::new(right_ref),
3442                column_ident,
3443            ])),
3444        };
3445
3446        combined = Some(match combined {
3447            Some(prev) => Expr::BinaryOp {
3448                left: Box::new(prev),
3449                op: BinaryOperator::And,
3450                right: Box::new(equality),
3451            },
3452            None => equality,
3453        });
3454    }
3455
3456    if let Some(on_expr) = combined {
3457        *constraint = JoinConstraint::On(on_expr);
3458    }
3459}
3460
3461fn fix_table_factor(relation: &mut TableFactor, rule_filter: &RuleFilter) {
3462    match relation {
3463        TableFactor::Table {
3464            args, with_hints, ..
3465        } => {
3466            if let Some(args) = args {
3467                for arg in &mut args.args {
3468                    fix_function_arg(arg, rule_filter);
3469                }
3470            }
3471            for hint in with_hints {
3472                fix_expr(hint, rule_filter);
3473            }
3474        }
3475        TableFactor::Derived { subquery, .. } => fix_query(subquery, rule_filter),
3476        TableFactor::TableFunction { expr, .. } => fix_expr(expr, rule_filter),
3477        TableFactor::Function { args, .. } => {
3478            for arg in args {
3479                fix_function_arg(arg, rule_filter);
3480            }
3481        }
3482        TableFactor::UNNEST { array_exprs, .. } => {
3483            for expr in array_exprs {
3484                fix_expr(expr, rule_filter);
3485            }
3486        }
3487        TableFactor::NestedJoin {
3488            table_with_joins, ..
3489        } => {
3490            if rule_filter.allows(issue_codes::LINT_CV_008) {
3491                rewrite_right_join_to_left(table_with_joins);
3492            }
3493
3494            fix_table_factor(&mut table_with_joins.relation, rule_filter);
3495
3496            let mut left_ref = table_factor_reference_name(&table_with_joins.relation);
3497
3498            for join in &mut table_with_joins.joins {
3499                let right_ref = table_factor_reference_name(&join.relation);
3500                if rule_filter.allows(issue_codes::LINT_ST_007) {
3501                    rewrite_using_join_constraint(
3502                        &mut join.join_operator,
3503                        left_ref.as_deref(),
3504                        right_ref.as_deref(),
3505                    );
3506                }
3507
3508                fix_table_factor(&mut join.relation, rule_filter);
3509                fix_join_operator(&mut join.join_operator, rule_filter);
3510
3511                if right_ref.is_some() {
3512                    left_ref = right_ref;
3513                }
3514            }
3515        }
3516        TableFactor::Pivot {
3517            table,
3518            aggregate_functions,
3519            value_column,
3520            default_on_null,
3521            ..
3522        } => {
3523            fix_table_factor(table, rule_filter);
3524            for func in aggregate_functions {
3525                fix_expr(&mut func.expr, rule_filter);
3526            }
3527            for expr in value_column {
3528                fix_expr(expr, rule_filter);
3529            }
3530            if let Some(expr) = default_on_null {
3531                fix_expr(expr, rule_filter);
3532            }
3533        }
3534        TableFactor::Unpivot {
3535            table,
3536            value,
3537            columns,
3538            ..
3539        } => {
3540            fix_table_factor(table, rule_filter);
3541            fix_expr(value, rule_filter);
3542            for column in columns {
3543                fix_expr(&mut column.expr, rule_filter);
3544            }
3545        }
3546        TableFactor::JsonTable { json_expr, .. } => fix_expr(json_expr, rule_filter),
3547        TableFactor::OpenJsonTable { json_expr, .. } => fix_expr(json_expr, rule_filter),
3548        _ => {}
3549    }
3550}
3551
3552fn fix_join_operator(op: &mut JoinOperator, rule_filter: &RuleFilter) {
3553    match op {
3554        JoinOperator::Join(constraint)
3555        | JoinOperator::Inner(constraint)
3556        | JoinOperator::Left(constraint)
3557        | JoinOperator::LeftOuter(constraint)
3558        | JoinOperator::Right(constraint)
3559        | JoinOperator::RightOuter(constraint)
3560        | JoinOperator::FullOuter(constraint)
3561        | JoinOperator::CrossJoin(constraint)
3562        | JoinOperator::Semi(constraint)
3563        | JoinOperator::LeftSemi(constraint)
3564        | JoinOperator::RightSemi(constraint)
3565        | JoinOperator::Anti(constraint)
3566        | JoinOperator::LeftAnti(constraint)
3567        | JoinOperator::RightAnti(constraint)
3568        | JoinOperator::StraightJoin(constraint) => fix_join_constraint(constraint, rule_filter),
3569        JoinOperator::AsOf {
3570            match_condition,
3571            constraint,
3572        } => {
3573            fix_expr(match_condition, rule_filter);
3574            fix_join_constraint(constraint, rule_filter);
3575        }
3576        JoinOperator::CrossApply | JoinOperator::OuterApply => {}
3577    }
3578}
3579
3580fn join_constraint_mut(join_operator: &mut JoinOperator) -> Option<&mut JoinConstraint> {
3581    match join_operator {
3582        JoinOperator::Join(constraint)
3583        | JoinOperator::Inner(constraint)
3584        | JoinOperator::Left(constraint)
3585        | JoinOperator::LeftOuter(constraint)
3586        | JoinOperator::Right(constraint)
3587        | JoinOperator::RightOuter(constraint)
3588        | JoinOperator::FullOuter(constraint)
3589        | JoinOperator::CrossJoin(constraint)
3590        | JoinOperator::Semi(constraint)
3591        | JoinOperator::LeftSemi(constraint)
3592        | JoinOperator::RightSemi(constraint)
3593        | JoinOperator::Anti(constraint)
3594        | JoinOperator::LeftAnti(constraint)
3595        | JoinOperator::RightAnti(constraint)
3596        | JoinOperator::StraightJoin(constraint) => Some(constraint),
3597        JoinOperator::AsOf { constraint, .. } => Some(constraint),
3598        JoinOperator::CrossApply | JoinOperator::OuterApply => None,
3599    }
3600}
3601
3602fn fix_join_constraint(constraint: &mut JoinConstraint, rule_filter: &RuleFilter) {
3603    if let JoinConstraint::On(expr) = constraint {
3604        fix_expr(expr, rule_filter);
3605    }
3606}
3607
3608fn fix_order_by(order_by: &mut OrderBy, rule_filter: &RuleFilter) {
3609    if let OrderByKind::Expressions(exprs) = &mut order_by.kind {
3610        for order_expr in exprs.iter_mut() {
3611            fix_expr(&mut order_expr.expr, rule_filter);
3612        }
3613    }
3614
3615    if let Some(interpolate) = order_by.interpolate.as_mut() {
3616        if let Some(exprs) = interpolate.exprs.as_mut() {
3617            for expr in exprs {
3618                if let Some(inner) = expr.expr.as_mut() {
3619                    fix_expr(inner, rule_filter);
3620                }
3621            }
3622        }
3623    }
3624}
3625
3626fn fix_limit_clause(limit_clause: &mut LimitClause, rule_filter: &RuleFilter) {
3627    match limit_clause {
3628        LimitClause::LimitOffset {
3629            limit,
3630            offset,
3631            limit_by,
3632        } => {
3633            if let Some(limit) = limit {
3634                fix_expr(limit, rule_filter);
3635            }
3636            if let Some(offset) = offset {
3637                fix_expr(&mut offset.value, rule_filter);
3638            }
3639            for expr in limit_by {
3640                fix_expr(expr, rule_filter);
3641            }
3642        }
3643        LimitClause::OffsetCommaLimit { offset, limit } => {
3644            fix_expr(offset, rule_filter);
3645            fix_expr(limit, rule_filter);
3646        }
3647    }
3648}
3649
3650fn fix_expr(expr: &mut Expr, rule_filter: &RuleFilter) {
3651    match expr {
3652        Expr::BinaryOp { left, right, .. } => {
3653            fix_expr(left, rule_filter);
3654            fix_expr(right, rule_filter);
3655        }
3656        Expr::UnaryOp { expr: inner, .. }
3657        | Expr::Nested(inner)
3658        | Expr::IsNull(inner)
3659        | Expr::IsNotNull(inner)
3660        | Expr::IsTrue(inner)
3661        | Expr::IsNotTrue(inner)
3662        | Expr::IsFalse(inner)
3663        | Expr::IsNotFalse(inner)
3664        | Expr::IsUnknown(inner)
3665        | Expr::IsNotUnknown(inner) => fix_expr(inner, rule_filter),
3666        Expr::Case {
3667            operand,
3668            conditions,
3669            else_result,
3670            ..
3671        } => {
3672            if let Some(operand) = operand.as_mut() {
3673                fix_expr(operand, rule_filter);
3674            }
3675            for case_when in conditions {
3676                fix_expr(&mut case_when.condition, rule_filter);
3677                fix_expr(&mut case_when.result, rule_filter);
3678            }
3679            if let Some(else_result) = else_result.as_mut() {
3680                fix_expr(else_result, rule_filter);
3681            }
3682        }
3683        Expr::Function(func) => fix_function(func, rule_filter),
3684        Expr::Cast { expr: inner, .. } => fix_expr(inner, rule_filter),
3685        Expr::InSubquery {
3686            expr: inner,
3687            subquery,
3688            ..
3689        } => {
3690            fix_expr(inner, rule_filter);
3691            fix_query(subquery, rule_filter);
3692        }
3693        Expr::Subquery(subquery) | Expr::Exists { subquery, .. } => {
3694            fix_query(subquery, rule_filter)
3695        }
3696        Expr::Between {
3697            expr: target,
3698            low,
3699            high,
3700            ..
3701        } => {
3702            fix_expr(target, rule_filter);
3703            fix_expr(low, rule_filter);
3704            fix_expr(high, rule_filter);
3705        }
3706        Expr::InList {
3707            expr: target, list, ..
3708        } => {
3709            fix_expr(target, rule_filter);
3710            for item in list {
3711                fix_expr(item, rule_filter);
3712            }
3713        }
3714        Expr::Tuple(items) => {
3715            for item in items {
3716                fix_expr(item, rule_filter);
3717            }
3718        }
3719        _ => {}
3720    }
3721
3722    // CV11 cast-style rewriting is now handled entirely by the core autofix
3723    // in cv_011.rs, which correctly supports first-seen consistent mode,
3724    // CONVERT conversions, and chained :: expressions.
3725
3726    if rule_filter.allows(issue_codes::LINT_ST_004) {
3727        if let Some(rewritten) = nested_case_rewrite(expr) {
3728            *expr = rewritten;
3729        }
3730    }
3731}
3732
3733fn fix_function(func: &mut Function, rule_filter: &RuleFilter) {
3734    if let FunctionArguments::List(arg_list) = &mut func.args {
3735        for arg in &mut arg_list.args {
3736            fix_function_arg(arg, rule_filter);
3737        }
3738        for clause in &mut arg_list.clauses {
3739            match clause {
3740                FunctionArgumentClause::OrderBy(order_by_exprs) => {
3741                    for order_by_expr in order_by_exprs {
3742                        fix_expr(&mut order_by_expr.expr, rule_filter);
3743                    }
3744                }
3745                FunctionArgumentClause::Limit(expr) => fix_expr(expr, rule_filter),
3746                _ => {}
3747            }
3748        }
3749    }
3750
3751    if let Some(filter) = func.filter.as_mut() {
3752        fix_expr(filter, rule_filter);
3753    }
3754
3755    for order_expr in &mut func.within_group {
3756        fix_expr(&mut order_expr.expr, rule_filter);
3757    }
3758}
3759
3760fn fix_function_arg(arg: &mut FunctionArg, rule_filter: &RuleFilter) {
3761    match arg {
3762        FunctionArg::Named { arg, .. }
3763        | FunctionArg::ExprNamed { arg, .. }
3764        | FunctionArg::Unnamed(arg) => {
3765            if let FunctionArgExpr::Expr(expr) = arg {
3766                fix_expr(expr, rule_filter);
3767            }
3768        }
3769    }
3770}
3771
3772fn nested_case_rewrite(expr: &Expr) -> Option<Expr> {
3773    let Expr::Case {
3774        case_token,
3775        operand: outer_operand,
3776        conditions: outer_conditions,
3777        else_result: Some(outer_else),
3778        end_token,
3779    } = expr
3780    else {
3781        return None;
3782    };
3783
3784    if outer_conditions.is_empty() {
3785        return None;
3786    }
3787
3788    let Expr::Case {
3789        operand: inner_operand,
3790        conditions: inner_conditions,
3791        else_result: inner_else,
3792        ..
3793    } = nested_case_expr(outer_else.as_ref())?
3794    else {
3795        return None;
3796    };
3797
3798    if inner_conditions.is_empty() {
3799        return None;
3800    }
3801
3802    if !case_operands_match(outer_operand.as_deref(), inner_operand.as_deref()) {
3803        return None;
3804    }
3805
3806    let mut merged_conditions = outer_conditions.clone();
3807    merged_conditions.extend(inner_conditions.iter().cloned());
3808
3809    Some(Expr::Case {
3810        case_token: case_token.clone(),
3811        operand: outer_operand.clone(),
3812        conditions: merged_conditions,
3813        else_result: inner_else.clone(),
3814        end_token: end_token.clone(),
3815    })
3816}
3817
3818fn nested_case_expr(expr: &Expr) -> Option<&Expr> {
3819    match expr {
3820        Expr::Case { .. } => Some(expr),
3821        Expr::Nested(inner) => nested_case_expr(inner),
3822        _ => None,
3823    }
3824}
3825
3826fn case_operands_match(outer: Option<&Expr>, inner: Option<&Expr>) -> bool {
3827    match (outer, inner) {
3828        (None, None) => true,
3829        (Some(left), Some(right)) => format!("{left}") == format!("{right}"),
3830        _ => false,
3831    }
3832}
3833
3834#[cfg(test)]
3835mod tests {
3836    use super::*;
3837    use flowscope_core::{
3838        analyze, issue_codes, AnalysisOptions, AnalyzeRequest, Dialect, LintConfig,
3839    };
3840
3841    fn default_lint_config() -> LintConfig {
3842        LintConfig {
3843            enabled: true,
3844            disabled_rules: vec![],
3845            rule_configs: std::collections::BTreeMap::new(),
3846        }
3847    }
3848
3849    fn lint_config_keep_only_rule(rule_code: &str, mut config: LintConfig) -> LintConfig {
3850        let disabled_rules = flowscope_core::linter::rules::all_rules(&default_lint_config())
3851            .into_iter()
3852            .map(|rule| rule.code().to_string())
3853            .filter(|code| !code.eq_ignore_ascii_case(rule_code))
3854            .collect();
3855        config.disabled_rules = disabled_rules;
3856        config
3857    }
3858
3859    fn lint_rule_count_with_config(sql: &str, code: &str, lint_config: &LintConfig) -> usize {
3860        let request = AnalyzeRequest {
3861            sql: sql.to_string(),
3862            files: None,
3863            dialect: Dialect::Generic,
3864            source_name: None,
3865            options: Some(AnalysisOptions {
3866                lint: Some(lint_config.clone()),
3867                ..Default::default()
3868            }),
3869            schema: None,
3870            #[cfg(feature = "templating")]
3871            template_config: None,
3872        };
3873
3874        analyze(&request)
3875            .issues
3876            .iter()
3877            .filter(|issue| issue.code == code)
3878            .count()
3879    }
3880
3881    fn lint_rule_count_with_config_in_dialect(
3882        sql: &str,
3883        code: &str,
3884        dialect: Dialect,
3885        lint_config: &LintConfig,
3886    ) -> usize {
3887        let request = AnalyzeRequest {
3888            sql: sql.to_string(),
3889            files: None,
3890            dialect,
3891            source_name: None,
3892            options: Some(AnalysisOptions {
3893                lint: Some(lint_config.clone()),
3894                ..Default::default()
3895            }),
3896            schema: None,
3897            #[cfg(feature = "templating")]
3898            template_config: None,
3899        };
3900
3901        analyze(&request)
3902            .issues
3903            .iter()
3904            .filter(|issue| issue.code == code)
3905            .count()
3906    }
3907
3908    fn lint_rule_count(sql: &str, code: &str) -> usize {
3909        lint_rule_count_with_config(sql, code, &default_lint_config())
3910    }
3911
3912    fn apply_fix_with_config(sql: &str, lint_config: &LintConfig) -> FixOutcome {
3913        apply_lint_fixes_with_lint_config(sql, Dialect::Generic, lint_config).expect("fix result")
3914    }
3915
3916    fn apply_core_only_fixes(sql: &str) -> FixOutcome {
3917        apply_lint_fixes_with_options(
3918            sql,
3919            Dialect::Generic,
3920            &default_lint_config(),
3921            FixOptions {
3922                include_unsafe_fixes: true,
3923                include_rewrite_candidates: false,
3924            },
3925        )
3926        .expect("fix result")
3927    }
3928
3929    fn sample_outcome(skipped_counts: FixSkippedCounts) -> FixOutcome {
3930        FixOutcome {
3931            sql: String::new(),
3932            counts: FixCounts::default(),
3933            changed: false,
3934            skipped_due_to_comments: false,
3935            skipped_due_to_regression: false,
3936            skipped_counts,
3937        }
3938    }
3939
3940    #[test]
3941    fn collect_fix_candidate_stats_always_counts_display_only_as_blocked() {
3942        let outcome = sample_outcome(FixSkippedCounts {
3943            unsafe_skipped: 1,
3944            protected_range_blocked: 2,
3945            overlap_conflict_blocked: 3,
3946            display_only: 4,
3947        });
3948
3949        let stats = collect_fix_candidate_stats(
3950            &outcome,
3951            LintFixRuntimeOptions {
3952                include_unsafe_fixes: false,
3953                legacy_ast_fixes: false,
3954            },
3955        );
3956
3957        assert_eq!(stats.skipped, 0);
3958        assert_eq!(stats.blocked, 10);
3959        assert_eq!(stats.blocked_unsafe, 1);
3960        assert_eq!(stats.blocked_display_only, 4);
3961        assert_eq!(stats.blocked_protected_range, 2);
3962        assert_eq!(stats.blocked_overlap_conflict, 3);
3963    }
3964
3965    #[test]
3966    fn collect_fix_candidate_stats_excludes_unsafe_when_unsafe_fixes_enabled() {
3967        let outcome = sample_outcome(FixSkippedCounts {
3968            unsafe_skipped: 2,
3969            protected_range_blocked: 1,
3970            overlap_conflict_blocked: 1,
3971            display_only: 3,
3972        });
3973
3974        let stats = collect_fix_candidate_stats(
3975            &outcome,
3976            LintFixRuntimeOptions {
3977                include_unsafe_fixes: true,
3978                legacy_ast_fixes: false,
3979            },
3980        );
3981
3982        assert_eq!(stats.blocked, 5);
3983        assert_eq!(stats.blocked_unsafe, 0);
3984        assert_eq!(stats.blocked_display_only, 3);
3985    }
3986
3987    #[test]
3988    fn mostly_unfixable_residual_detects_dominated_known_residuals() {
3989        let counts = std::collections::BTreeMap::from([
3990            (issue_codes::LINT_LT_005.to_string(), 140usize),
3991            (issue_codes::LINT_RF_002.to_string(), 116usize),
3992            (issue_codes::LINT_AL_003.to_string(), 43usize),
3993            (issue_codes::LINT_RF_004.to_string(), 2usize),
3994            (issue_codes::LINT_ST_009.to_string(), 1usize),
3995        ]);
3996        assert!(is_mostly_unfixable_residual(&counts));
3997    }
3998
3999    #[test]
4000    fn mostly_unfixable_residual_rejects_when_fixable_tail_is_material() {
4001        let counts = std::collections::BTreeMap::from([
4002            (issue_codes::LINT_LT_005.to_string(), 20usize),
4003            (issue_codes::LINT_RF_002.to_string(), 10usize),
4004            (issue_codes::LINT_ST_009.to_string(), 8usize),
4005            (issue_codes::LINT_LT_003.to_string(), 3usize),
4006        ]);
4007        assert!(!is_mostly_unfixable_residual(&counts));
4008    }
4009
4010    #[test]
4011    fn am005_outer_mode_full_join_fix_output() {
4012        let lint_config = LintConfig {
4013            enabled: true,
4014            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4015            rule_configs: std::collections::BTreeMap::from([(
4016                "ambiguous.join".to_string(),
4017                serde_json::json!({"fully_qualify_join_types": "outer"}),
4018            )]),
4019        };
4020        let sql = "SELECT a FROM t FULL JOIN u ON t.id = u.id";
4021        assert_eq!(
4022            lint_rule_count_with_config(
4023                "SELECT a FROM t FULL OUTER JOIN u ON t.id = u.id",
4024                issue_codes::LINT_AM_005,
4025                &lint_config,
4026            ),
4027            0
4028        );
4029        let out = apply_fix_with_config(sql, &lint_config);
4030        assert!(
4031            out.sql.to_ascii_uppercase().contains("FULL OUTER JOIN"),
4032            "expected FULL OUTER JOIN in fixed SQL, got: {}",
4033            out.sql
4034        );
4035        assert_eq!(fix_count_for_code(&out.counts, issue_codes::LINT_AM_005), 1);
4036    }
4037
4038    fn fix_count_for_code(counts: &FixCounts, code: &str) -> usize {
4039        counts.get(code)
4040    }
4041
4042    #[test]
4043    fn lint_rule_counts_includes_parse_errors() {
4044        let counts = lint_rule_counts("SELECT (", Dialect::Generic, &default_lint_config());
4045        assert!(
4046            counts.get(issue_codes::PARSE_ERROR).copied().unwrap_or(0) > 0,
4047            "invalid SQL should contribute PARSE_ERROR to regression counts"
4048        );
4049    }
4050
4051    #[test]
4052    fn parse_error_regression_is_detected_even_with_lint_improvements() {
4053        let before = std::collections::BTreeMap::from([(issue_codes::LINT_ST_005.to_string(), 1)]);
4054        let after = std::collections::BTreeMap::from([(issue_codes::PARSE_ERROR.to_string(), 1)]);
4055        let removed = FixCounts::from_removed(&before, &after);
4056
4057        assert_eq!(
4058            removed.total(),
4059            1,
4060            "lint-only comparison can still look improved"
4061        );
4062        assert!(
4063            parse_errors_increased(&before, &after),
4064            "introduced parse errors must force regression"
4065        );
4066    }
4067
4068    #[test]
4069    fn lint_improvements_can_mask_total_violation_regressions() {
4070        let before = std::collections::BTreeMap::from([
4071            (issue_codes::LINT_LT_002.to_string(), 2usize),
4072            (issue_codes::LINT_LT_001.to_string(), 0usize),
4073        ]);
4074        let after = std::collections::BTreeMap::from([
4075            (issue_codes::LINT_LT_002.to_string(), 1usize),
4076            (issue_codes::LINT_LT_001.to_string(), 2usize),
4077        ]);
4078        let removed = FixCounts::from_removed(&before, &after);
4079        let before_total: usize = before.values().sum();
4080        let after_total: usize = after.values().sum();
4081
4082        assert_eq!(
4083            removed.total(),
4084            1,
4085            "a rule-level improvement can still be observed"
4086        );
4087        assert!(
4088            after_total > before_total,
4089            "strict regression guard must reject net-violation increases"
4090        );
4091    }
4092
4093    #[test]
4094    fn lt03_improvement_allows_lt05_tradeoff_at_equal_totals() {
4095        let before = std::collections::BTreeMap::from([
4096            (issue_codes::LINT_LT_003.to_string(), 1usize),
4097            (issue_codes::LINT_LT_005.to_string(), 5usize),
4098        ]);
4099        let after = std::collections::BTreeMap::from([
4100            (issue_codes::LINT_LT_003.to_string(), 0usize),
4101            (issue_codes::LINT_LT_005.to_string(), 6usize),
4102        ]);
4103        let core_rules = std::collections::HashSet::from([
4104            issue_codes::LINT_LT_003.to_string(),
4105            issue_codes::LINT_LT_005.to_string(),
4106        ]);
4107
4108        assert!(
4109            !core_autofix_rules_not_improved(&before, &after, &core_rules),
4110            "LT03 improvements should be allowed to trade against LT05 at equal totals"
4111        );
4112    }
4113
4114    #[test]
4115    fn lt05_tradeoff_is_not_allowed_without_lt03_improvement() {
4116        let before = std::collections::BTreeMap::from([
4117            (issue_codes::LINT_LT_003.to_string(), 1usize),
4118            (issue_codes::LINT_LT_005.to_string(), 5usize),
4119        ]);
4120        let after = std::collections::BTreeMap::from([
4121            (issue_codes::LINT_LT_003.to_string(), 1usize),
4122            (issue_codes::LINT_LT_005.to_string(), 6usize),
4123        ]);
4124        let core_rules = std::collections::HashSet::from([
4125            issue_codes::LINT_LT_003.to_string(),
4126            issue_codes::LINT_LT_005.to_string(),
4127        ]);
4128
4129        assert!(
4130            core_autofix_rules_not_improved(&before, &after, &core_rules),
4131            "without LT03 improvement, LT05 worsening remains blocked"
4132        );
4133    }
4134
4135    fn assert_rule_case(
4136        sql: &str,
4137        code: &str,
4138        expected_before: usize,
4139        expected_after: usize,
4140        expected_fix_count: usize,
4141    ) {
4142        let before = lint_rule_count(sql, code);
4143        assert_eq!(
4144            before, expected_before,
4145            "unexpected initial lint count for {code} in SQL: {sql}"
4146        );
4147
4148        let out = apply_core_only_fixes(sql);
4149        assert!(
4150            !out.skipped_due_to_comments,
4151            "test SQL should not be skipped"
4152        );
4153        assert_eq!(
4154            fix_count_for_code(&out.counts, code),
4155            expected_fix_count,
4156            "unexpected fix count for {code} in SQL: {sql}"
4157        );
4158
4159        if expected_fix_count > 0 {
4160            assert!(out.changed, "expected SQL to change for {code}: {sql}");
4161        }
4162
4163        let after = lint_rule_count(&out.sql, code);
4164        assert_eq!(
4165            after, expected_after,
4166            "unexpected lint count after fix for {code}. SQL: {}",
4167            out.sql
4168        );
4169
4170        let second_pass = apply_core_only_fixes(&out.sql);
4171        assert_eq!(
4172            fix_count_for_code(&second_pass.counts, code),
4173            0,
4174            "expected idempotent second pass for {code}"
4175        );
4176    }
4177
4178    fn assert_rule_case_with_config(
4179        sql: &str,
4180        code: &str,
4181        expected_before: usize,
4182        expected_after: usize,
4183        expected_fix_count: usize,
4184        lint_config: &LintConfig,
4185    ) {
4186        let before = lint_rule_count_with_config(sql, code, lint_config);
4187        assert_eq!(
4188            before, expected_before,
4189            "unexpected initial lint count for {code} in SQL: {sql}"
4190        );
4191
4192        let out = apply_fix_with_config(sql, lint_config);
4193        assert!(
4194            !out.skipped_due_to_comments,
4195            "test SQL should not be skipped"
4196        );
4197        assert_eq!(
4198            fix_count_for_code(&out.counts, code),
4199            expected_fix_count,
4200            "unexpected fix count for {code} in SQL: {sql}"
4201        );
4202
4203        if expected_fix_count > 0 {
4204            assert!(out.changed, "expected SQL to change for {code}: {sql}");
4205        }
4206
4207        let after = lint_rule_count_with_config(&out.sql, code, lint_config);
4208        assert_eq!(
4209            after, expected_after,
4210            "unexpected lint count after fix for {code}. SQL: {}",
4211            out.sql
4212        );
4213
4214        let second_pass = apply_fix_with_config(&out.sql, lint_config);
4215        assert_eq!(
4216            fix_count_for_code(&second_pass.counts, code),
4217            0,
4218            "expected idempotent second pass for {code}"
4219        );
4220    }
4221
4222    #[test]
4223    fn sqlfluff_am003_cases_are_fixed() {
4224        let cases = [
4225            ("SELECT DISTINCT col FROM t GROUP BY col", 1, 0, 1),
4226            (
4227                "SELECT * FROM (SELECT DISTINCT a FROM t GROUP BY a) AS sub",
4228                1,
4229                0,
4230                1,
4231            ),
4232            (
4233                "WITH cte AS (SELECT DISTINCT a FROM t GROUP BY a) SELECT * FROM cte",
4234                1,
4235                0,
4236                1,
4237            ),
4238            (
4239                "CREATE VIEW v AS SELECT DISTINCT a FROM t GROUP BY a",
4240                1,
4241                0,
4242                1,
4243            ),
4244            (
4245                "INSERT INTO target SELECT DISTINCT a FROM t GROUP BY a",
4246                1,
4247                0,
4248                1,
4249            ),
4250            (
4251                "SELECT a FROM t UNION ALL SELECT DISTINCT b FROM t2 GROUP BY b",
4252                1,
4253                0,
4254                1,
4255            ),
4256            ("SELECT a, b FROM t", 0, 0, 0),
4257        ];
4258
4259        for (sql, before, after, fix_count) in cases {
4260            assert_rule_case(sql, issue_codes::LINT_AM_001, before, after, fix_count);
4261        }
4262    }
4263
4264    #[test]
4265    fn sqlfluff_am001_cases_are_fixed_or_unchanged() {
4266        let lint_config = LintConfig {
4267            enabled: true,
4268            disabled_rules: vec![issue_codes::LINT_LT_011.to_string()],
4269            rule_configs: std::collections::BTreeMap::new(),
4270        };
4271        let cases = [
4272            (
4273                "SELECT a, b FROM tbl UNION SELECT c, d FROM tbl1",
4274                1,
4275                0,
4276                1,
4277                Some("DISTINCT SELECT"),
4278            ),
4279            (
4280                "SELECT a, b FROM tbl UNION ALL SELECT c, d FROM tbl1",
4281                0,
4282                0,
4283                0,
4284                None,
4285            ),
4286            (
4287                "SELECT a, b FROM tbl UNION DISTINCT SELECT c, d FROM tbl1",
4288                0,
4289                0,
4290                0,
4291                None,
4292            ),
4293            (
4294                "select a, b from tbl union select c, d from tbl1",
4295                1,
4296                0,
4297                1,
4298                Some("DISTINCT SELECT"),
4299            ),
4300        ];
4301
4302        for (sql, before, after, fix_count, expected_text) in cases {
4303            assert_rule_case_with_config(
4304                sql,
4305                issue_codes::LINT_AM_002,
4306                before,
4307                after,
4308                fix_count,
4309                &lint_config,
4310            );
4311
4312            if let Some(expected) = expected_text {
4313                let out = apply_fix_with_config(sql, &lint_config);
4314                assert!(
4315                    out.sql.to_ascii_uppercase().contains(expected),
4316                    "expected {expected:?} in fixed SQL, got: {}",
4317                    out.sql
4318                );
4319            }
4320        }
4321    }
4322
4323    #[test]
4324    fn sqlfluff_am005_cases_are_fixed_or_unchanged() {
4325        let cases = [
4326            (
4327                "SELECT * FROM t ORDER BY a, b DESC",
4328                1,
4329                0,
4330                1,
4331                Some("ORDER BY A ASC, B DESC"),
4332            ),
4333            (
4334                "SELECT * FROM t ORDER BY a DESC, b",
4335                1,
4336                0,
4337                1,
4338                Some("ORDER BY A DESC, B ASC"),
4339            ),
4340            (
4341                "SELECT * FROM t ORDER BY a DESC, b NULLS LAST",
4342                1,
4343                0,
4344                1,
4345                Some("ORDER BY A DESC, B ASC NULLS LAST"),
4346            ),
4347            ("SELECT * FROM t ORDER BY a, b", 0, 0, 0, None),
4348            ("SELECT * FROM t ORDER BY a ASC, b DESC", 0, 0, 0, None),
4349        ];
4350
4351        for (sql, before, after, fix_count, expected_text) in cases {
4352            assert_rule_case(sql, issue_codes::LINT_AM_003, before, after, fix_count);
4353
4354            if let Some(expected) = expected_text {
4355                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4356                assert!(
4357                    out.sql.to_ascii_uppercase().contains(expected),
4358                    "expected {expected:?} in fixed SQL, got: {}",
4359                    out.sql
4360                );
4361            }
4362        }
4363    }
4364
4365    #[test]
4366    fn sqlfluff_am006_cases_are_fixed_or_unchanged() {
4367        let cases = [
4368            (
4369                "SELECT a FROM t JOIN u ON t.id = u.id",
4370                1,
4371                0,
4372                1,
4373                Some("INNER JOIN"),
4374            ),
4375            (
4376                "SELECT a FROM t JOIN u ON t.id = u.id JOIN v ON u.id = v.id",
4377                2,
4378                0,
4379                2,
4380                Some("INNER JOIN U"),
4381            ),
4382            ("SELECT a FROM t INNER JOIN u ON t.id = u.id", 0, 0, 0, None),
4383            ("SELECT a FROM t LEFT JOIN u ON t.id = u.id", 0, 0, 0, None),
4384        ];
4385
4386        for (sql, before, after, fix_count, expected_text) in cases {
4387            assert_rule_case(sql, issue_codes::LINT_AM_005, before, after, fix_count);
4388
4389            if let Some(expected) = expected_text {
4390                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4391                assert!(
4392                    out.sql.to_ascii_uppercase().contains(expected),
4393                    "expected {expected:?} in fixed SQL, got: {}",
4394                    out.sql
4395                );
4396            }
4397        }
4398    }
4399
4400    #[test]
4401    fn sqlfluff_am005_outer_and_both_configs_are_fixed() {
4402        let outer_config = LintConfig {
4403            enabled: true,
4404            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4405            rule_configs: std::collections::BTreeMap::from([(
4406                "ambiguous.join".to_string(),
4407                serde_json::json!({"fully_qualify_join_types": "outer"}),
4408            )]),
4409        };
4410        let both_config = LintConfig {
4411            enabled: true,
4412            disabled_rules: vec![issue_codes::LINT_CV_008.to_string()],
4413            rule_configs: std::collections::BTreeMap::from([(
4414                "ambiguous.join".to_string(),
4415                serde_json::json!({"fully_qualify_join_types": "both"}),
4416            )]),
4417        };
4418
4419        let outer_cases = [
4420            (
4421                "SELECT a FROM t LEFT JOIN u ON t.id = u.id",
4422                1,
4423                0,
4424                1,
4425                Some("LEFT OUTER JOIN"),
4426            ),
4427            (
4428                "SELECT a FROM t RIGHT JOIN u ON t.id = u.id",
4429                1,
4430                0,
4431                1,
4432                Some("RIGHT OUTER JOIN"),
4433            ),
4434            (
4435                "SELECT a FROM t FULL JOIN u ON t.id = u.id",
4436                1,
4437                0,
4438                1,
4439                Some("FULL OUTER JOIN"),
4440            ),
4441            (
4442                "SELECT a FROM t full join u ON t.id = u.id",
4443                1,
4444                0,
4445                1,
4446                Some("FULL OUTER JOIN"),
4447            ),
4448            ("SELECT a FROM t JOIN u ON t.id = u.id", 0, 0, 0, None),
4449        ];
4450        for (sql, before, after, fix_count, expected_text) in outer_cases {
4451            assert_rule_case_with_config(
4452                sql,
4453                issue_codes::LINT_AM_005,
4454                before,
4455                after,
4456                fix_count,
4457                &outer_config,
4458            );
4459            if let Some(expected) = expected_text {
4460                let out = apply_fix_with_config(sql, &outer_config);
4461                assert!(
4462                    out.sql.to_ascii_uppercase().contains(expected),
4463                    "expected {expected:?} in fixed SQL, got: {}",
4464                    out.sql
4465                );
4466            }
4467        }
4468
4469        let both_cases = [
4470            (
4471                "SELECT a FROM t JOIN u ON t.id = u.id",
4472                1,
4473                0,
4474                1,
4475                Some("INNER JOIN"),
4476            ),
4477            (
4478                "SELECT a FROM t LEFT JOIN u ON t.id = u.id",
4479                1,
4480                0,
4481                1,
4482                Some("LEFT OUTER JOIN"),
4483            ),
4484            (
4485                "SELECT a FROM t FULL JOIN u ON t.id = u.id",
4486                1,
4487                0,
4488                1,
4489                Some("FULL OUTER JOIN"),
4490            ),
4491        ];
4492        for (sql, before, after, fix_count, expected_text) in both_cases {
4493            assert_rule_case_with_config(
4494                sql,
4495                issue_codes::LINT_AM_005,
4496                before,
4497                after,
4498                fix_count,
4499                &both_config,
4500            );
4501            if let Some(expected) = expected_text {
4502                let out = apply_fix_with_config(sql, &both_config);
4503                assert!(
4504                    out.sql.to_ascii_uppercase().contains(expected),
4505                    "expected {expected:?} in fixed SQL, got: {}",
4506                    out.sql
4507                );
4508            }
4509        }
4510    }
4511
4512    #[test]
4513    fn sqlfluff_am009_cases_are_fixed_or_unchanged() {
4514        let cases = [
4515            (
4516                "SELECT foo.a, bar.b FROM foo INNER JOIN bar",
4517                1,
4518                0,
4519                1,
4520                Some("CROSS JOIN BAR"),
4521            ),
4522            (
4523                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar",
4524                1,
4525                0,
4526                1,
4527                Some("CROSS JOIN BAR"),
4528            ),
4529            (
4530                "SELECT foo.a, bar.b FROM foo JOIN bar WHERE foo.a = bar.a OR foo.x = 3",
4531                0,
4532                0,
4533                0,
4534                None,
4535            ),
4536            ("SELECT foo.a, bar.b FROM foo CROSS JOIN bar", 0, 0, 0, None),
4537            (
4538                "SELECT foo.id, bar.id FROM foo LEFT JOIN bar USING (id)",
4539                0,
4540                0,
4541                0,
4542                None,
4543            ),
4544        ];
4545
4546        for (sql, before, after, fix_count, expected_text) in cases {
4547            assert_rule_case(sql, issue_codes::LINT_AM_008, before, after, fix_count);
4548
4549            if let Some(expected) = expected_text {
4550                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4551                assert!(
4552                    out.sql.to_ascii_uppercase().contains(expected),
4553                    "expected {expected:?} in fixed SQL, got: {}",
4554                    out.sql
4555                );
4556            }
4557        }
4558    }
4559
4560    #[test]
4561    fn sqlfluff_al007_force_enabled_single_table_alias_is_fixed() {
4562        let lint_config = LintConfig {
4563            enabled: true,
4564            disabled_rules: vec![],
4565            rule_configs: std::collections::BTreeMap::from([(
4566                "aliasing.forbid".to_string(),
4567                serde_json::json!({"force_enable": true}),
4568            )]),
4569        };
4570        let sql = "SELECT u.id FROM users u";
4571        assert_rule_case_with_config(sql, issue_codes::LINT_AL_007, 1, 0, 1, &lint_config);
4572
4573        let out = apply_fix_with_config(sql, &lint_config);
4574        let fixed_upper = out.sql.to_ascii_uppercase();
4575        assert!(
4576            fixed_upper.contains("FROM USERS"),
4577            "expected table alias to be removed: {}",
4578            out.sql
4579        );
4580        assert!(
4581            !fixed_upper.contains("FROM USERS U"),
4582            "expected unnecessary table alias to be removed: {}",
4583            out.sql
4584        );
4585        assert!(
4586            fixed_upper.contains("USERS.ID"),
4587            "expected references to use table name after alias removal: {}",
4588            out.sql
4589        );
4590    }
4591
4592    #[test]
4593    fn sqlfluff_al009_fix_respects_case_sensitive_mode() {
4594        let lint_config = LintConfig {
4595            enabled: true,
4596            // Disable CP_002 so identifier lowercasing does not turn `A` into `a`,
4597            // which would create a new AL_009 self-alias violation.
4598            disabled_rules: vec![issue_codes::LINT_CP_002.to_string()],
4599            rule_configs: std::collections::BTreeMap::from([(
4600                "aliasing.self_alias.column".to_string(),
4601                serde_json::json!({"alias_case_check": "case_sensitive"}),
4602            )]),
4603        };
4604        let sql = "SELECT a AS A FROM t";
4605        assert_rule_case_with_config(sql, issue_codes::LINT_AL_009, 0, 0, 0, &lint_config);
4606
4607        let out = apply_fix_with_config(sql, &lint_config);
4608        assert!(
4609            out.sql.contains("AS A"),
4610            "case-sensitive mode should keep case-mismatched alias: {}",
4611            out.sql
4612        );
4613    }
4614
4615    #[test]
4616    fn sqlfluff_al009_ast_fix_keeps_table_aliases() {
4617        let lint_config = LintConfig {
4618            enabled: true,
4619            disabled_rules: vec![issue_codes::LINT_AL_007.to_string()],
4620            rule_configs: std::collections::BTreeMap::new(),
4621        };
4622        let sql = "SELECT t.a AS a FROM t AS t";
4623        assert_rule_case_with_config(sql, issue_codes::LINT_AL_009, 1, 0, 1, &lint_config);
4624
4625        let out = apply_fix_with_config(sql, &lint_config);
4626        let fixed_upper = out.sql.to_ascii_uppercase();
4627        assert!(
4628            fixed_upper.contains("FROM T AS T"),
4629            "AL09 fix should not remove table alias declarations: {}",
4630            out.sql
4631        );
4632        assert!(
4633            !fixed_upper.contains("T.A AS A"),
4634            "expected only column self-alias to be removed: {}",
4635            out.sql
4636        );
4637    }
4638
4639    #[test]
4640    fn sqlfluff_st002_unnecessary_case_fix_cases() {
4641        let cases = [
4642            // Bool coalesce: CASE WHEN cond THEN TRUE ELSE FALSE END → coalesce(cond, false)
4643            (
4644                "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t",
4645                1,
4646                0,
4647                1,
4648                Some("COALESCE(X > 0, FALSE)"),
4649            ),
4650            // Negated bool: CASE WHEN cond THEN FALSE ELSE TRUE END → not coalesce(cond, false)
4651            (
4652                "SELECT CASE WHEN x > 0 THEN false ELSE true END FROM t",
4653                1,
4654                0,
4655                1,
4656                Some("NOT COALESCE(X > 0, FALSE)"),
4657            ),
4658            // Null coalesce: CASE WHEN x IS NULL THEN y ELSE x END → coalesce(x, y)
4659            (
4660                "SELECT CASE WHEN x IS NULL THEN 0 ELSE x END FROM t",
4661                1,
4662                0,
4663                1,
4664                Some("COALESCE(X, 0)"),
4665            ),
4666            // Not flagged: regular searched CASE (not an unnecessary pattern)
4667            (
4668                "SELECT CASE WHEN x = 1 THEN 'a' WHEN x = 2 THEN 'b' END FROM t",
4669                0,
4670                0,
4671                0,
4672                None,
4673            ),
4674        ];
4675
4676        for (sql, before, after, fix_count, expected_text) in cases {
4677            assert_rule_case(sql, issue_codes::LINT_ST_002, before, after, fix_count);
4678
4679            if let Some(expected) = expected_text {
4680                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4681                assert!(
4682                    out.sql.to_ascii_uppercase().contains(expected),
4683                    "expected {expected:?} in fixed SQL, got: {}",
4684                    out.sql
4685                );
4686            }
4687        }
4688    }
4689
4690    #[test]
4691    fn sqlfluff_st006_cases_are_fixed_or_unchanged() {
4692        let cases = [
4693            ("SELECT a + 1, a FROM t", 1, 0, 1, Some("A,\n    A + 1")),
4694            (
4695                "SELECT a + 1, b + 2, a FROM t",
4696                1,
4697                0,
4698                1,
4699                Some("A,\n    A + 1,\n    B + 2"),
4700            ),
4701            (
4702                "SELECT a + 1, b AS b_alias FROM t",
4703                1,
4704                0,
4705                1,
4706                Some("B AS B_ALIAS,\n    A + 1"),
4707            ),
4708            ("SELECT a, b + 1 FROM t", 0, 0, 0, None),
4709            ("SELECT a + 1, b + 2 FROM t", 0, 0, 0, None),
4710        ];
4711
4712        for (sql, before, after, fix_count, expected_text) in cases {
4713            assert_rule_case(sql, issue_codes::LINT_ST_006, before, after, fix_count);
4714
4715            if let Some(expected) = expected_text {
4716                let out = apply_core_only_fixes(sql);
4717                assert!(
4718                    out.sql.to_ascii_uppercase().contains(expected),
4719                    "expected {expected:?} in fixed SQL, got: {}",
4720                    out.sql
4721                );
4722            }
4723        }
4724    }
4725
4726    #[test]
4727    fn sqlfluff_st008_cases_are_fixed_or_unchanged() {
4728        let cases = [
4729            (
4730                "SELECT DISTINCT(a) FROM t",
4731                1,
4732                0,
4733                1,
4734                Some("SELECT DISTINCT A"),
4735            ),
4736            ("SELECT DISTINCT a FROM t", 0, 0, 0, None),
4737            ("SELECT a FROM t", 0, 0, 0, None),
4738        ];
4739
4740        for (sql, before, after, fix_count, expected_text) in cases {
4741            assert_rule_case(sql, issue_codes::LINT_ST_008, before, after, fix_count);
4742
4743            if let Some(expected) = expected_text {
4744                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4745                assert!(
4746                    out.sql.to_ascii_uppercase().contains(expected),
4747                    "expected {expected:?} in fixed SQL, got: {}",
4748                    out.sql
4749                );
4750            }
4751        }
4752    }
4753
4754    #[test]
4755    fn sqlfluff_st009_cases_are_fixed_or_unchanged() {
4756        let cases = [
4757            (
4758                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON bar.a = foo.a",
4759                1,
4760                0,
4761                1,
4762                Some("ON FOO.A = BAR.A"),
4763            ),
4764            (
4765                "SELECT foo.a, foo.b, bar.c FROM foo LEFT JOIN bar ON bar.a = foo.a AND bar.b = foo.b",
4766                1,
4767                1,
4768                0,
4769                None,
4770            ),
4771            (
4772                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON foo.a = bar.a",
4773                0,
4774                0,
4775                0,
4776                None,
4777            ),
4778            (
4779                "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON bar.b = a",
4780                0,
4781                0,
4782                0,
4783                None,
4784            ),
4785            (
4786                "SELECT foo.a, bar.b FROM foo AS x LEFT JOIN bar AS y ON y.a = x.a",
4787                1,
4788                0,
4789                1,
4790                Some("ON X.A = Y.A"),
4791            ),
4792        ];
4793
4794        for (sql, before, after, fix_count, expected_text) in cases {
4795            if before == after && fix_count == 0 {
4796                let initial = lint_rule_count(sql, issue_codes::LINT_ST_009);
4797                assert_eq!(
4798                    initial,
4799                    before,
4800                    "unexpected initial lint count for {} in SQL: {}",
4801                    issue_codes::LINT_ST_009,
4802                    sql
4803                );
4804
4805                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4806                assert_eq!(
4807                    fix_count_for_code(&out.counts, issue_codes::LINT_ST_009),
4808                    0,
4809                    "unexpected fix count for {} in SQL: {}",
4810                    issue_codes::LINT_ST_009,
4811                    sql
4812                );
4813                let after_count = lint_rule_count(&out.sql, issue_codes::LINT_ST_009);
4814                assert_eq!(
4815                    after_count,
4816                    after,
4817                    "unexpected lint count after fix for {}. SQL: {}",
4818                    issue_codes::LINT_ST_009,
4819                    out.sql
4820                );
4821            } else {
4822                assert_rule_case(sql, issue_codes::LINT_ST_009, before, after, fix_count);
4823            }
4824
4825            if let Some(expected) = expected_text {
4826                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4827                assert!(
4828                    out.sql.to_ascii_uppercase().contains(expected),
4829                    "expected {expected:?} in fixed SQL, got: {}",
4830                    out.sql
4831                );
4832            }
4833        }
4834    }
4835
4836    #[test]
4837    fn sqlfluff_st007_cases_are_fixed_or_unchanged() {
4838        let cases = [
4839            (
4840                "SELECT * FROM a JOIN b USING (id)",
4841                1,
4842                0,
4843                1,
4844                Some("ON A.ID = B.ID"),
4845            ),
4846            (
4847                "SELECT * FROM a AS x JOIN b AS y USING (id)",
4848                1,
4849                0,
4850                1,
4851                Some("ON X.ID = Y.ID"),
4852            ),
4853            (
4854                "SELECT * FROM a JOIN b USING (id, tenant_id)",
4855                1,
4856                0,
4857                1,
4858                Some("ON A.ID = B.ID AND A.TENANT_ID = B.TENANT_ID"),
4859            ),
4860            ("SELECT * FROM a JOIN b ON a.id = b.id", 0, 0, 0, None),
4861        ];
4862
4863        for (sql, before, after, fix_count, expected_text) in cases {
4864            assert_rule_case(sql, issue_codes::LINT_ST_007, before, after, fix_count);
4865
4866            if let Some(expected) = expected_text {
4867                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4868                assert!(
4869                    out.sql.to_ascii_uppercase().contains(expected),
4870                    "expected {expected:?} in fixed SQL, got: {}",
4871                    out.sql
4872                );
4873            }
4874        }
4875    }
4876
4877    #[test]
4878    fn sqlfluff_st004_cases_are_fixed_or_unchanged() {
4879        let cases = [
4880            (
4881                "SELECT CASE WHEN species = 'Rat' THEN 'Squeak' ELSE CASE WHEN species = 'Dog' THEN 'Woof' END END AS sound FROM mytable",
4882                1,
4883                1,
4884                0,
4885            ),
4886            (
4887                "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",
4888                1,
4889                1,
4890                0,
4891            ),
4892            (
4893                "SELECT CASE WHEN species = 'Rat' THEN CASE WHEN colour = 'Black' THEN 'Growl' WHEN colour = 'Grey' THEN 'Squeak' END END AS sound FROM mytable",
4894                0,
4895                0,
4896                0,
4897            ),
4898            (
4899                "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",
4900                0,
4901                0,
4902                0,
4903            ),
4904            (
4905                "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",
4906                1,
4907                1,
4908                0,
4909            ),
4910        ];
4911
4912        for (sql, before, after, fix_count) in cases {
4913            assert_rule_case(sql, issue_codes::LINT_ST_004, before, after, fix_count);
4914        }
4915    }
4916
4917    #[test]
4918    fn sqlfluff_cv003_cases_are_fixed_or_unchanged() {
4919        let cases = [
4920            ("SELECT a FROM foo WHERE a IS NULL", 0, 0, 0, None),
4921            ("SELECT a FROM foo WHERE a IS NOT NULL", 0, 0, 0, None),
4922            (
4923                "SELECT a FROM foo WHERE a <> NULL",
4924                1,
4925                0,
4926                1,
4927                Some("WHERE A IS NOT NULL"),
4928            ),
4929            (
4930                "SELECT a FROM foo WHERE a <> NULL AND b != NULL AND c = 'foo'",
4931                2,
4932                0,
4933                2,
4934                Some("A IS NOT NULL AND B IS NOT NULL"),
4935            ),
4936            (
4937                "SELECT a FROM foo WHERE a = NULL",
4938                1,
4939                0,
4940                1,
4941                Some("WHERE A IS NULL"),
4942            ),
4943            (
4944                "SELECT a FROM foo WHERE a=NULL",
4945                1,
4946                0,
4947                1,
4948                Some("WHERE A IS NULL"),
4949            ),
4950            (
4951                "SELECT a FROM foo WHERE a = b OR (c > d OR e = NULL)",
4952                1,
4953                0,
4954                1,
4955                Some("OR E IS NULL"),
4956            ),
4957            ("UPDATE table1 SET col = NULL WHERE col = ''", 0, 0, 0, None),
4958        ];
4959
4960        for (sql, before, after, fix_count, expected_text) in cases {
4961            assert_rule_case(sql, issue_codes::LINT_CV_005, before, after, fix_count);
4962
4963            if let Some(expected) = expected_text {
4964                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
4965                assert!(
4966                    out.sql.to_ascii_uppercase().contains(expected),
4967                    "expected {expected:?} in fixed SQL, got: {}",
4968                    out.sql
4969                );
4970            }
4971        }
4972    }
4973
4974    #[test]
4975    fn sqlfluff_cv001_cases_are_fixed_or_unchanged() {
4976        let cases = [
4977            ("SELECT coalesce(foo, 0) AS bar FROM baz", 0, 0, 0),
4978            ("SELECT ifnull(foo, 0) AS bar FROM baz", 1, 0, 1),
4979            ("SELECT nvl(foo, 0) AS bar FROM baz", 1, 0, 1),
4980            (
4981                "SELECT CASE WHEN x IS NULL THEN 'default' ELSE x END FROM t",
4982                0,
4983                0,
4984                0,
4985            ),
4986        ];
4987
4988        for (sql, before, after, fix_count) in cases {
4989            assert_rule_case(sql, issue_codes::LINT_CV_002, before, after, fix_count);
4990        }
4991    }
4992
4993    #[test]
4994    fn sqlfluff_cv003_trailing_comma_cases_are_fixed_or_unchanged() {
4995        let cases = [
4996            ("SELECT a, FROM t", 1, 0, 1),
4997            ("SELECT a , FROM t", 1, 0, 1),
4998            ("SELECT a FROM t", 0, 0, 0),
4999        ];
5000
5001        for (sql, before, after, fix_count) in cases {
5002            assert_rule_case(sql, issue_codes::LINT_CV_003, before, after, fix_count);
5003        }
5004    }
5005
5006    #[test]
5007    fn sqlfluff_cv001_not_equal_style_cases_are_fixed_or_unchanged() {
5008        let cases = [
5009            ("SELECT * FROM t WHERE a <> b AND c != d", 1, 0, 1),
5010            ("SELECT * FROM t WHERE a != b", 0, 0, 0),
5011        ];
5012
5013        for (sql, before, after, fix_count) in cases {
5014            assert_rule_case(sql, issue_codes::LINT_CV_001, before, after, fix_count);
5015        }
5016    }
5017
5018    #[test]
5019    fn sqlfluff_cv008_cases_are_fixed_or_unchanged() {
5020        let cases: [(&str, usize, usize, usize, Option<&str>); 4] = [
5021            ("SELECT * FROM a RIGHT JOIN b ON a.id = b.id", 1, 1, 0, None),
5022            (
5023                "SELECT a.id FROM a JOIN b ON a.id = b.id RIGHT JOIN c ON b.id = c.id",
5024                1,
5025                1,
5026                0,
5027                None,
5028            ),
5029            (
5030                "SELECT a.id FROM a RIGHT JOIN b ON a.id = b.id RIGHT JOIN c ON b.id = c.id",
5031                2,
5032                2,
5033                0,
5034                None,
5035            ),
5036            ("SELECT * FROM a LEFT JOIN b ON a.id = b.id", 0, 0, 0, None),
5037        ];
5038
5039        for (sql, before, after, fix_count, expected_text) in cases {
5040            assert_rule_case(sql, issue_codes::LINT_CV_008, before, after, fix_count);
5041
5042            if let Some(expected) = expected_text {
5043                let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5044                assert!(
5045                    out.sql.to_ascii_uppercase().contains(expected),
5046                    "expected {expected:?} in fixed SQL, got: {}",
5047                    out.sql
5048                );
5049            }
5050        }
5051    }
5052
5053    #[test]
5054    fn sqlfluff_cv007_cases_are_fixed_or_unchanged() {
5055        let cases = [
5056            ("(SELECT 1)", 1, 0, 1),
5057            ("((SELECT 1))", 1, 0, 1),
5058            ("SELECT 1", 0, 0, 0),
5059        ];
5060
5061        for (sql, before, after, fix_count) in cases {
5062            assert_rule_case(sql, issue_codes::LINT_CV_007, before, after, fix_count);
5063        }
5064    }
5065
5066    #[test]
5067    fn cv007_fix_respects_disabled_rules() {
5068        let sql = "(SELECT 1)\n";
5069        let out = apply_lint_fixes(
5070            sql,
5071            Dialect::Generic,
5072            &[issue_codes::LINT_CV_007.to_string()],
5073        )
5074        .expect("fix result");
5075        assert_eq!(out.sql, sql);
5076        assert_eq!(out.counts.get(issue_codes::LINT_CV_007), 0);
5077    }
5078
5079    #[test]
5080    fn cv010_fix_converts_double_to_single_in_bigquery() {
5081        let lint_config = LintConfig {
5082            enabled: true,
5083            disabled_rules: vec![],
5084            rule_configs: std::collections::BTreeMap::from([(
5085                "convention.quoted_literals".to_string(),
5086                serde_json::json!({"preferred_quoted_literal_style": "single_quotes"}),
5087            )]),
5088        };
5089        // In BigQuery, both "abc" and 'abc' are string literals.
5090        let sql = "SELECT \"abc\"";
5091        let before = lint_rule_count_with_config_in_dialect(
5092            sql,
5093            issue_codes::LINT_CV_010,
5094            Dialect::Bigquery,
5095            &lint_config,
5096        );
5097        assert_eq!(
5098            before, 1,
5099            "CV10 should flag double-quoted string in BigQuery with single_quotes preference"
5100        );
5101
5102        let out = apply_lint_fixes_with_lint_config(sql, Dialect::Bigquery, &lint_config)
5103            .expect("fix result");
5104        assert!(
5105            out.sql.contains("'abc'"),
5106            "expected double-quoted string to be converted to single-quoted: {}",
5107            out.sql
5108        );
5109    }
5110
5111    #[test]
5112    fn cv011_cast_preference_rewrites_double_colon_style() {
5113        let lint_config = LintConfig {
5114            enabled: true,
5115            disabled_rules: vec![],
5116            rule_configs: std::collections::BTreeMap::from([(
5117                "convention.casting_style".to_string(),
5118                serde_json::json!({"preferred_type_casting_style": "cast"}),
5119            )]),
5120        };
5121        let sql = "SELECT amount::INT FROM t";
5122        assert_rule_case_with_config(sql, issue_codes::LINT_CV_011, 1, 0, 1, &lint_config);
5123
5124        let out = apply_fix_with_config(sql, &lint_config);
5125        assert!(
5126            out.sql.to_ascii_uppercase().contains("CAST(AMOUNT AS INT)"),
5127            "expected CAST(...) rewrite for CV_011 fix: {}",
5128            out.sql
5129        );
5130    }
5131
5132    #[test]
5133    fn cv011_shorthand_preference_rewrites_cast_style_when_safe() {
5134        let lint_config = LintConfig {
5135            enabled: true,
5136            disabled_rules: vec![],
5137            rule_configs: std::collections::BTreeMap::from([(
5138                "LINT_CV_011".to_string(),
5139                serde_json::json!({"preferred_type_casting_style": "shorthand"}),
5140            )]),
5141        };
5142        let sql = "SELECT CAST(amount AS INT) FROM t";
5143        assert_rule_case_with_config(sql, issue_codes::LINT_CV_011, 1, 0, 1, &lint_config);
5144
5145        let out = apply_fix_with_config(sql, &lint_config);
5146        assert!(
5147            out.sql.to_ascii_uppercase().contains("AMOUNT::INT"),
5148            "expected :: rewrite for CV_011 fix: {}",
5149            out.sql
5150        );
5151    }
5152
5153    #[test]
5154    fn sqlfluff_st012_cases_are_fixed_or_unchanged() {
5155        let cases = [
5156            ("SELECT 1;;", 1, 0, 1),
5157            ("SELECT 1;\n \t ;", 1, 0, 1),
5158            ("SELECT 1;", 0, 0, 0),
5159        ];
5160
5161        for (sql, before, after, fix_count) in cases {
5162            assert_rule_case(sql, issue_codes::LINT_ST_012, before, after, fix_count);
5163        }
5164    }
5165
5166    #[test]
5167    fn sqlfluff_st002_cases_are_fixed_or_unchanged() {
5168        let cases = [
5169            ("SELECT CASE WHEN x > 1 THEN 'a' ELSE NULL END FROM t", 1, 0, 1),
5170            (
5171                "SELECT CASE name WHEN 'cat' THEN 'meow' WHEN 'dog' THEN 'woof' ELSE NULL END FROM t",
5172                1,
5173                0,
5174                1,
5175            ),
5176            (
5177                "SELECT CASE WHEN x = 1 THEN 'a' WHEN x = 2 THEN 'b' WHEN x = 3 THEN 'c' ELSE NULL END FROM t",
5178                1,
5179                0,
5180                1,
5181            ),
5182            (
5183                "SELECT CASE WHEN x > 0 THEN CASE WHEN y > 0 THEN 'pos' ELSE NULL END ELSE NULL END FROM t",
5184                2,
5185                0,
5186                2,
5187            ),
5188            (
5189                "SELECT * FROM t WHERE (CASE WHEN x > 0 THEN 1 ELSE NULL END) IS NOT NULL",
5190                1,
5191                0,
5192                1,
5193            ),
5194            (
5195                "WITH cte AS (SELECT CASE WHEN x > 0 THEN 'yes' ELSE NULL END AS flag FROM t) SELECT * FROM cte",
5196                1,
5197                0,
5198                1,
5199            ),
5200            ("SELECT CASE WHEN x > 1 THEN 'a' END FROM t", 0, 0, 0),
5201            (
5202                "SELECT CASE name WHEN 'cat' THEN 'meow' ELSE UPPER(name) END FROM t",
5203                0,
5204                0,
5205                0,
5206            ),
5207            ("SELECT CASE WHEN x > 1 THEN 'a' ELSE 'b' END FROM t", 0, 0, 0),
5208        ];
5209
5210        for (sql, before, after, fix_count) in cases {
5211            assert_rule_case(sql, issue_codes::LINT_ST_001, before, after, fix_count);
5212        }
5213    }
5214
5215    #[test]
5216    fn count_style_cases_are_fixed_or_unchanged() {
5217        let cases = [
5218            ("SELECT COUNT(1) FROM t", 1, 0, 1),
5219            (
5220                "SELECT col FROM t GROUP BY col HAVING COUNT(1) > 5",
5221                1,
5222                0,
5223                1,
5224            ),
5225            (
5226                "SELECT * FROM t WHERE id IN (SELECT COUNT(1) FROM t2 GROUP BY col)",
5227                1,
5228                0,
5229                1,
5230            ),
5231            ("SELECT COUNT(1), COUNT(1) FROM t", 2, 0, 2),
5232            (
5233                "WITH cte AS (SELECT COUNT(1) AS cnt FROM t) SELECT * FROM cte",
5234                1,
5235                0,
5236                1,
5237            ),
5238            ("SELECT COUNT(*) FROM t", 0, 0, 0),
5239            ("SELECT COUNT(id) FROM t", 0, 0, 0),
5240            ("SELECT COUNT(0) FROM t", 1, 0, 1),
5241            ("SELECT COUNT(01) FROM t", 1, 0, 1),
5242            ("SELECT COUNT(DISTINCT id) FROM t", 0, 0, 0),
5243        ];
5244
5245        for (sql, before, after, fix_count) in cases {
5246            assert_rule_case(sql, issue_codes::LINT_CV_004, before, after, fix_count);
5247        }
5248    }
5249
5250    #[test]
5251    fn safe_mode_blocks_template_tag_edits_but_applies_non_template_fixes() {
5252        let sql = "SELECT '{{foo}}' AS templated, COUNT(1) FROM t";
5253        let out = apply_lint_fixes_with_options(
5254            sql,
5255            Dialect::Generic,
5256            &default_lint_config(),
5257            FixOptions {
5258                include_unsafe_fixes: false,
5259                include_rewrite_candidates: true,
5260            },
5261        )
5262        .expect("fix result");
5263
5264        assert!(
5265            out.sql.contains("{{foo}}"),
5266            "template tag bytes should be preserved in safe mode: {}",
5267            out.sql
5268        );
5269        assert!(
5270            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5271            "non-template safe fixes should still apply: {}",
5272            out.sql
5273        );
5274        assert!(
5275            out.skipped_counts.protected_range_blocked > 0,
5276            "template-tag edits should be blocked in safe mode"
5277        );
5278    }
5279
5280    #[test]
5281    fn unsafe_mode_allows_template_tag_edits() {
5282        let sql = "SELECT '{{foo}}' AS templated, COUNT(1) FROM t";
5283        let out = apply_lint_fixes_with_options(
5284            sql,
5285            Dialect::Generic,
5286            &default_lint_config(),
5287            FixOptions {
5288                include_unsafe_fixes: true,
5289                include_rewrite_candidates: false,
5290            },
5291        )
5292        .expect("fix result");
5293
5294        assert!(
5295            out.sql.contains("{{ foo }}"),
5296            "unsafe mode should allow template-tag formatting fixes: {}",
5297            out.sql
5298        );
5299        assert!(
5300            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5301            "other fixes should still apply: {}",
5302            out.sql
5303        );
5304    }
5305
5306    #[test]
5307    fn comments_are_not_globally_skipped() {
5308        let sql = "-- keep this comment\nSELECT COUNT(1) FROM t";
5309        let out = apply_lint_fixes_with_options(
5310            sql,
5311            Dialect::Generic,
5312            &default_lint_config(),
5313            FixOptions {
5314                include_unsafe_fixes: false,
5315                include_rewrite_candidates: false,
5316            },
5317        )
5318        .expect("fix result");
5319        assert!(
5320            !out.skipped_due_to_comments,
5321            "comment presence should not skip all fixes"
5322        );
5323        assert!(
5324            out.sql.contains("-- keep this comment"),
5325            "comment text must be preserved: {}",
5326            out.sql
5327        );
5328        assert!(
5329            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5330            "non-comment region should still be fixable: {}",
5331            out.sql
5332        );
5333    }
5334
5335    #[test]
5336    fn mysql_hash_comments_are_not_globally_skipped() {
5337        let sql = "# keep this comment\nSELECT COUNT(1) FROM t";
5338        let out = apply_lint_fixes_with_options(
5339            sql,
5340            Dialect::Mysql,
5341            &default_lint_config(),
5342            FixOptions {
5343                include_unsafe_fixes: false,
5344                include_rewrite_candidates: false,
5345            },
5346        )
5347        .expect("fix result");
5348        assert!(
5349            !out.skipped_due_to_comments,
5350            "comment presence should not skip all fixes"
5351        );
5352        assert!(
5353            out.sql.contains("# keep this comment"),
5354            "comment text must be preserved: {}",
5355            out.sql
5356        );
5357        assert!(
5358            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
5359            "non-comment region should still be fixable: {}",
5360            out.sql
5361        );
5362    }
5363
5364    #[test]
5365    fn does_not_treat_double_quoted_comment_markers_as_comments() {
5366        let sql = "SELECT \"a--b\", \"x/*y\" FROM t";
5367        assert!(!contains_comment_markers(sql, Dialect::Generic));
5368    }
5369
5370    #[test]
5371    fn does_not_treat_backtick_or_bracketed_markers_as_comments() {
5372        let sql = "SELECT `a--b`, [x/*y] FROM t";
5373        assert!(!contains_comment_markers(sql, Dialect::Mysql));
5374    }
5375
5376    #[test]
5377    fn fix_mode_does_not_skip_double_quoted_markers() {
5378        let sql = "SELECT \"a--b\", COUNT(1) FROM t";
5379        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5380        assert!(!out.skipped_due_to_comments);
5381    }
5382
5383    #[test]
5384    fn fix_mode_does_not_skip_backtick_markers() {
5385        let sql = "SELECT `a--b`, COUNT(1) FROM t";
5386        let out = apply_lint_fixes(sql, Dialect::Mysql, &[]).expect("fix result");
5387        assert!(!out.skipped_due_to_comments);
5388    }
5389
5390    #[test]
5391    fn planner_blocks_protected_ranges_and_applies_non_overlapping_edits() {
5392        let sql = "SELECT '{{foo}}' AS templated, 1";
5393        let protected = collect_comment_protected_ranges(sql, Dialect::Generic, true);
5394        let template_idx = sql.find("{{foo}}").expect("template exists");
5395        let one_idx = sql.rfind('1').expect("digit exists");
5396
5397        let planned = plan_fix_candidates(
5398            sql,
5399            vec![
5400                FixCandidate {
5401                    start: template_idx,
5402                    end: template_idx + "{{foo}}".len(),
5403                    replacement: String::new(),
5404                    applicability: FixCandidateApplicability::Safe,
5405                    source: FixCandidateSource::PrimaryRewrite,
5406                    rule_code: None,
5407                },
5408                FixCandidate {
5409                    start: one_idx,
5410                    end: one_idx + 1,
5411                    replacement: "2".to_string(),
5412                    applicability: FixCandidateApplicability::Safe,
5413                    source: FixCandidateSource::PrimaryRewrite,
5414                    rule_code: None,
5415                },
5416            ],
5417            &protected,
5418            false,
5419        );
5420
5421        let applied = apply_planned_edits(sql, &planned.edits);
5422        assert!(
5423            applied.contains("{{foo}}"),
5424            "template span should remain protected: {applied}"
5425        );
5426        assert!(
5427            applied.ends_with("2"),
5428            "expected non-overlapping edit: {applied}"
5429        );
5430        assert_eq!(planned.skipped.protected_range_blocked, 1);
5431    }
5432
5433    #[test]
5434    fn planner_is_deterministic_for_conflicting_candidates() {
5435        let sql = "SELECT 1";
5436        let one_idx = sql.rfind('1').expect("digit exists");
5437
5438        let left_first = plan_fix_candidates(
5439            sql,
5440            vec![
5441                FixCandidate {
5442                    start: one_idx,
5443                    end: one_idx + 1,
5444                    replacement: "9".to_string(),
5445                    applicability: FixCandidateApplicability::Safe,
5446                    source: FixCandidateSource::PrimaryRewrite,
5447                    rule_code: None,
5448                },
5449                FixCandidate {
5450                    start: one_idx,
5451                    end: one_idx + 1,
5452                    replacement: "2".to_string(),
5453                    applicability: FixCandidateApplicability::Safe,
5454                    source: FixCandidateSource::PrimaryRewrite,
5455                    rule_code: None,
5456                },
5457            ],
5458            &[],
5459            false,
5460        );
5461        let right_first = plan_fix_candidates(
5462            sql,
5463            vec![
5464                FixCandidate {
5465                    start: one_idx,
5466                    end: one_idx + 1,
5467                    replacement: "2".to_string(),
5468                    applicability: FixCandidateApplicability::Safe,
5469                    source: FixCandidateSource::PrimaryRewrite,
5470                    rule_code: None,
5471                },
5472                FixCandidate {
5473                    start: one_idx,
5474                    end: one_idx + 1,
5475                    replacement: "9".to_string(),
5476                    applicability: FixCandidateApplicability::Safe,
5477                    source: FixCandidateSource::PrimaryRewrite,
5478                    rule_code: None,
5479                },
5480            ],
5481            &[],
5482            false,
5483        );
5484
5485        let left_sql = apply_planned_edits(sql, &left_first.edits);
5486        let right_sql = apply_planned_edits(sql, &right_first.edits);
5487        assert_eq!(left_sql, "SELECT 2");
5488        assert_eq!(left_sql, right_sql);
5489        assert_eq!(left_first.skipped.overlap_conflict_blocked, 1);
5490        assert_eq!(right_first.skipped.overlap_conflict_blocked, 1);
5491    }
5492
5493    #[test]
5494    fn core_autofix_candidates_are_collected_and_applied() {
5495        let sql = "SELECT 1";
5496        let one_idx = sql.rfind('1').expect("digit exists");
5497        let issues = vec![serde_json::json!({
5498            "code": issue_codes::LINT_CV_004,
5499            "span": { "start": one_idx, "end": one_idx + 1 },
5500            "autofix": {
5501                "applicability": "safe",
5502                "edits": [
5503                    {
5504                        "start": one_idx,
5505                        "end": one_idx + 1,
5506                        "replacement": "2"
5507                    }
5508                ]
5509            }
5510        })];
5511        let candidates = build_fix_candidates_from_issue_values(sql, &issues);
5512
5513        assert_eq!(candidates.len(), 1);
5514        let planned = plan_fix_candidates(sql, candidates, &[], false);
5515        let applied = apply_planned_edits(sql, &planned.edits);
5516        assert_eq!(applied, "SELECT 2");
5517    }
5518
5519    #[test]
5520    fn st002_core_autofix_candidates_apply_cleanly_in_safe_mode() {
5521        let sql = "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t\n";
5522        let issues = lint_issues(sql, Dialect::Generic, &default_lint_config());
5523        let candidates = build_fix_candidates_from_issue_autofixes(sql, &issues);
5524        assert!(
5525            candidates
5526                .iter()
5527                .any(|candidate| candidate.rule_code.as_deref() == Some(issue_codes::LINT_ST_002)),
5528            "expected ST002 core candidate from lint issues: {candidates:?}"
5529        );
5530
5531        let protected = collect_comment_protected_ranges(sql, Dialect::Generic, true);
5532        let planned = plan_fix_candidates(sql, candidates, &protected, false);
5533        let applied = apply_planned_edits(sql, &planned.edits);
5534        assert_eq!(
5535            applied, "SELECT coalesce(x > 0, false) FROM t\n",
5536            "unexpected ST002 planned edits with skipped={:?}",
5537            planned.skipped
5538        );
5539    }
5540
5541    #[test]
5542    fn incremental_core_plan_applies_st009_even_when_not_top_priority() {
5543        let sql = "select foo.a, bar.b from foo left join bar on bar.a = foo.a";
5544        let lint_config = default_lint_config();
5545        let before_counts = lint_rule_counts(sql, Dialect::Generic, &lint_config);
5546        assert_eq!(
5547            before_counts
5548                .get(issue_codes::LINT_ST_009)
5549                .copied()
5550                .unwrap_or(0),
5551            1
5552        );
5553
5554        let out = try_incremental_core_fix_plan(
5555            sql,
5556            Dialect::Generic,
5557            &lint_config,
5558            &before_counts,
5559            None,
5560            false,
5561            24,
5562            usize::MAX,
5563        )
5564        .expect("expected incremental ST009 fix");
5565        assert!(
5566            out.sql.contains("foo.a = bar.a"),
5567            "expected ST009 join condition reorder, got: {}",
5568            out.sql
5569        );
5570
5571        let after_counts = lint_rule_counts(&out.sql, Dialect::Generic, &lint_config);
5572        assert_eq!(
5573            after_counts
5574                .get(issue_codes::LINT_ST_009)
5575                .copied()
5576                .unwrap_or(0),
5577            0
5578        );
5579    }
5580
5581    #[test]
5582    fn cached_pre_lint_state_matches_uncached_next_pass_behavior() {
5583        let sql = "SELECT 1 UNION SELECT 2";
5584        let lint_config = default_lint_config();
5585        let fix_options = FixOptions {
5586            include_unsafe_fixes: false,
5587            include_rewrite_candidates: false,
5588        };
5589
5590        let first_pass = apply_lint_fixes_with_options_and_lint_state(
5591            sql,
5592            Dialect::Generic,
5593            &lint_config,
5594            fix_options,
5595            None,
5596        )
5597        .expect("first fix pass");
5598
5599        let second_cached = apply_lint_fixes_with_options_and_lint_state(
5600            &first_pass.outcome.sql,
5601            Dialect::Generic,
5602            &lint_config,
5603            fix_options,
5604            Some(first_pass.post_lint_state.clone()),
5605        )
5606        .expect("second cached pass");
5607        let second_uncached = apply_lint_fixes_with_options_and_lint_state(
5608            &first_pass.outcome.sql,
5609            Dialect::Generic,
5610            &lint_config,
5611            fix_options,
5612            None,
5613        )
5614        .expect("second uncached pass");
5615
5616        assert_eq!(second_cached.outcome.sql, second_uncached.outcome.sql);
5617        assert_eq!(second_cached.outcome.counts, second_uncached.outcome.counts);
5618        assert_eq!(
5619            second_cached.outcome.changed,
5620            second_uncached.outcome.changed
5621        );
5622        assert_eq!(
5623            second_cached.outcome.skipped_due_to_regression,
5624            second_uncached.outcome.skipped_due_to_regression
5625        );
5626    }
5627
5628    #[test]
5629    fn cp03_templated_case_emits_core_autofix_candidate() {
5630        let sql = "SELECT\n    {{ \"greatest(a, b)\" }},\n    GREATEST(i, j)\n";
5631        let config = lint_config_keep_only_rule(
5632            issue_codes::LINT_CP_003,
5633            LintConfig {
5634                enabled: true,
5635                disabled_rules: vec![],
5636                rule_configs: std::collections::BTreeMap::from([(
5637                    "core".to_string(),
5638                    serde_json::json!({"ignore_templated_areas": false}),
5639                )]),
5640            },
5641        );
5642        let issues = lint_issues(sql, Dialect::Ansi, &config);
5643        assert!(
5644            issues
5645                .iter()
5646                .any(|issue| { issue.code == issue_codes::LINT_CP_003 && issue.autofix.is_some() }),
5647            "expected CP03 issue with autofix metadata, got issues={issues:?}"
5648        );
5649
5650        let candidates = build_fix_candidates_from_issue_autofixes(sql, &issues);
5651        assert!(
5652            candidates.iter().any(|candidate| {
5653                candidate.rule_code.as_deref() == Some(issue_codes::LINT_CP_003)
5654                    && &sql[candidate.start..candidate.end] == "GREATEST"
5655                    && candidate.replacement == "greatest"
5656            }),
5657            "expected CP03 GREATEST candidate, got candidates={candidates:?}"
5658        );
5659    }
5660
5661    #[test]
5662    fn planner_prefers_core_autofix_over_rewrite_conflicts() {
5663        let sql = "SELECT 1";
5664        let one_idx = sql.rfind('1').expect("digit exists");
5665        let core_issue = serde_json::json!({
5666            "code": issue_codes::LINT_CV_004,
5667            "autofix": {
5668                "start": one_idx,
5669                "end": one_idx + 1,
5670                "replacement": "9",
5671                "applicability": "safe"
5672            }
5673        });
5674        let core_candidate = build_fix_candidates_from_issue_values(sql, &[core_issue])[0].clone();
5675        let rewrite_candidate = FixCandidate {
5676            start: one_idx,
5677            end: one_idx + 1,
5678            replacement: "2".to_string(),
5679            applicability: FixCandidateApplicability::Safe,
5680            source: FixCandidateSource::PrimaryRewrite,
5681            rule_code: None,
5682        };
5683
5684        let left_first = plan_fix_candidates(
5685            sql,
5686            vec![rewrite_candidate.clone(), core_candidate.clone()],
5687            &[],
5688            false,
5689        );
5690        let right_first =
5691            plan_fix_candidates(sql, vec![core_candidate, rewrite_candidate], &[], false);
5692
5693        let left_sql = apply_planned_edits(sql, &left_first.edits);
5694        let right_sql = apply_planned_edits(sql, &right_first.edits);
5695        assert_eq!(left_sql, "SELECT 9");
5696        assert_eq!(left_sql, right_sql);
5697        assert_eq!(left_first.skipped.overlap_conflict_blocked, 1);
5698        assert_eq!(right_first.skipped.overlap_conflict_blocked, 1);
5699    }
5700
5701    #[test]
5702    fn rewrite_mode_falls_back_to_core_plan_when_core_rule_is_not_improved() {
5703        // Consistent mode normalizes to whichever style appears first.
5704        // `<>` is first, so the fix normalizes `!=` to `<>`.
5705        let sql = "SELECT * FROM t WHERE a <> b AND c != d";
5706        let out = apply_lint_fixes_with_options(
5707            sql,
5708            Dialect::Generic,
5709            &default_lint_config(),
5710            FixOptions {
5711                include_unsafe_fixes: true,
5712                include_rewrite_candidates: true,
5713            },
5714        )
5715        .expect("fix result");
5716
5717        assert_eq!(fix_count_for_code(&out.counts, issue_codes::LINT_CV_001), 1);
5718        assert!(
5719            out.sql.contains("a <> b"),
5720            "expected CV001 style fix: {}",
5721            out.sql
5722        );
5723        assert!(
5724            out.sql.contains("c <> d"),
5725            "expected CV001 style fix: {}",
5726            out.sql
5727        );
5728        assert!(
5729            !out.sql.contains("!="),
5730            "expected no bang-style operator: {}",
5731            out.sql
5732        );
5733    }
5734
5735    #[test]
5736    fn core_autofix_applicability_is_mapped_to_existing_planner_logic() {
5737        let sql = "SELECT 1";
5738        let one_idx = sql.rfind('1').expect("digit exists");
5739        let issues = vec![
5740            serde_json::json!({
5741                "code": issue_codes::LINT_ST_005,
5742                "autofix": {
5743                    "start": one_idx,
5744                    "end": one_idx + 1,
5745                    "replacement": "2",
5746                    "applicability": "unsafe"
5747                }
5748            }),
5749            serde_json::json!({
5750                "code": issue_codes::LINT_ST_005,
5751                "autofix": {
5752                    "start": one_idx,
5753                    "end": one_idx + 1,
5754                    "replacement": "3",
5755                    "applicability": "display_only"
5756                }
5757            }),
5758        ];
5759        let candidates = build_fix_candidates_from_issue_values(sql, &issues);
5760
5761        assert_eq!(
5762            candidates[0].applicability,
5763            FixCandidateApplicability::Unsafe
5764        );
5765        assert_eq!(
5766            candidates[1].applicability,
5767            FixCandidateApplicability::DisplayOnly
5768        );
5769
5770        let planned_safe = plan_fix_candidates(sql, candidates.clone(), &[], false);
5771        assert_eq!(apply_planned_edits(sql, &planned_safe.edits), sql);
5772        assert_eq!(planned_safe.skipped.unsafe_skipped, 1);
5773        assert_eq!(planned_safe.skipped.display_only, 1);
5774
5775        let planned_unsafe = plan_fix_candidates(sql, candidates, &[], true);
5776        assert_eq!(apply_planned_edits(sql, &planned_unsafe.edits), "SELECT 2");
5777        assert_eq!(planned_unsafe.skipped.display_only, 1);
5778    }
5779
5780    #[test]
5781    fn planner_tracks_unsafe_and_display_only_skips() {
5782        let sql = "SELECT 1";
5783        let one_idx = sql.rfind('1').expect("digit exists");
5784        let planned = plan_fix_candidates(
5785            sql,
5786            vec![
5787                FixCandidate {
5788                    start: one_idx,
5789                    end: one_idx + 1,
5790                    replacement: "2".to_string(),
5791                    applicability: FixCandidateApplicability::Unsafe,
5792                    source: FixCandidateSource::UnsafeFallback,
5793                    rule_code: None,
5794                },
5795                FixCandidate {
5796                    start: 0,
5797                    end: 0,
5798                    replacement: String::new(),
5799                    applicability: FixCandidateApplicability::DisplayOnly,
5800                    source: FixCandidateSource::DisplayHint,
5801                    rule_code: None,
5802                },
5803            ],
5804            &[],
5805            false,
5806        );
5807        let applied = apply_planned_edits(sql, &planned.edits);
5808        assert_eq!(applied, sql);
5809        assert_eq!(planned.skipped.unsafe_skipped, 1);
5810        assert_eq!(planned.skipped.display_only, 1);
5811    }
5812
5813    #[test]
5814    fn does_not_collapse_independent_select_statements() {
5815        let sql = "SELECT 1; SELECT 2;";
5816        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5817        assert!(
5818            !out.sql.to_ascii_uppercase().contains("DISTINCT SELECT"),
5819            "auto-fix must preserve statement boundaries: {}",
5820            out.sql
5821        );
5822        let parsed = parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("parse fixed sql");
5823        assert_eq!(
5824            parsed.len(),
5825            2,
5826            "auto-fix should preserve two independent statements"
5827        );
5828    }
5829
5830    #[test]
5831    fn subquery_to_cte_text_fix_applies() {
5832        let fixed = fix_subquery_to_cte("SELECT * FROM (SELECT 1) sub");
5833        assert_eq!(fixed, "WITH sub AS (SELECT 1) SELECT * FROM sub");
5834    }
5835
5836    #[test]
5837    fn st005_core_autofix_applies_in_unsafe_mode_with_from_config() {
5838        let sql = "SELECT * FROM (SELECT 1) sub";
5839        let lint_config = LintConfig {
5840            enabled: true,
5841            disabled_rules: vec![],
5842            rule_configs: std::collections::BTreeMap::from([(
5843                "structure.subquery".to_string(),
5844                serde_json::json!({"forbid_subquery_in": "from"}),
5845            )]),
5846        };
5847
5848        let fixed = apply_lint_fixes_with_options(
5849            sql,
5850            Dialect::Generic,
5851            &lint_config,
5852            FixOptions {
5853                include_unsafe_fixes: true,
5854                include_rewrite_candidates: false,
5855            },
5856        )
5857        .expect("fix result")
5858        .sql;
5859        assert!(
5860            fixed.to_ascii_uppercase().contains("WITH SUB AS"),
5861            "expected unsafe core ST005 autofix to rewrite to CTE, got: {fixed}"
5862        );
5863    }
5864
5865    #[test]
5866    fn subquery_to_cte_text_fix_handles_nested_parentheses() {
5867        let fixed = fix_subquery_to_cte("SELECT * FROM (SELECT COUNT(*) FROM t) sub");
5868        assert_eq!(
5869            fixed,
5870            "WITH sub AS (SELECT COUNT(*) FROM t) SELECT * FROM sub"
5871        );
5872        parse_sql_with_dialect(&fixed, Dialect::Generic).expect("fixed SQL should parse");
5873    }
5874
5875    #[test]
5876    fn st005_ast_fix_rewrites_simple_join_derived_subquery_to_cte() {
5877        let lint_config = LintConfig {
5878            enabled: true,
5879            disabled_rules: vec![issue_codes::LINT_AM_005.to_string()],
5880            rule_configs: std::collections::BTreeMap::new(),
5881        };
5882        let sql = "SELECT t.id FROM t JOIN (SELECT id FROM u) sub ON t.id = sub.id";
5883        assert_rule_case_with_config(sql, issue_codes::LINT_ST_005, 1, 0, 1, &lint_config);
5884
5885        let out = apply_fix_with_config(sql, &lint_config);
5886        assert!(
5887            out.sql.to_ascii_uppercase().contains("WITH SUB AS"),
5888            "expected AST ST_005 rewrite to emit CTE: {}",
5889            out.sql
5890        );
5891    }
5892
5893    #[test]
5894    fn st005_ast_fix_rewrites_simple_from_derived_subquery_with_config() {
5895        let lint_config = LintConfig {
5896            enabled: true,
5897            disabled_rules: vec![],
5898            rule_configs: std::collections::BTreeMap::from([(
5899                "structure.subquery".to_string(),
5900                serde_json::json!({"forbid_subquery_in": "from"}),
5901            )]),
5902        };
5903        let sql = "SELECT sub.id FROM (SELECT id FROM u) sub";
5904        assert_rule_case_with_config(sql, issue_codes::LINT_ST_005, 1, 0, 1, &lint_config);
5905
5906        let out = apply_fix_with_config(sql, &lint_config);
5907        assert!(
5908            out.sql.to_ascii_uppercase().contains("WITH SUB AS"),
5909            "expected FROM-derived ST_005 rewrite to emit CTE: {}",
5910            out.sql
5911        );
5912    }
5913
5914    #[test]
5915    fn consecutive_semicolon_fix_ignores_string_literal_content() {
5916        let sql = "SELECT 'a;;b' AS txt;;";
5917        let out = apply_lint_fixes_with_options(
5918            sql,
5919            Dialect::Generic,
5920            &default_lint_config(),
5921            FixOptions {
5922                include_unsafe_fixes: true,
5923                include_rewrite_candidates: false,
5924            },
5925        )
5926        .expect("fix result");
5927        assert!(
5928            out.sql.contains("'a;;b'"),
5929            "string literal content must be preserved: {}",
5930            out.sql
5931        );
5932        assert!(
5933            out.sql.trim_end().ends_with(';') && !out.sql.trim_end().ends_with(";;"),
5934            "trailing semicolon run should be collapsed to one terminator: {}",
5935            out.sql
5936        );
5937    }
5938
5939    #[test]
5940    fn consecutive_semicolon_fix_collapses_whitespace_separated_runs() {
5941        let out = apply_lint_fixes_with_options(
5942            "SELECT 1;\n \t ;",
5943            Dialect::Generic,
5944            &default_lint_config(),
5945            FixOptions {
5946                include_unsafe_fixes: true,
5947                include_rewrite_candidates: false,
5948            },
5949        )
5950        .expect("fix result");
5951        assert_eq!(out.sql.matches(';').count(), 1);
5952    }
5953
5954    #[test]
5955    fn lint_fix_subquery_with_function_call_is_parseable() {
5956        let sql = "SELECT * FROM (SELECT COUNT(*) FROM t) sub";
5957        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5958        assert!(
5959            !out.skipped_due_to_regression,
5960            "function-call subquery rewrite should not be treated as regression: {}",
5961            out.sql
5962        );
5963        parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("fixed SQL should parse");
5964    }
5965
5966    #[test]
5967    fn distinct_parentheses_fix_preserves_valid_sql() {
5968        let sql = "SELECT DISTINCT(a) FROM t";
5969        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
5970        assert!(
5971            !out.sql.contains("a)"),
5972            "unexpected dangling parenthesis after fix: {}",
5973            out.sql
5974        );
5975        parse_sql_with_dialect(&out.sql, Dialect::Generic).expect("fixed SQL should parse");
5976    }
5977
5978    #[test]
5979    fn not_equal_fix_does_not_rewrite_string_literals() {
5980        let sql = "SELECT '<>' AS x, a<>b, c!=d FROM t";
5981        let out = apply_lint_fixes_with_options(
5982            sql,
5983            Dialect::Generic,
5984            &default_lint_config(),
5985            FixOptions {
5986                include_unsafe_fixes: false,
5987                include_rewrite_candidates: false,
5988            },
5989        )
5990        .expect("fix result");
5991        assert!(
5992            out.sql.contains("'<>'"),
5993            "string literal should remain unchanged: {}",
5994            out.sql
5995        );
5996        let compact: String = out.sql.chars().filter(|ch| !ch.is_whitespace()).collect();
5997        let has_c_style = compact.contains("a!=b") && compact.contains("c!=d");
5998        let has_ansi_style = compact.contains("a<>b") && compact.contains("c<>d");
5999        assert!(
6000            has_c_style || has_ansi_style || compact.contains("a<>b") && compact.contains("c!=d"),
6001            "operator usage outside string literals should remain intact: {}",
6002            out.sql
6003        );
6004    }
6005
6006    #[test]
6007    fn spacing_fixes_do_not_rewrite_single_quoted_literals() {
6008        let operator_fixed = apply_lint_fixes_with_options(
6009            "SELECT payload->>'id', 'x=y' FROM t",
6010            Dialect::Generic,
6011            &default_lint_config(),
6012            FixOptions {
6013                include_unsafe_fixes: false,
6014                include_rewrite_candidates: false,
6015            },
6016        )
6017        .expect("operator spacing fix result")
6018        .sql;
6019        assert!(
6020            operator_fixed.contains("'x=y'"),
6021            "operator spacing must not mutate literals: {operator_fixed}"
6022        );
6023        assert!(
6024            operator_fixed.contains("payload ->>"),
6025            "operator spacing should still apply: {operator_fixed}"
6026        );
6027
6028        let comma_fixed = apply_lint_fixes_with_options(
6029            "SELECT a,b, 'x,y' FROM t",
6030            Dialect::Generic,
6031            &default_lint_config(),
6032            FixOptions {
6033                include_unsafe_fixes: false,
6034                include_rewrite_candidates: false,
6035            },
6036        )
6037        .expect("comma spacing fix result")
6038        .sql;
6039        assert!(
6040            comma_fixed.contains("'x,y'"),
6041            "comma spacing must not mutate literals: {comma_fixed}"
6042        );
6043        assert!(
6044            !comma_fixed.contains("a,b"),
6045            "comma spacing should still apply: {comma_fixed}"
6046        );
6047    }
6048
6049    #[test]
6050    fn keyword_newline_fix_does_not_rewrite_literals_or_quoted_identifiers() {
6051        let sql = "SELECT COUNT(1), 'hello FROM world', \"x WHERE y\" FROM t WHERE a = 1";
6052        let fixed = apply_lint_fixes(sql, Dialect::Generic, &[])
6053            .expect("fix result")
6054            .sql;
6055        assert!(
6056            fixed.contains("'hello FROM world'"),
6057            "single-quoted literal should remain unchanged: {fixed}"
6058        );
6059        assert!(
6060            fixed.contains("\"x WHERE y\""),
6061            "double-quoted identifier should remain unchanged: {fixed}"
6062        );
6063        assert!(
6064            !fixed.contains("hello\nFROM world"),
6065            "keyword newline fix must not inject newlines into literals: {fixed}"
6066        );
6067    }
6068
6069    #[test]
6070    fn cp04_fix_reduces_literal_capitalisation_violations() {
6071        // Per-identifier: true and False both violate upper → 2 violations, 2 fixes.
6072        assert_rule_case(
6073            "SELECT NULL, true, False FROM t",
6074            issue_codes::LINT_CP_004,
6075            2,
6076            0,
6077            2,
6078        );
6079    }
6080
6081    #[test]
6082    fn cp05_fix_reduces_type_capitalisation_violations() {
6083        // Per-identifier: VarChar violates upper (INT is already correct) → 1 violation.
6084        assert_rule_case(
6085            "CREATE TABLE t (a INT, b VarChar(10));",
6086            issue_codes::LINT_CP_005,
6087            1,
6088            0,
6089            1,
6090        );
6091    }
6092
6093    #[test]
6094    fn cv06_fix_adds_missing_final_terminator() {
6095        assert_rule_case("SELECT 1 ;", issue_codes::LINT_CV_006, 1, 0, 1);
6096    }
6097
6098    #[test]
6099    fn lt03_fix_moves_trailing_operator_to_leading_position() {
6100        assert_rule_case("SELECT a +\n b FROM t", issue_codes::LINT_LT_003, 1, 0, 1);
6101    }
6102
6103    #[test]
6104    fn lt04_fix_moves_comma_around_templated_columns_in_ansi() {
6105        let leading_sql = "SELECT\n    c1,\n    {{ \"c2\" }} AS days_since\nFROM logs";
6106        let leading_config = lint_config_keep_only_rule(
6107            issue_codes::LINT_LT_004,
6108            LintConfig {
6109                enabled: true,
6110                disabled_rules: vec![],
6111                rule_configs: std::collections::BTreeMap::from([(
6112                    "layout.commas".to_string(),
6113                    serde_json::json!({"line_position": "leading"}),
6114                )]),
6115            },
6116        );
6117        let leading_issues = lint_issues(leading_sql, Dialect::Ansi, &leading_config);
6118        let leading_lt04 = leading_issues
6119            .iter()
6120            .find(|issue| issue.code == issue_codes::LINT_LT_004)
6121            .expect("expected LT04 issue before fix");
6122        assert!(
6123            leading_lt04.autofix.is_some(),
6124            "expected LT04 issue to carry autofix metadata in fix pipeline"
6125        );
6126        let leading_out = apply_lint_fixes_with_options(
6127            leading_sql,
6128            Dialect::Ansi,
6129            &leading_config,
6130            FixOptions {
6131                include_unsafe_fixes: true,
6132                include_rewrite_candidates: false,
6133            },
6134        )
6135        .expect("fix result");
6136        assert!(
6137            !leading_out.skipped_due_to_regression,
6138            "LT04 leading templated fix should not be treated as regression"
6139        );
6140        assert_eq!(
6141            leading_out.sql,
6142            "SELECT\n    c1\n    , {{ \"c2\" }} AS days_since\nFROM logs"
6143        );
6144
6145        let trailing_sql = "SELECT\n    {{ \"c1\" }}\n    , c2 AS days_since\nFROM logs";
6146        let trailing_config =
6147            lint_config_keep_only_rule(issue_codes::LINT_LT_004, default_lint_config());
6148        let trailing_out = apply_lint_fixes_with_options(
6149            trailing_sql,
6150            Dialect::Ansi,
6151            &trailing_config,
6152            FixOptions {
6153                include_unsafe_fixes: true,
6154                include_rewrite_candidates: false,
6155            },
6156        )
6157        .expect("fix result");
6158        assert!(
6159            !trailing_out.skipped_due_to_regression,
6160            "LT04 trailing templated fix should not be treated as regression"
6161        );
6162        assert_eq!(
6163            trailing_out.sql,
6164            "SELECT\n    {{ \"c1\" }},\n    c2 AS days_since\nFROM logs"
6165        );
6166    }
6167    #[test]
6168    fn rf004_core_autofix_respects_rule_filter() {
6169        let sql = "select a from users as select\n";
6170
6171        let out_rf_disabled = apply_lint_fixes(
6172            sql,
6173            Dialect::Generic,
6174            &[issue_codes::LINT_RF_004.to_string()],
6175        )
6176        .expect("fix result");
6177        assert_eq!(
6178            out_rf_disabled.sql, sql,
6179            "excluding RF_004 should block alias-keyword core autofix"
6180        );
6181
6182        let out_al_disabled = apply_lint_fixes(
6183            sql,
6184            Dialect::Generic,
6185            &[issue_codes::LINT_AL_005.to_string()],
6186        )
6187        .expect("fix result");
6188        assert!(
6189            out_al_disabled.sql.contains("alias_select"),
6190            "excluding AL_005 must not block RF_004 core autofix: {}",
6191            out_al_disabled.sql
6192        );
6193    }
6194
6195    #[test]
6196    fn rf003_core_autofix_respects_rule_filter() {
6197        let sql = "select a.id, id2 from a\n";
6198
6199        let rf_disabled_config = LintConfig {
6200            enabled: true,
6201            disabled_rules: vec![issue_codes::LINT_RF_003.to_string()],
6202            rule_configs: std::collections::BTreeMap::new(),
6203        };
6204        let out_rf_disabled = apply_lint_fixes_with_options(
6205            sql,
6206            Dialect::Generic,
6207            &rf_disabled_config,
6208            FixOptions {
6209                include_unsafe_fixes: true,
6210                include_rewrite_candidates: false,
6211            },
6212        )
6213        .expect("fix result");
6214        assert!(
6215            !out_rf_disabled.sql.contains("a.id2"),
6216            "excluding RF_003 should block reference qualification core autofix: {}",
6217            out_rf_disabled.sql
6218        );
6219
6220        let al_disabled_config = LintConfig {
6221            enabled: true,
6222            disabled_rules: vec![issue_codes::LINT_AL_005.to_string()],
6223            rule_configs: std::collections::BTreeMap::new(),
6224        };
6225        let out_al_disabled = apply_lint_fixes_with_options(
6226            sql,
6227            Dialect::Generic,
6228            &al_disabled_config,
6229            FixOptions {
6230                include_unsafe_fixes: true,
6231                include_rewrite_candidates: false,
6232            },
6233        )
6234        .expect("fix result");
6235        assert!(
6236            out_al_disabled.sql.contains("a.id2"),
6237            "excluding AL_005 must not block RF_003 core autofix: {}",
6238            out_al_disabled.sql
6239        );
6240    }
6241
6242    #[test]
6243    fn al001_fix_still_improves_with_fix_mode() {
6244        let sql = "SELECT * FROM a x JOIN b y ON x.id = y.id";
6245        assert_rule_case(sql, issue_codes::LINT_AL_001, 2, 0, 2);
6246
6247        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6248        let upper = out.sql.to_ascii_uppercase();
6249        assert!(
6250            upper.contains("FROM A AS X"),
6251            "expected explicit alias in fixed SQL, got: {}",
6252            out.sql
6253        );
6254        assert!(
6255            upper.contains("JOIN B AS Y"),
6256            "expected explicit alias in fixed SQL, got: {}",
6257            out.sql
6258        );
6259    }
6260
6261    #[test]
6262    fn al001_fix_does_not_synthesize_missing_aliases() {
6263        let sql = "SELECT COUNT(1) FROM users";
6264        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6265
6266        assert!(
6267            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6268            "expected non-AL001 fix to apply: {}",
6269            out.sql
6270        );
6271        assert!(
6272            !out.sql.to_ascii_uppercase().contains(" AS T"),
6273            "AL001 fixer must not generate synthetic aliases: {}",
6274            out.sql
6275        );
6276    }
6277
6278    #[test]
6279    fn al001_disabled_preserves_implicit_aliases_when_other_rules_fix() {
6280        let sql = "select count(1) from a x join b y on x.id = y.id";
6281        let out = apply_lint_fixes(
6282            sql,
6283            Dialect::Generic,
6284            &[issue_codes::LINT_AL_001.to_string()],
6285        )
6286        .expect("fix result");
6287
6288        assert!(
6289            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6290            "expected non-AL001 fix to apply: {}",
6291            out.sql
6292        );
6293        assert!(
6294            out.sql.to_ascii_uppercase().contains("FROM A X"),
6295            "implicit alias should be preserved when AL001 is disabled: {}",
6296            out.sql
6297        );
6298        assert!(
6299            out.sql.to_ascii_uppercase().contains("JOIN B Y"),
6300            "implicit alias should be preserved when AL001 is disabled: {}",
6301            out.sql
6302        );
6303        assert!(
6304            lint_rule_count(&out.sql, issue_codes::LINT_AL_001) > 0,
6305            "AL001 violations should remain when the rule is disabled: {}",
6306            out.sql
6307        );
6308    }
6309
6310    #[test]
6311    fn al001_implicit_config_rewrites_explicit_aliases() {
6312        let lint_config = LintConfig {
6313            enabled: true,
6314            disabled_rules: vec![],
6315            rule_configs: std::collections::BTreeMap::from([(
6316                issue_codes::LINT_AL_001.to_string(),
6317                serde_json::json!({"aliasing": "implicit"}),
6318            )]),
6319        };
6320
6321        let sql = "SELECT COUNT(1) FROM a AS x JOIN b AS y ON x.id = y.id";
6322        assert_eq!(
6323            lint_rule_count_with_config(sql, issue_codes::LINT_AL_001, &lint_config),
6324            2,
6325            "explicit aliases should violate AL001 under implicit mode"
6326        );
6327
6328        let out = apply_fix_with_config(sql, &lint_config);
6329        assert!(
6330            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6331            "expected non-AL001 fix to apply: {}",
6332            out.sql
6333        );
6334        assert!(
6335            !out.sql.to_ascii_uppercase().contains(" AS X"),
6336            "implicit-mode AL001 should remove explicit aliases: {}",
6337            out.sql
6338        );
6339        assert!(
6340            !out.sql.to_ascii_uppercase().contains(" AS Y"),
6341            "implicit-mode AL001 should remove explicit aliases: {}",
6342            out.sql
6343        );
6344        assert_eq!(
6345            lint_rule_count_with_config(&out.sql, issue_codes::LINT_AL_001, &lint_config),
6346            0,
6347            "AL001 should be resolved under implicit mode: {}",
6348            out.sql
6349        );
6350    }
6351
6352    #[test]
6353    fn table_alias_occurrences_handles_with_insert_select_aliases() {
6354        let sql = r#"
6355WITH params AS (
6356    SELECT now() - interval '1 day' AS period_start, now() AS period_end
6357),
6358overall AS (
6359    SELECT route, nav_type, mark FROM metrics.page_performance
6360),
6361device_breakdown AS (
6362    SELECT route, nav_type, mark FROM (
6363        SELECT route, nav_type, mark FROM metrics.page_performance
6364    ) sub
6365),
6366network_breakdown AS (
6367    SELECT route, nav_type, mark FROM (
6368        SELECT route, nav_type, mark FROM metrics.page_performance
6369    ) sub
6370),
6371version_breakdown AS (
6372    SELECT route, nav_type, mark FROM (
6373        SELECT route, nav_type, mark FROM metrics.page_performance
6374    ) sub
6375)
6376INSERT INTO metrics.page_performance_summary (route, period_start, period_end, nav_type, mark)
6377SELECT o.route, p.period_start, p.period_end, o.nav_type, o.mark
6378FROM overall o
6379CROSS JOIN params p
6380LEFT JOIN device_breakdown d ON d.route = o.route
6381LEFT JOIN network_breakdown n ON n.route = o.route
6382LEFT JOIN version_breakdown v ON v.route = o.route
6383ON CONFLICT (route, period_start, nav_type, mark) DO UPDATE SET
6384    period_end = EXCLUDED.period_end;
6385"#;
6386
6387        let occurrences = table_alias_occurrences(sql, Dialect::Postgres)
6388            .expect("alias occurrences should parse");
6389        let implicit_count = occurrences
6390            .iter()
6391            .filter(|alias| !alias.explicit_as)
6392            .count();
6393        assert!(
6394            implicit_count >= 8,
6395            "expected implicit aliases in CTE+INSERT query, got {}: {:?}",
6396            implicit_count,
6397            occurrences
6398                .iter()
6399                .map(|alias| (&alias.alias_key, alias.explicit_as))
6400                .collect::<Vec<_>>()
6401        );
6402    }
6403
6404    #[test]
6405    fn excluded_rule_is_not_rewritten_when_other_rules_are_fixed() {
6406        let sql = "SELECT COUNT(1) FROM t WHERE a<>b";
6407        let disabled = vec![issue_codes::LINT_CV_001.to_string()];
6408        let out = apply_lint_fixes(sql, Dialect::Generic, &disabled).expect("fix result");
6409        assert!(
6410            out.sql.to_ascii_uppercase().contains("COUNT(*)"),
6411            "expected COUNT style fix: {}",
6412            out.sql
6413        );
6414        assert!(
6415            out.sql.contains("<>"),
6416            "excluded CV_005 should remain '<>' (not '!='): {}",
6417            out.sql
6418        );
6419        assert!(
6420            !out.sql.contains("!="),
6421            "excluded CV_005 should not be rewritten to '!=': {}",
6422            out.sql
6423        );
6424    }
6425
6426    #[test]
6427    fn references_quoting_fix_keeps_reserved_identifier_quotes() {
6428        let sql = "SELECT \"FROM\" FROM t UNION SELECT 2";
6429        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6430        assert!(
6431            out.sql.contains("\"FROM\""),
6432            "reserved identifier must remain quoted: {}",
6433            out.sql
6434        );
6435    }
6436
6437    #[test]
6438    fn references_quoting_fix_unquotes_case_insensitive_dialect() {
6439        // In a case-insensitive dialect (Generic), mixed-case quoted identifiers
6440        // are unnecessarily quoted because case doesn't matter.
6441        let sql = "SELECT \"CamelCase\" FROM t UNION SELECT 2";
6442        let out = apply_lint_fixes(
6443            sql,
6444            Dialect::Generic,
6445            &[issue_codes::LINT_LT_011.to_string()],
6446        )
6447        .expect("fix result");
6448        assert!(
6449            out.sql.contains("CamelCase") && !out.sql.contains("\"CamelCase\""),
6450            "case-insensitive dialect should unquote: {}",
6451            out.sql
6452        );
6453        assert!(
6454            out.sql.to_ascii_uppercase().contains("DISTINCT SELECT"),
6455            "expected another fix to persist output: {}",
6456            out.sql
6457        );
6458    }
6459
6460    #[test]
6461    fn references_quoting_fix_keeps_case_sensitive_identifier_quotes() {
6462        // In Postgres (lowercase casefold), mixed-case identifiers must stay
6463        // quoted because unquoting would fold to lowercase.
6464        let sql = "SELECT \"CamelCase\" FROM t UNION SELECT 2";
6465        let out = apply_lint_fixes(
6466            sql,
6467            Dialect::Postgres,
6468            &[issue_codes::LINT_LT_011.to_string()],
6469        )
6470        .expect("fix result");
6471        assert!(
6472            out.sql.contains("\"CamelCase\""),
6473            "case-sensitive identifier must remain quoted: {}",
6474            out.sql
6475        );
6476    }
6477
6478    #[test]
6479    fn sqlfluff_fix_rule_smoke_cases_reduce_target_violations() {
6480        let cases = vec![
6481            (
6482                issue_codes::LINT_AL_001,
6483                "SELECT * FROM a x JOIN b y ON x.id = y.id",
6484            ),
6485            (
6486                issue_codes::LINT_AL_005,
6487                "SELECT u.name FROM users u JOIN orders o ON users.id = orders.user_id",
6488            ),
6489            (issue_codes::LINT_AL_009, "SELECT a AS a FROM t"),
6490            (issue_codes::LINT_AM_002, "SELECT 1 UNION SELECT 2"),
6491            (
6492                issue_codes::LINT_AM_003,
6493                "SELECT * FROM t ORDER BY a, b DESC",
6494            ),
6495            (
6496                issue_codes::LINT_AM_005,
6497                "SELECT * FROM a JOIN b ON a.id = b.id",
6498            ),
6499            (
6500                issue_codes::LINT_AM_008,
6501                "SELECT foo.a, bar.b FROM foo INNER JOIN bar",
6502            ),
6503            (issue_codes::LINT_CP_001, "SELECT a from t"),
6504            (issue_codes::LINT_CP_002, "SELECT Col, col FROM t"),
6505            (issue_codes::LINT_CP_003, "SELECT COUNT(*), count(name) FROM t"),
6506            (issue_codes::LINT_CP_004, "SELECT NULL, true FROM t"),
6507            (
6508                issue_codes::LINT_CP_005,
6509                "CREATE TABLE t (a INT, b varchar(10))",
6510            ),
6511            (
6512                issue_codes::LINT_CV_001,
6513                "SELECT * FROM t WHERE a <> b AND c != d",
6514            ),
6515            (
6516                issue_codes::LINT_CV_002,
6517                "SELECT IFNULL(x, 'default') FROM t",
6518            ),
6519            (issue_codes::LINT_CV_003, "SELECT a, FROM t"),
6520            (issue_codes::LINT_CV_004, "SELECT COUNT(1) FROM t"),
6521            (issue_codes::LINT_CV_005, "SELECT * FROM t WHERE a = NULL"),
6522            (issue_codes::LINT_CV_006, "SELECT 1 ;"),
6523            (issue_codes::LINT_CV_007, "(SELECT 1)"),
6524            (
6525                issue_codes::LINT_CV_012,
6526                "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id",
6527            ),
6528            (issue_codes::LINT_JJ_001, "SELECT '{{foo}}' AS templated"),
6529            (issue_codes::LINT_LT_001, "SELECT payload->>'id' FROM t"),
6530            (issue_codes::LINT_LT_002, "SELECT a\n   , b\nFROM t"),
6531            (issue_codes::LINT_LT_003, "SELECT a +\n b FROM t"),
6532            (issue_codes::LINT_LT_004, "SELECT a,b FROM t"),
6533            (issue_codes::LINT_LT_006, "SELECT COUNT (1) FROM t"),
6534            (
6535                issue_codes::LINT_LT_007,
6536                "WITH cte AS (\n  SELECT 1) SELECT * FROM cte",
6537            ),
6538            (issue_codes::LINT_LT_009, "SELECT a,b,c,d,e FROM t"),
6539            (issue_codes::LINT_LT_010, "SELECT\nDISTINCT a\nFROM t"),
6540            (
6541                issue_codes::LINT_LT_011,
6542                "SELECT 1 UNION SELECT 2\nUNION SELECT 3",
6543            ),
6544            (issue_codes::LINT_LT_012, "SELECT 1\nFROM t"),
6545            (issue_codes::LINT_LT_013, "\n\nSELECT 1"),
6546            (issue_codes::LINT_LT_014, "SELECT a FROM t\nWHERE a=1"),
6547            (issue_codes::LINT_LT_015, "SELECT 1\n\n\nFROM t"),
6548            (issue_codes::LINT_RF_003, "SELECT a.id, id2 FROM a"),
6549            (issue_codes::LINT_RF_006, "SELECT \"good_name\" FROM t"),
6550            (
6551                issue_codes::LINT_ST_001,
6552                "SELECT CASE WHEN x > 1 THEN 'a' ELSE NULL END FROM t",
6553            ),
6554            (
6555                issue_codes::LINT_ST_004,
6556                "SELECT CASE WHEN species = 'Rat' THEN 'Squeak' ELSE CASE WHEN species = 'Dog' THEN 'Woof' END END FROM mytable",
6557            ),
6558            (
6559                issue_codes::LINT_ST_002,
6560                "SELECT CASE WHEN x > 0 THEN true ELSE false END FROM t",
6561            ),
6562            (
6563                issue_codes::LINT_ST_005,
6564                "SELECT * FROM t JOIN (SELECT * FROM u) sub ON t.id = sub.id",
6565            ),
6566            (issue_codes::LINT_ST_006, "SELECT a + 1, a FROM t"),
6567            (
6568                issue_codes::LINT_ST_007,
6569                "SELECT * FROM a JOIN b USING (id)",
6570            ),
6571            (issue_codes::LINT_ST_008, "SELECT DISTINCT(a) FROM t"),
6572            (
6573                issue_codes::LINT_ST_009,
6574                "SELECT * FROM a x JOIN b y ON y.id = x.id",
6575            ),
6576            (issue_codes::LINT_ST_012, "SELECT 1;;"),
6577        ];
6578
6579        for (code, sql) in cases {
6580            let before = lint_rule_count(sql, code);
6581            assert!(before > 0, "expected {code} to trigger before fix: {sql}");
6582            let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix result");
6583            assert!(
6584                !out.skipped_due_to_comments,
6585                "test SQL should not be skipped: {sql}"
6586            );
6587            let after = lint_rule_count(&out.sql, code);
6588            assert!(
6589                after < before || out.sql != sql,
6590                "expected {code} count to decrease or SQL to be rewritten. before={before} after={after}\ninput={sql}\noutput={}",
6591                out.sql
6592            );
6593        }
6594    }
6595
6596    // --- CV_012: implicit WHERE join → explicit ON ---
6597
6598    #[test]
6599    fn cv012_simple_where_join_to_on() {
6600        let sql = "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id";
6601        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6602        let lower = out.sql.to_ascii_lowercase();
6603        assert!(
6604            lower.contains(" on ") && !lower.contains("where"),
6605            "expected JOIN ON without WHERE: {}",
6606            out.sql
6607        );
6608    }
6609
6610    #[test]
6611    fn cv012_mixed_where_keeps_non_join_predicates() {
6612        let sql = "SELECT a.x FROM a JOIN b WHERE a.id = b.id AND a.val > 10";
6613        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6614        let lower = out.sql.to_ascii_lowercase();
6615        assert!(lower.contains(" on "), "expected JOIN ON: {}", out.sql);
6616        assert!(
6617            lower.contains("where"),
6618            "expected remaining WHERE: {}",
6619            out.sql
6620        );
6621    }
6622
6623    #[test]
6624    fn cv012_multi_join_chain() {
6625        let sql = "SELECT * FROM a JOIN b JOIN c WHERE a.id = b.id AND b.id = c.id";
6626        let out = apply_lint_fixes(
6627            sql,
6628            Dialect::Generic,
6629            &[issue_codes::LINT_AM_005.to_string()],
6630        )
6631        .expect("fix");
6632        let lower = out.sql.to_ascii_lowercase();
6633        // Both joins should get ON clauses.
6634        let on_count = lower.matches(" on ").count();
6635        assert!(on_count >= 2, "expected at least 2 ON clauses: {}", out.sql);
6636        assert!(
6637            !lower.contains("where"),
6638            "all predicates should be extracted: {}",
6639            out.sql
6640        );
6641    }
6642
6643    #[test]
6644    fn cv012_preserves_explicit_on() {
6645        let sql = "SELECT * FROM a JOIN b ON a.id = b.id";
6646        let out = apply_lint_fixes(sql, Dialect::Generic, &[]).expect("fix");
6647        assert_eq!(
6648            lint_rule_count(sql, issue_codes::LINT_CV_012),
6649            0,
6650            "explicit ON should not trigger CV_012"
6651        );
6652        let lower = out.sql.to_ascii_lowercase();
6653        assert!(
6654            lower.contains("on a.id = b.id"),
6655            "ON clause should be preserved: {}",
6656            out.sql
6657        );
6658    }
6659
6660    #[test]
6661    fn cv012_idempotent() {
6662        let sql = "SELECT a.x, b.y FROM a JOIN b WHERE a.id = b.id";
6663        let lint_config = LintConfig {
6664            enabled: true,
6665            disabled_rules: vec![issue_codes::LINT_LT_014.to_string()],
6666            rule_configs: std::collections::BTreeMap::new(),
6667        };
6668        let out1 = apply_lint_fixes_with_options(
6669            sql,
6670            Dialect::Generic,
6671            &lint_config,
6672            FixOptions {
6673                include_unsafe_fixes: true,
6674                include_rewrite_candidates: false,
6675            },
6676        )
6677        .expect("fix");
6678        let out2 = apply_lint_fixes_with_options(
6679            &out1.sql,
6680            Dialect::Generic,
6681            &lint_config,
6682            FixOptions {
6683                include_unsafe_fixes: true,
6684                include_rewrite_candidates: false,
6685            },
6686        )
6687        .expect("fix2");
6688        assert_eq!(
6689            out1.sql.trim_end(),
6690            out2.sql.trim_end(),
6691            "second pass should be idempotent aside from trailing-whitespace normalization"
6692        );
6693    }
6694}