Skip to main content

flowscope_core/linter/rules/
rf_003.rs

1//! LINT_RF_003: References consistency.
2//!
3//! In single-source queries, avoid mixing qualified and unqualified references.
4
5use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::{
9    Expr, FunctionArg, FunctionArgExpr, FunctionArguments, Select, SelectItem, Spanned, Statement,
10    TableFactor, WindowType,
11};
12use std::collections::HashSet;
13
14use super::semantic_helpers::{
15    select_projection_alias_set, select_source_count, visit_select_expressions,
16    visit_selects_in_statement,
17};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20enum SingleTableReferencesMode {
21    Consistent,
22    Qualified,
23    Unqualified,
24}
25
26impl SingleTableReferencesMode {
27    fn from_config(config: &LintConfig) -> Self {
28        match config
29            .rule_option_str(issue_codes::LINT_RF_003, "single_table_references")
30            .unwrap_or("consistent")
31            .to_ascii_lowercase()
32            .as_str()
33        {
34            "qualified" => Self::Qualified,
35            "unqualified" => Self::Unqualified,
36            _ => Self::Consistent,
37        }
38    }
39
40    fn violation(self, qualified: usize, unqualified: usize) -> bool {
41        match self {
42            Self::Consistent => qualified > 0 && unqualified > 0,
43            Self::Qualified => unqualified > 0,
44            Self::Unqualified => qualified > 0,
45        }
46    }
47}
48
49pub struct ReferencesConsistent {
50    single_table_references: SingleTableReferencesMode,
51    force_enable: bool,
52}
53
54#[derive(Clone)]
55struct Rf003SelectScope {
56    start: usize,
57    end: usize,
58    sources: HashSet<String>,
59}
60
61impl ReferencesConsistent {
62    pub fn from_config(config: &LintConfig) -> Self {
63        Self {
64            single_table_references: SingleTableReferencesMode::from_config(config),
65            force_enable: config
66                .rule_option_bool(issue_codes::LINT_RF_003, "force_enable")
67                .unwrap_or(true),
68        }
69    }
70}
71
72impl Default for ReferencesConsistent {
73    fn default() -> Self {
74        Self {
75            single_table_references: SingleTableReferencesMode::Consistent,
76            force_enable: true,
77        }
78    }
79}
80
81impl LintRule for ReferencesConsistent {
82    fn code(&self) -> &'static str {
83        issue_codes::LINT_RF_003
84    }
85
86    fn name(&self) -> &'static str {
87        "References consistent"
88    }
89
90    fn description(&self) -> &'static str {
91        "Column references should be qualified consistently in single table statements."
92    }
93
94    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
95        if !self.force_enable {
96            return Vec::new();
97        }
98
99        let mut select_scopes = Vec::new();
100        visit_selects_in_statement(statement, &mut |select| {
101            if let Some((start, end)) = select_span_offsets(ctx.sql, select) {
102                select_scopes.push(Rf003SelectScope {
103                    start,
104                    end,
105                    sources: select_source_names(select),
106                });
107            }
108        });
109
110        let mut mixed_count = 0usize;
111        let mut consistency_transition_count = 0usize;
112        let mut autofix_edits_raw: Vec<Rf003AutofixEdit> = Vec::new();
113
114        visit_selects_in_statement(statement, &mut |select| {
115            if select_source_count(select) != 1 {
116                return;
117            }
118            if select_contains_pivot(select) || select_contains_table_variable_source(select) {
119                return;
120            }
121
122            let aliases = select_projection_alias_set(select);
123            let source_names = select_source_names(select);
124            let ancestor_sources = ancestor_source_names_for_select(ctx, select, &select_scopes);
125            let (mut qualified, mut unqualified, has_outer_references) =
126                count_reference_qualification_for_select(
127                    select,
128                    &aliases,
129                    &source_names,
130                    &ancestor_sources,
131                    ctx.dialect(),
132                );
133            let (projection_qualified, projection_unqualified) =
134                projection_wildcard_qualification_counts(select);
135            qualified += projection_qualified;
136            unqualified += projection_unqualified;
137
138            // SQLFluff RF03 parity: correlated subqueries in unqualified mode
139            // should not be forced into local-table qualification checks.
140            if has_outer_references
141                && self.single_table_references == SingleTableReferencesMode::Unqualified
142            {
143                return;
144            }
145
146            if self
147                .single_table_references
148                .violation(qualified, unqualified)
149            {
150                mixed_count += 1;
151                if self.single_table_references == SingleTableReferencesMode::Consistent
152                    && qualified > 0
153                    && unqualified > 0
154                {
155                    // SQLFluff emits an additional "inconsistent with previous
156                    // references" issue per mixed single-source SELECT.
157                    consistency_transition_count += 1;
158                }
159
160                let target_style = match self.single_table_references {
161                    SingleTableReferencesMode::Consistent
162                    | SingleTableReferencesMode::Qualified => {
163                        Some(Rf003AutofixTargetStyle::Qualify)
164                    }
165                    SingleTableReferencesMode::Unqualified => {
166                        Some(Rf003AutofixTargetStyle::Unqualify)
167                    }
168                };
169
170                if let Some(target_style) = target_style {
171                    autofix_edits_raw.extend(rf003_autofix_edits_for_select(
172                        select,
173                        ctx,
174                        target_style,
175                        &aliases,
176                        &source_names,
177                        &ancestor_sources,
178                    ));
179                }
180            }
181        });
182
183        if mixed_count == 0 {
184            return Vec::new();
185        }
186
187        if autofix_edits_raw.is_empty()
188            && self.single_table_references == SingleTableReferencesMode::Unqualified
189        {
190            let sql = ctx.statement_sql();
191            if let Some((table_name, alias)) = extract_from_table_and_alias(sql) {
192                let prefix = if alias.is_empty() {
193                    table_name.rsplit('.').next().unwrap_or(&table_name)
194                } else {
195                    alias.as_str()
196                };
197                if !prefix.is_empty() {
198                    let rewritten = unqualify_prefix_in_sql_slice(sql, prefix);
199                    if rewritten != sql {
200                        autofix_edits_raw.push(Rf003AutofixEdit {
201                            start: 0,
202                            end: sql.len(),
203                            replacement: rewritten,
204                        });
205                    }
206                }
207            }
208        }
209        if autofix_edits_raw.is_empty() {
210            // Keep legacy text fallback for simple cases not covered by AST spans.
211            autofix_edits_raw.extend(mixed_reference_autofix_edits(ctx.statement_sql()));
212        }
213        autofix_edits_raw.sort_by_key(|edit| (edit.start, edit.end));
214        autofix_edits_raw.dedup_by_key(|edit| (edit.start, edit.end));
215
216        let autofix_edits: Vec<IssuePatchEdit> = autofix_edits_raw
217            .into_iter()
218            .map(|edit| {
219                IssuePatchEdit::new(
220                    ctx.span_from_statement_offset(edit.start, edit.end),
221                    edit.replacement,
222                )
223            })
224            .collect();
225
226        // Emit one issue per violating reference at its specific position
227        // (SQLFluff reports per-reference, not per-SELECT).
228        if !autofix_edits.is_empty() || consistency_transition_count > 0 {
229            let mut issues: Vec<Issue> = autofix_edits
230                .into_iter()
231                .map(|edit| {
232                    let span = Span::new(edit.span.start, edit.span.end);
233                    Issue::info(
234                        issue_codes::LINT_RF_003,
235                        "Avoid mixing qualified and unqualified references.",
236                    )
237                    .with_statement(ctx.statement_index)
238                    .with_span(span)
239                    .with_autofix_edits(IssueAutofixApplicability::Safe, vec![edit])
240                })
241                .collect();
242            issues.extend((0..consistency_transition_count).map(|_| {
243                Issue::info(
244                    issue_codes::LINT_RF_003,
245                    "Avoid mixing qualified and unqualified references.",
246                )
247                .with_statement(ctx.statement_index)
248            }));
249            return issues;
250        }
251
252        // Fallback: no per-reference edits available, emit per-SELECT issues.
253        (0..mixed_count)
254            .map(|_| {
255                Issue::info(
256                    issue_codes::LINT_RF_003,
257                    "Avoid mixing qualified and unqualified references.",
258                )
259                .with_statement(ctx.statement_index)
260            })
261            .collect()
262    }
263}
264
265fn ancestor_source_names_for_select(
266    ctx: &LintContext,
267    select: &Select,
268    scopes: &[Rf003SelectScope],
269) -> HashSet<String> {
270    let Some((start, end)) = select_span_offsets(ctx.sql, select) else {
271        return HashSet::new();
272    };
273
274    let mut out = HashSet::new();
275    for scope in scopes {
276        let strictly_contains =
277            scope.start <= start && scope.end >= end && (scope.start != start || scope.end != end);
278        if strictly_contains {
279            out.extend(scope.sources.iter().cloned());
280        }
281    }
282    out
283}
284
285struct Rf003AutofixEdit {
286    start: usize,
287    end: usize,
288    replacement: String,
289}
290
291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
292enum Rf003AutofixTargetStyle {
293    Qualify,
294    Unqualify,
295}
296
297#[derive(Clone, Copy, Debug, Eq, PartialEq)]
298enum Rf003ReferenceClass {
299    Unqualified,
300    LocalQualified,
301    ObjectPath,
302    Ignore,
303}
304
305fn rf003_autofix_edits_for_select(
306    select: &Select,
307    ctx: &LintContext,
308    target_style: Rf003AutofixTargetStyle,
309    aliases: &HashSet<String>,
310    local_sources: &HashSet<String>,
311    statement_sources: &HashSet<String>,
312) -> Vec<Rf003AutofixEdit> {
313    let Some(prefix) = preferred_qualification_prefix(select) else {
314        return Vec::new();
315    };
316    if prefix.is_empty() {
317        return Vec::new();
318    }
319
320    let statement_sql = ctx.statement_sql();
321    let mut edits = Vec::new();
322    visit_select_expressions(select, &mut |expr| {
323        collect_rf003_autofix_edits_in_expr(
324            expr,
325            ctx,
326            statement_sql,
327            target_style,
328            &prefix,
329            aliases,
330            local_sources,
331            statement_sources,
332            ctx.dialect(),
333            &mut edits,
334        );
335    });
336
337    if edits.is_empty() && target_style == Rf003AutofixTargetStyle::Unqualify {
338        if let Some((start, end)) = select_statement_offsets(ctx, select) {
339            if start < end && end <= statement_sql.len() {
340                let original = &statement_sql[start..end];
341                let rewritten = unqualify_prefix_in_sql_slice(original, &prefix);
342                if rewritten != original {
343                    edits.push(Rf003AutofixEdit {
344                        start,
345                        end,
346                        replacement: rewritten,
347                    });
348                }
349            }
350        }
351    }
352
353    edits
354}
355
356fn preferred_qualification_prefix(select: &Select) -> Option<String> {
357    let table = select.from.first()?;
358    match &table.relation {
359        TableFactor::Table { name, alias, .. } => {
360            if let Some(alias) = alias {
361                return Some(alias.name.value.clone());
362            }
363            let table_name = name.to_string();
364            let last = table_name.rsplit('.').next().unwrap_or(&table_name).trim();
365            (!last.is_empty()).then_some(last.to_string())
366        }
367        TableFactor::Derived { alias, .. }
368        | TableFactor::TableFunction { alias, .. }
369        | TableFactor::Function { alias, .. }
370        | TableFactor::UNNEST { alias, .. }
371        | TableFactor::JsonTable { alias, .. }
372        | TableFactor::OpenJsonTable { alias, .. }
373        | TableFactor::NestedJoin { alias, .. }
374        | TableFactor::Pivot { alias, .. }
375        | TableFactor::Unpivot { alias, .. }
376        | TableFactor::MatchRecognize { alias, .. } => alias.as_ref().map(|a| a.name.value.clone()),
377        _ => None,
378    }
379}
380
381#[allow(clippy::too_many_arguments)]
382fn collect_rf003_autofix_edits_in_expr(
383    expr: &Expr,
384    ctx: &LintContext,
385    statement_sql: &str,
386    target_style: Rf003AutofixTargetStyle,
387    prefix: &str,
388    aliases: &HashSet<String>,
389    local_sources: &HashSet<String>,
390    statement_sources: &HashSet<String>,
391    dialect: Dialect,
392    edits: &mut Vec<Rf003AutofixEdit>,
393) {
394    match expr {
395        Expr::Identifier(_) | Expr::CompoundIdentifier(_) => {
396            let class =
397                classify_rf003_reference(expr, aliases, local_sources, statement_sources, dialect);
398            let Some((start, end)) = expr_statement_offsets(ctx, expr) else {
399                return;
400            };
401            if start >= end || end > statement_sql.len() {
402                return;
403            }
404            let original = &statement_sql[start..end];
405
406            let replacement = match (target_style, class) {
407                (Rf003AutofixTargetStyle::Qualify, Rf003ReferenceClass::Unqualified)
408                | (Rf003AutofixTargetStyle::Qualify, Rf003ReferenceClass::ObjectPath) => {
409                    Some(format!("{prefix}.{original}"))
410                }
411                (Rf003AutofixTargetStyle::Unqualify, Rf003ReferenceClass::LocalQualified) => {
412                    original
413                        .find('.')
414                        .map(|dot| original[dot + 1..].to_string())
415                        .filter(|rest| !rest.is_empty())
416                }
417                _ => None,
418            };
419
420            if let Some(replacement) = replacement {
421                if replacement != original {
422                    edits.push(Rf003AutofixEdit {
423                        start,
424                        end,
425                        replacement,
426                    });
427                }
428            }
429        }
430        Expr::BinaryOp { left, right, .. }
431        | Expr::AnyOp { left, right, .. }
432        | Expr::AllOp { left, right, .. } => {
433            collect_rf003_autofix_edits_in_expr(
434                left,
435                ctx,
436                statement_sql,
437                target_style,
438                prefix,
439                aliases,
440                local_sources,
441                statement_sources,
442                dialect,
443                edits,
444            );
445            collect_rf003_autofix_edits_in_expr(
446                right,
447                ctx,
448                statement_sql,
449                target_style,
450                prefix,
451                aliases,
452                local_sources,
453                statement_sources,
454                dialect,
455                edits,
456            );
457        }
458        Expr::UnaryOp { expr: inner, .. }
459        | Expr::Nested(inner)
460        | Expr::IsNull(inner)
461        | Expr::IsNotNull(inner)
462        | Expr::Cast { expr: inner, .. } => collect_rf003_autofix_edits_in_expr(
463            inner,
464            ctx,
465            statement_sql,
466            target_style,
467            prefix,
468            aliases,
469            local_sources,
470            statement_sources,
471            dialect,
472            edits,
473        ),
474        Expr::InList { expr, list, .. } => {
475            collect_rf003_autofix_edits_in_expr(
476                expr,
477                ctx,
478                statement_sql,
479                target_style,
480                prefix,
481                aliases,
482                local_sources,
483                statement_sources,
484                dialect,
485                edits,
486            );
487            for item in list {
488                collect_rf003_autofix_edits_in_expr(
489                    item,
490                    ctx,
491                    statement_sql,
492                    target_style,
493                    prefix,
494                    aliases,
495                    local_sources,
496                    statement_sources,
497                    dialect,
498                    edits,
499                );
500            }
501        }
502        Expr::Between {
503            expr, low, high, ..
504        } => {
505            collect_rf003_autofix_edits_in_expr(
506                expr,
507                ctx,
508                statement_sql,
509                target_style,
510                prefix,
511                aliases,
512                local_sources,
513                statement_sources,
514                dialect,
515                edits,
516            );
517            collect_rf003_autofix_edits_in_expr(
518                low,
519                ctx,
520                statement_sql,
521                target_style,
522                prefix,
523                aliases,
524                local_sources,
525                statement_sources,
526                dialect,
527                edits,
528            );
529            collect_rf003_autofix_edits_in_expr(
530                high,
531                ctx,
532                statement_sql,
533                target_style,
534                prefix,
535                aliases,
536                local_sources,
537                statement_sources,
538                dialect,
539                edits,
540            );
541        }
542        Expr::Case {
543            operand,
544            conditions,
545            else_result,
546            ..
547        } => {
548            if let Some(operand) = operand {
549                collect_rf003_autofix_edits_in_expr(
550                    operand,
551                    ctx,
552                    statement_sql,
553                    target_style,
554                    prefix,
555                    aliases,
556                    local_sources,
557                    statement_sources,
558                    dialect,
559                    edits,
560                );
561            }
562            for when in conditions {
563                collect_rf003_autofix_edits_in_expr(
564                    &when.condition,
565                    ctx,
566                    statement_sql,
567                    target_style,
568                    prefix,
569                    aliases,
570                    local_sources,
571                    statement_sources,
572                    dialect,
573                    edits,
574                );
575                collect_rf003_autofix_edits_in_expr(
576                    &when.result,
577                    ctx,
578                    statement_sql,
579                    target_style,
580                    prefix,
581                    aliases,
582                    local_sources,
583                    statement_sources,
584                    dialect,
585                    edits,
586                );
587            }
588            if let Some(otherwise) = else_result {
589                collect_rf003_autofix_edits_in_expr(
590                    otherwise,
591                    ctx,
592                    statement_sql,
593                    target_style,
594                    prefix,
595                    aliases,
596                    local_sources,
597                    statement_sources,
598                    dialect,
599                    edits,
600                );
601            }
602        }
603        Expr::Function(function) => {
604            if let FunctionArguments::List(arguments) = &function.args {
605                for (index, arg) in arguments.args.iter().enumerate() {
606                    match arg {
607                        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
608                        | FunctionArg::Named {
609                            arg: FunctionArgExpr::Expr(expr),
610                            ..
611                        } => {
612                            if should_skip_identifier_reference_for_function_arg(
613                                function, index, expr,
614                            ) {
615                                continue;
616                            }
617                            collect_rf003_autofix_edits_in_expr(
618                                expr,
619                                ctx,
620                                statement_sql,
621                                target_style,
622                                prefix,
623                                aliases,
624                                local_sources,
625                                statement_sources,
626                                dialect,
627                                edits,
628                            );
629                        }
630                        _ => {}
631                    }
632                }
633            }
634            if let Some(filter) = &function.filter {
635                collect_rf003_autofix_edits_in_expr(
636                    filter,
637                    ctx,
638                    statement_sql,
639                    target_style,
640                    prefix,
641                    aliases,
642                    local_sources,
643                    statement_sources,
644                    dialect,
645                    edits,
646                );
647            }
648            for order_expr in &function.within_group {
649                collect_rf003_autofix_edits_in_expr(
650                    &order_expr.expr,
651                    ctx,
652                    statement_sql,
653                    target_style,
654                    prefix,
655                    aliases,
656                    local_sources,
657                    statement_sources,
658                    dialect,
659                    edits,
660                );
661            }
662            if let Some(WindowType::WindowSpec(spec)) = &function.over {
663                for expr in &spec.partition_by {
664                    collect_rf003_autofix_edits_in_expr(
665                        expr,
666                        ctx,
667                        statement_sql,
668                        target_style,
669                        prefix,
670                        aliases,
671                        local_sources,
672                        statement_sources,
673                        dialect,
674                        edits,
675                    );
676                }
677                for order_expr in &spec.order_by {
678                    collect_rf003_autofix_edits_in_expr(
679                        &order_expr.expr,
680                        ctx,
681                        statement_sql,
682                        target_style,
683                        prefix,
684                        aliases,
685                        local_sources,
686                        statement_sources,
687                        dialect,
688                        edits,
689                    );
690                }
691            }
692        }
693        Expr::InSubquery { expr, .. } => collect_rf003_autofix_edits_in_expr(
694            expr,
695            ctx,
696            statement_sql,
697            target_style,
698            prefix,
699            aliases,
700            local_sources,
701            statement_sources,
702            dialect,
703            edits,
704        ),
705        Expr::Exists { .. } | Expr::Subquery(_) => {}
706        _ => {}
707    }
708}
709
710fn classify_rf003_reference(
711    expr: &Expr,
712    aliases: &HashSet<String>,
713    local_sources: &HashSet<String>,
714    statement_sources: &HashSet<String>,
715    dialect: Dialect,
716) -> Rf003ReferenceClass {
717    match expr {
718        Expr::Identifier(identifier) => {
719            let name = identifier.value.to_ascii_uppercase();
720            if aliases.contains(&name) || identifier.value.starts_with('@') {
721                Rf003ReferenceClass::Ignore
722            } else {
723                Rf003ReferenceClass::Unqualified
724            }
725        }
726        Expr::CompoundIdentifier(parts) => {
727            if parts.is_empty() {
728                return Rf003ReferenceClass::Ignore;
729            }
730            let first = parts[0].value.to_ascii_uppercase();
731            if first.starts_with('@') {
732                return Rf003ReferenceClass::Ignore;
733            }
734            if parts.len() == 1 {
735                if aliases.contains(&first) {
736                    Rf003ReferenceClass::Ignore
737                } else {
738                    Rf003ReferenceClass::Unqualified
739                }
740            } else if local_sources.contains(&first) {
741                Rf003ReferenceClass::LocalQualified
742            } else if statement_sources.contains(&first) {
743                Rf003ReferenceClass::Ignore
744            } else if is_object_reference_dialect(dialect) {
745                Rf003ReferenceClass::ObjectPath
746            } else {
747                Rf003ReferenceClass::LocalQualified
748            }
749        }
750        _ => Rf003ReferenceClass::Ignore,
751    }
752}
753
754fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> {
755    // Statement ranges may intentionally trim leading comments/whitespace.
756    // SQLParser spans are often absolute to the full document, so prefer
757    // document-level conversion when the statement does not start at byte 0.
758    if ctx.statement_range.start > 0 {
759        if let Some((start, end)) = expr_span_offsets(ctx.sql, expr) {
760            if start >= ctx.statement_range.start && end <= ctx.statement_range.end {
761                return Some((
762                    start - ctx.statement_range.start,
763                    end - ctx.statement_range.start,
764                ));
765            }
766        }
767    }
768
769    if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) {
770        return Some((start, end));
771    }
772
773    let (start, end) = expr_span_offsets(ctx.sql, expr)?;
774    if start < ctx.statement_range.start || end > ctx.statement_range.end {
775        return None;
776    }
777
778    Some((
779        start - ctx.statement_range.start,
780        end - ctx.statement_range.start,
781    ))
782}
783
784fn select_statement_offsets(ctx: &LintContext, select: &Select) -> Option<(usize, usize)> {
785    // Statement ranges may intentionally trim leading comments/whitespace.
786    // SQLParser spans are often absolute to the full document, so prefer
787    // document-level conversion when the statement does not start at byte 0.
788    if ctx.statement_range.start > 0 {
789        if let Some((start, end)) = select_span_offsets(ctx.sql, select) {
790            if start >= ctx.statement_range.start && end <= ctx.statement_range.end {
791                return Some((
792                    start - ctx.statement_range.start,
793                    end - ctx.statement_range.start,
794                ));
795            }
796        }
797    }
798
799    if let Some((start, end)) = select_span_offsets(ctx.statement_sql(), select) {
800        return Some((start, end));
801    }
802
803    let (start, end) = select_span_offsets(ctx.sql, select)?;
804    if start < ctx.statement_range.start || end > ctx.statement_range.end {
805        return None;
806    }
807
808    Some((
809        start - ctx.statement_range.start,
810        end - ctx.statement_range.start,
811    ))
812}
813
814fn expr_span_offsets(sql: &str, expr: &Expr) -> Option<(usize, usize)> {
815    let span = expr.span();
816    if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
817    {
818        return None;
819    }
820
821    let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
822    let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
823    (end >= start).then_some((start, end))
824}
825
826fn select_span_offsets(sql: &str, select: &Select) -> Option<(usize, usize)> {
827    let span = select.span();
828    if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
829    {
830        return None;
831    }
832
833    let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
834    let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
835    (end >= start).then_some((start, end))
836}
837
838fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
839    if line == 0 || column == 0 {
840        return None;
841    }
842
843    let mut current_line = 1usize;
844    let mut line_start = 0usize;
845
846    for (idx, ch) in sql.char_indices() {
847        if current_line == line {
848            break;
849        }
850        if ch == '\n' {
851            current_line += 1;
852            line_start = idx + ch.len_utf8();
853        }
854    }
855    if current_line != line {
856        return None;
857    }
858
859    let mut current_column = 1usize;
860    for (rel_idx, ch) in sql[line_start..].char_indices() {
861        if current_column == column {
862            return Some(line_start + rel_idx);
863        }
864        if ch == '\n' {
865            return None;
866        }
867        current_column += 1;
868    }
869
870    if current_column == column {
871        return Some(sql.len());
872    }
873
874    None
875}
876
877fn unqualify_prefix_in_sql_slice(sql: &str, prefix: &str) -> String {
878    let bytes = sql.as_bytes();
879    let prefix_bytes = prefix.as_bytes();
880    let mut out = String::with_capacity(sql.len());
881    let mut i = 0usize;
882
883    #[derive(Clone, Copy, PartialEq, Eq)]
884    enum Mode {
885        Outside,
886        SingleQuote,
887        DoubleQuote,
888        BacktickQuote,
889        BracketQuote,
890        LineComment,
891        BlockComment,
892    }
893
894    let mut mode = Mode::Outside;
895
896    while i < bytes.len() {
897        let b = bytes[i];
898        let next = bytes.get(i + 1).copied();
899
900        match mode {
901            Mode::Outside => {
902                if b == b'-' && next == Some(b'-') {
903                    out.push('-');
904                    out.push('-');
905                    i += 2;
906                    mode = Mode::LineComment;
907                    continue;
908                }
909                if b == b'/' && next == Some(b'*') {
910                    out.push('/');
911                    out.push('*');
912                    i += 2;
913                    mode = Mode::BlockComment;
914                    continue;
915                }
916                if b == b'\'' {
917                    out.push('\'');
918                    i += 1;
919                    mode = Mode::SingleQuote;
920                    continue;
921                }
922                if b == b'"' {
923                    out.push('"');
924                    i += 1;
925                    mode = Mode::DoubleQuote;
926                    continue;
927                }
928                if b == b'`' {
929                    out.push('`');
930                    i += 1;
931                    mode = Mode::BacktickQuote;
932                    continue;
933                }
934                if b == b'[' {
935                    out.push('[');
936                    i += 1;
937                    mode = Mode::BracketQuote;
938                    continue;
939                }
940
941                if i + prefix_bytes.len() + 1 < bytes.len()
942                    && bytes[i..i + prefix_bytes.len()]
943                        .iter()
944                        .zip(prefix_bytes.iter())
945                        .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected))
946                    && (i == 0
947                        || !bytes[i - 1].is_ascii_alphanumeric()
948                            && bytes[i - 1] != b'_'
949                            && bytes[i - 1] != b'$')
950                    && bytes[i + prefix_bytes.len()] == b'.'
951                {
952                    i += prefix_bytes.len() + 1;
953                    continue;
954                }
955
956                out.push(char::from(b));
957                i += 1;
958            }
959            Mode::SingleQuote => {
960                out.push(char::from(b));
961                i += 1;
962                if b == b'\'' {
963                    if next == Some(b'\'') {
964                        out.push('\'');
965                        i += 1;
966                    } else {
967                        mode = Mode::Outside;
968                    }
969                }
970            }
971            Mode::DoubleQuote => {
972                out.push(char::from(b));
973                i += 1;
974                if b == b'"' {
975                    if next == Some(b'"') {
976                        out.push('"');
977                        i += 1;
978                    } else {
979                        mode = Mode::Outside;
980                    }
981                }
982            }
983            Mode::BacktickQuote => {
984                out.push(char::from(b));
985                i += 1;
986                if b == b'`' {
987                    if next == Some(b'`') {
988                        out.push('`');
989                        i += 1;
990                    } else {
991                        mode = Mode::Outside;
992                    }
993                }
994            }
995            Mode::BracketQuote => {
996                out.push(char::from(b));
997                i += 1;
998                if b == b']' {
999                    if next == Some(b']') {
1000                        out.push(']');
1001                        i += 1;
1002                    } else {
1003                        mode = Mode::Outside;
1004                    }
1005                }
1006            }
1007            Mode::LineComment => {
1008                out.push(char::from(b));
1009                i += 1;
1010                if b == b'\n' {
1011                    mode = Mode::Outside;
1012                }
1013            }
1014            Mode::BlockComment => {
1015                out.push(char::from(b));
1016                i += 1;
1017                if b == b'*' && next == Some(b'/') {
1018                    out.push('/');
1019                    i += 1;
1020                    mode = Mode::Outside;
1021                }
1022            }
1023        }
1024    }
1025
1026    out
1027}
1028
1029fn mixed_reference_autofix_edits(sql: &str) -> Vec<Rf003AutofixEdit> {
1030    let bytes = sql.as_bytes();
1031    let Some(select_start) = find_ascii_keyword(bytes, b"SELECT", 0) else {
1032        return Vec::new();
1033    };
1034    let select_end = select_start + b"SELECT".len();
1035    let Some(from_start) = find_ascii_keyword(bytes, b"FROM", select_end) else {
1036        return Vec::new();
1037    };
1038
1039    let Some((table_name, alias)) = extract_from_table_and_alias(sql) else {
1040        return Vec::new();
1041    };
1042    let prefix = if alias.is_empty() {
1043        table_name.rsplit('.').next().unwrap_or(&table_name)
1044    } else {
1045        alias.as_str()
1046    };
1047    if prefix.is_empty() {
1048        return Vec::new();
1049    }
1050
1051    let select_clause = &sql[select_end..from_start];
1052    let projection_items = split_projection_items(select_clause);
1053    if projection_items.is_empty() {
1054        return Vec::new();
1055    }
1056
1057    let has_qualified = projection_items
1058        .iter()
1059        .any(|(value, _, _)| is_simple_qualified_identifier(value));
1060    let has_unqualified = projection_items
1061        .iter()
1062        .any(|(value, _, _)| is_simple_identifier(value));
1063    if !(has_qualified && has_unqualified) {
1064        return Vec::new();
1065    }
1066
1067    projection_items
1068        .into_iter()
1069        .filter_map(|(value, start, end)| {
1070            if !is_simple_identifier(&value) {
1071                return None;
1072            }
1073            Some(Rf003AutofixEdit {
1074                start: select_end + start,
1075                end: select_end + end,
1076                replacement: format!("{prefix}.{value}"),
1077            })
1078        })
1079        .collect()
1080}
1081
1082fn split_projection_items(select_clause: &str) -> Vec<(String, usize, usize)> {
1083    let bytes = select_clause.as_bytes();
1084    let mut out = Vec::new();
1085    let mut segment_start = 0usize;
1086    let mut index = 0usize;
1087
1088    while index <= bytes.len() {
1089        if index == bytes.len() || bytes[index] == b',' {
1090            let segment = &select_clause[segment_start..index];
1091            let leading_trim = segment
1092                .char_indices()
1093                .find(|(_, ch)| !ch.is_ascii_whitespace())
1094                .map(|(idx, _)| idx)
1095                .unwrap_or(segment.len());
1096            let trailing_trim = segment
1097                .char_indices()
1098                .rfind(|(_, ch)| !ch.is_ascii_whitespace())
1099                .map(|(idx, ch)| idx + ch.len_utf8())
1100                .unwrap_or(leading_trim);
1101
1102            if leading_trim < trailing_trim {
1103                let value = segment[leading_trim..trailing_trim].to_string();
1104                out.push((
1105                    value,
1106                    segment_start + leading_trim,
1107                    segment_start + trailing_trim,
1108                ));
1109            }
1110            segment_start = index + 1;
1111        }
1112        index += 1;
1113    }
1114
1115    out
1116}
1117
1118fn extract_from_table_and_alias(sql: &str) -> Option<(String, String)> {
1119    let bytes = sql.as_bytes();
1120    let from_start = find_ascii_keyword(bytes, b"FROM", 0)?;
1121    let mut index = skip_ascii_whitespace(bytes, from_start + b"FROM".len());
1122    let table_start = index;
1123    index = consume_ascii_identifier(bytes, index)?;
1124    while index < bytes.len() && bytes[index] == b'.' {
1125        let next = consume_ascii_identifier(bytes, index + 1)?;
1126        index = next;
1127    }
1128    let table_name = sql[table_start..index].to_string();
1129
1130    let mut alias = String::new();
1131    let after_table = skip_ascii_whitespace(bytes, index);
1132    if after_table > index {
1133        if let Some(as_end) = match_ascii_keyword_at(bytes, after_table, b"AS") {
1134            let alias_start = skip_ascii_whitespace(bytes, as_end);
1135            if alias_start > as_end {
1136                if let Some(alias_end) = consume_ascii_identifier(bytes, alias_start) {
1137                    alias = sql[alias_start..alias_end].to_string();
1138                }
1139            }
1140        } else if let Some(alias_end) = consume_ascii_identifier(bytes, after_table) {
1141            alias = sql[after_table..alias_end].to_string();
1142        }
1143    }
1144
1145    Some((table_name, alias))
1146}
1147
1148fn is_ascii_whitespace_byte(byte: u8) -> bool {
1149    matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0x0b | 0x0c)
1150}
1151
1152fn is_ascii_ident_start(byte: u8) -> bool {
1153    byte.is_ascii_alphabetic() || byte == b'_'
1154}
1155
1156fn is_ascii_ident_continue(byte: u8) -> bool {
1157    byte.is_ascii_alphanumeric() || byte == b'_'
1158}
1159
1160fn skip_ascii_whitespace(bytes: &[u8], mut index: usize) -> usize {
1161    while index < bytes.len() && is_ascii_whitespace_byte(bytes[index]) {
1162        index += 1;
1163    }
1164    index
1165}
1166
1167fn consume_ascii_identifier(bytes: &[u8], start: usize) -> Option<usize> {
1168    if start >= bytes.len() || !is_ascii_ident_start(bytes[start]) {
1169        return None;
1170    }
1171    let mut index = start + 1;
1172    while index < bytes.len() && is_ascii_ident_continue(bytes[index]) {
1173        index += 1;
1174    }
1175    Some(index)
1176}
1177
1178fn is_word_boundary_for_keyword(bytes: &[u8], index: usize) -> bool {
1179    index == 0 || index >= bytes.len() || !is_ascii_ident_continue(bytes[index])
1180}
1181
1182fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> Option<usize> {
1183    let end = start.checked_add(keyword_upper.len())?;
1184    if end > bytes.len() {
1185        return None;
1186    }
1187    if !is_word_boundary_for_keyword(bytes, start.saturating_sub(1))
1188        || !is_word_boundary_for_keyword(bytes, end)
1189    {
1190        return None;
1191    }
1192    let matches = bytes[start..end]
1193        .iter()
1194        .zip(keyword_upper.iter())
1195        .all(|(actual, expected)| actual.to_ascii_uppercase() == *expected);
1196    if matches {
1197        Some(end)
1198    } else {
1199        None
1200    }
1201}
1202
1203fn find_ascii_keyword(bytes: &[u8], keyword_upper: &[u8], from: usize) -> Option<usize> {
1204    let mut index = from;
1205    while index + keyword_upper.len() <= bytes.len() {
1206        if match_ascii_keyword_at(bytes, index, keyword_upper).is_some() {
1207            return Some(index);
1208        }
1209        index += 1;
1210    }
1211    None
1212}
1213
1214fn is_simple_identifier(value: &str) -> bool {
1215    let bytes = value.as_bytes();
1216    if bytes.is_empty() || !is_ascii_ident_start(bytes[0]) {
1217        return false;
1218    }
1219    bytes[1..].iter().copied().all(is_ascii_ident_continue)
1220}
1221
1222fn is_simple_qualified_identifier(value: &str) -> bool {
1223    let mut parts = value.split('.');
1224    match (parts.next(), parts.next(), parts.next()) {
1225        (Some(left), Some(right), None) => {
1226            is_simple_identifier(left) && is_simple_identifier(right)
1227        }
1228        _ => false,
1229    }
1230}
1231
1232fn projection_wildcard_qualification_counts(select: &Select) -> (usize, usize) {
1233    let mut qualified = 0usize;
1234
1235    for item in &select.projection {
1236        match item {
1237            // SQLFluff RF03 parity: treat qualified wildcards as qualified references.
1238            SelectItem::QualifiedWildcard(_, _) => qualified += 1,
1239            // Keep unqualified wildcard neutral to avoid forcing `SELECT *` style choices.
1240            SelectItem::Wildcard(_) => {}
1241            _ => {}
1242        }
1243    }
1244
1245    (qualified, 0)
1246}
1247
1248fn select_source_names(select: &Select) -> HashSet<String> {
1249    let mut names = HashSet::new();
1250    for table in &select.from {
1251        collect_source_names_from_table_factor(&table.relation, &mut names);
1252        for join in &table.joins {
1253            collect_source_names_from_table_factor(&join.relation, &mut names);
1254        }
1255    }
1256    names
1257}
1258
1259fn collect_source_names_from_table_factor(table_factor: &TableFactor, names: &mut HashSet<String>) {
1260    match table_factor {
1261        TableFactor::Table { name, alias, .. } => {
1262            if let Some(alias) = alias {
1263                names.insert(alias.name.value.to_ascii_uppercase());
1264            }
1265            let table_name = name.to_string();
1266            if !table_name.is_empty() {
1267                let last = table_name
1268                    .rsplit('.')
1269                    .next()
1270                    .unwrap_or(&table_name)
1271                    .trim_matches(|ch| matches!(ch, '"' | '`' | '[' | ']'))
1272                    .to_ascii_uppercase();
1273                if !last.is_empty() {
1274                    names.insert(last);
1275                }
1276            }
1277        }
1278        TableFactor::Derived {
1279            alias: Some(alias), ..
1280        } => {
1281            names.insert(alias.name.value.to_ascii_uppercase());
1282        }
1283        TableFactor::Derived { alias: None, .. } => {}
1284        TableFactor::TableFunction { alias, .. }
1285        | TableFactor::Function { alias, .. }
1286        | TableFactor::UNNEST { alias, .. }
1287        | TableFactor::JsonTable { alias, .. }
1288        | TableFactor::OpenJsonTable { alias, .. } => {
1289            if let Some(alias) = alias {
1290                names.insert(alias.name.value.to_ascii_uppercase());
1291            }
1292        }
1293        TableFactor::NestedJoin {
1294            table_with_joins, ..
1295        } => {
1296            collect_source_names_from_table_factor(&table_with_joins.relation, names);
1297            for join in &table_with_joins.joins {
1298                collect_source_names_from_table_factor(&join.relation, names);
1299            }
1300        }
1301        TableFactor::Pivot { table, .. }
1302        | TableFactor::Unpivot { table, .. }
1303        | TableFactor::MatchRecognize { table, .. } => {
1304            collect_source_names_from_table_factor(table, names);
1305        }
1306        _ => {}
1307    }
1308}
1309
1310fn select_contains_pivot(select: &Select) -> bool {
1311    select.from.iter().any(|table| {
1312        table_factor_contains_pivot(&table.relation)
1313            || table
1314                .joins
1315                .iter()
1316                .any(|join| table_factor_contains_pivot(&join.relation))
1317    })
1318}
1319
1320fn table_factor_contains_pivot(table_factor: &TableFactor) -> bool {
1321    match table_factor {
1322        TableFactor::Pivot { .. } => true,
1323        TableFactor::NestedJoin {
1324            table_with_joins, ..
1325        } => {
1326            table_factor_contains_pivot(&table_with_joins.relation)
1327                || table_with_joins
1328                    .joins
1329                    .iter()
1330                    .any(|join| table_factor_contains_pivot(&join.relation))
1331        }
1332        TableFactor::Unpivot { table, .. } | TableFactor::MatchRecognize { table, .. } => {
1333            table_factor_contains_pivot(table)
1334        }
1335        _ => false,
1336    }
1337}
1338
1339fn select_contains_table_variable_source(select: &Select) -> bool {
1340    select.from.iter().any(|table| {
1341        table_factor_contains_table_variable(&table.relation)
1342            || table
1343                .joins
1344                .iter()
1345                .any(|join| table_factor_contains_table_variable(&join.relation))
1346    })
1347}
1348
1349fn table_factor_contains_table_variable(table_factor: &TableFactor) -> bool {
1350    match table_factor {
1351        TableFactor::Table { name, .. } => name.to_string().trim_start().starts_with('@'),
1352        TableFactor::NestedJoin {
1353            table_with_joins, ..
1354        } => {
1355            table_factor_contains_table_variable(&table_with_joins.relation)
1356                || table_with_joins
1357                    .joins
1358                    .iter()
1359                    .any(|join| table_factor_contains_table_variable(&join.relation))
1360        }
1361        TableFactor::Pivot { table, .. }
1362        | TableFactor::Unpivot { table, .. }
1363        | TableFactor::MatchRecognize { table, .. } => table_factor_contains_table_variable(table),
1364        _ => false,
1365    }
1366}
1367
1368fn count_reference_qualification_for_select(
1369    select: &Select,
1370    aliases: &HashSet<String>,
1371    local_sources: &HashSet<String>,
1372    statement_sources: &HashSet<String>,
1373    dialect: Dialect,
1374) -> (usize, usize, bool) {
1375    let mut qualified = 0usize;
1376    let mut unqualified = 0usize;
1377    let mut has_outer_references = false;
1378
1379    visit_select_expressions(select, &mut |expr| {
1380        let (q, u) = count_reference_qualification_in_expr_rf03(
1381            expr,
1382            aliases,
1383            local_sources,
1384            statement_sources,
1385            dialect,
1386            &mut has_outer_references,
1387        );
1388        qualified += q;
1389        unqualified += u;
1390    });
1391
1392    (qualified, unqualified, has_outer_references)
1393}
1394
1395fn count_reference_qualification_in_expr_rf03(
1396    expr: &Expr,
1397    aliases: &HashSet<String>,
1398    local_sources: &HashSet<String>,
1399    statement_sources: &HashSet<String>,
1400    dialect: Dialect,
1401    has_outer_references: &mut bool,
1402) -> (usize, usize) {
1403    match expr {
1404        Expr::Identifier(identifier) => {
1405            let name = identifier.value.to_ascii_uppercase();
1406            if aliases.contains(&name) || identifier.value.starts_with('@') {
1407                (0, 0)
1408            } else {
1409                (0, 1)
1410            }
1411        }
1412        Expr::CompoundIdentifier(parts) => {
1413            if parts.is_empty() {
1414                return (0, 0);
1415            }
1416
1417            let first = parts[0].value.to_ascii_uppercase();
1418            if first.starts_with('@') {
1419                return (0, 0);
1420            }
1421
1422            if parts.len() == 1 {
1423                if aliases.contains(&first) {
1424                    return (0, 0);
1425                }
1426                return (0, 1);
1427            }
1428
1429            if local_sources.contains(&first) {
1430                (1, 0)
1431            } else if statement_sources.contains(&first) {
1432                *has_outer_references = true;
1433                (0, 0)
1434            } else if is_object_reference_dialect(dialect) {
1435                // BigQuery/Hive/Redshift object-style refs (e.g. a.bar) should
1436                // behave like unqualified refs unless the prefix is a known source.
1437                (0, 1)
1438            } else {
1439                (1, 0)
1440            }
1441        }
1442        Expr::BinaryOp { left, right, .. }
1443        | Expr::AnyOp { left, right, .. }
1444        | Expr::AllOp { left, right, .. } => {
1445            let (lq, lu) = count_reference_qualification_in_expr_rf03(
1446                left,
1447                aliases,
1448                local_sources,
1449                statement_sources,
1450                dialect,
1451                has_outer_references,
1452            );
1453            let (rq, ru) = count_reference_qualification_in_expr_rf03(
1454                right,
1455                aliases,
1456                local_sources,
1457                statement_sources,
1458                dialect,
1459                has_outer_references,
1460            );
1461            (lq + rq, lu + ru)
1462        }
1463        Expr::UnaryOp { expr: inner, .. }
1464        | Expr::Nested(inner)
1465        | Expr::IsNull(inner)
1466        | Expr::IsNotNull(inner)
1467        | Expr::Cast { expr: inner, .. } => count_reference_qualification_in_expr_rf03(
1468            inner,
1469            aliases,
1470            local_sources,
1471            statement_sources,
1472            dialect,
1473            has_outer_references,
1474        ),
1475        Expr::InList { expr, list, .. } => {
1476            let (mut q, mut u) = count_reference_qualification_in_expr_rf03(
1477                expr,
1478                aliases,
1479                local_sources,
1480                statement_sources,
1481                dialect,
1482                has_outer_references,
1483            );
1484            for item in list {
1485                let (iq, iu) = count_reference_qualification_in_expr_rf03(
1486                    item,
1487                    aliases,
1488                    local_sources,
1489                    statement_sources,
1490                    dialect,
1491                    has_outer_references,
1492                );
1493                q += iq;
1494                u += iu;
1495            }
1496            (q, u)
1497        }
1498        Expr::Between {
1499            expr, low, high, ..
1500        } => {
1501            let (eq, eu) = count_reference_qualification_in_expr_rf03(
1502                expr,
1503                aliases,
1504                local_sources,
1505                statement_sources,
1506                dialect,
1507                has_outer_references,
1508            );
1509            let (lq, lu) = count_reference_qualification_in_expr_rf03(
1510                low,
1511                aliases,
1512                local_sources,
1513                statement_sources,
1514                dialect,
1515                has_outer_references,
1516            );
1517            let (hq, hu) = count_reference_qualification_in_expr_rf03(
1518                high,
1519                aliases,
1520                local_sources,
1521                statement_sources,
1522                dialect,
1523                has_outer_references,
1524            );
1525            (eq + lq + hq, eu + lu + hu)
1526        }
1527        Expr::Case {
1528            operand,
1529            conditions,
1530            else_result,
1531            ..
1532        } => {
1533            let mut q = 0usize;
1534            let mut u = 0usize;
1535            if let Some(operand) = operand {
1536                let (oq, ou) = count_reference_qualification_in_expr_rf03(
1537                    operand,
1538                    aliases,
1539                    local_sources,
1540                    statement_sources,
1541                    dialect,
1542                    has_outer_references,
1543                );
1544                q += oq;
1545                u += ou;
1546            }
1547            for when in conditions {
1548                let (cq, cu) = count_reference_qualification_in_expr_rf03(
1549                    &when.condition,
1550                    aliases,
1551                    local_sources,
1552                    statement_sources,
1553                    dialect,
1554                    has_outer_references,
1555                );
1556                let (rq, ru) = count_reference_qualification_in_expr_rf03(
1557                    &when.result,
1558                    aliases,
1559                    local_sources,
1560                    statement_sources,
1561                    dialect,
1562                    has_outer_references,
1563                );
1564                q += cq + rq;
1565                u += cu + ru;
1566            }
1567            if let Some(otherwise) = else_result {
1568                let (oq, ou) = count_reference_qualification_in_expr_rf03(
1569                    otherwise,
1570                    aliases,
1571                    local_sources,
1572                    statement_sources,
1573                    dialect,
1574                    has_outer_references,
1575                );
1576                q += oq;
1577                u += ou;
1578            }
1579            (q, u)
1580        }
1581        Expr::Function(function) => {
1582            let mut q = 0usize;
1583            let mut u = 0usize;
1584
1585            if let FunctionArguments::List(arguments) = &function.args {
1586                for (index, arg) in arguments.args.iter().enumerate() {
1587                    match arg {
1588                        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
1589                        | FunctionArg::Named {
1590                            arg: FunctionArgExpr::Expr(expr),
1591                            ..
1592                        } => {
1593                            if should_skip_identifier_reference_for_function_arg(
1594                                function, index, expr,
1595                            ) {
1596                                continue;
1597                            }
1598                            let (aq, au) = count_reference_qualification_in_expr_rf03(
1599                                expr,
1600                                aliases,
1601                                local_sources,
1602                                statement_sources,
1603                                dialect,
1604                                has_outer_references,
1605                            );
1606                            q += aq;
1607                            u += au;
1608                        }
1609                        _ => {}
1610                    }
1611                }
1612            }
1613
1614            if let Some(filter) = &function.filter {
1615                let (fq, fu) = count_reference_qualification_in_expr_rf03(
1616                    filter,
1617                    aliases,
1618                    local_sources,
1619                    statement_sources,
1620                    dialect,
1621                    has_outer_references,
1622                );
1623                q += fq;
1624                u += fu;
1625            }
1626
1627            for order_expr in &function.within_group {
1628                let (oq, ou) = count_reference_qualification_in_expr_rf03(
1629                    &order_expr.expr,
1630                    aliases,
1631                    local_sources,
1632                    statement_sources,
1633                    dialect,
1634                    has_outer_references,
1635                );
1636                q += oq;
1637                u += ou;
1638            }
1639
1640            if let Some(WindowType::WindowSpec(spec)) = &function.over {
1641                for expr in &spec.partition_by {
1642                    let (pq, pu) = count_reference_qualification_in_expr_rf03(
1643                        expr,
1644                        aliases,
1645                        local_sources,
1646                        statement_sources,
1647                        dialect,
1648                        has_outer_references,
1649                    );
1650                    q += pq;
1651                    u += pu;
1652                }
1653                for order_expr in &spec.order_by {
1654                    let (oq, ou) = count_reference_qualification_in_expr_rf03(
1655                        &order_expr.expr,
1656                        aliases,
1657                        local_sources,
1658                        statement_sources,
1659                        dialect,
1660                        has_outer_references,
1661                    );
1662                    q += oq;
1663                    u += ou;
1664                }
1665            }
1666
1667            (q, u)
1668        }
1669        Expr::InSubquery { expr, .. } => count_reference_qualification_in_expr_rf03(
1670            expr,
1671            aliases,
1672            local_sources,
1673            statement_sources,
1674            dialect,
1675            has_outer_references,
1676        ),
1677        Expr::Exists { .. } | Expr::Subquery(_) => (0, 0),
1678        _ => (0, 0),
1679    }
1680}
1681
1682fn is_object_reference_dialect(dialect: Dialect) -> bool {
1683    matches!(
1684        dialect,
1685        Dialect::Bigquery | Dialect::Hive | Dialect::Redshift
1686    )
1687}
1688
1689fn should_skip_identifier_reference_for_function_arg(
1690    function: &sqlparser::ast::Function,
1691    arg_index: usize,
1692    expr: &Expr,
1693) -> bool {
1694    let Expr::Identifier(ident) = expr else {
1695        return false;
1696    };
1697    if ident.quote_style.is_some() || !is_date_part_identifier(&ident.value) {
1698        return false;
1699    }
1700
1701    let Some(function_name) = function_name_upper(function) else {
1702        return false;
1703    };
1704    if !is_datepart_function_name(&function_name) {
1705        return false;
1706    }
1707
1708    arg_index <= 1
1709}
1710
1711fn function_name_upper(function: &sqlparser::ast::Function) -> Option<String> {
1712    function
1713        .name
1714        .0
1715        .last()
1716        .and_then(sqlparser::ast::ObjectNamePart::as_ident)
1717        .map(|ident| ident.value.to_ascii_uppercase())
1718}
1719
1720fn is_datepart_function_name(name: &str) -> bool {
1721    matches!(
1722        name,
1723        "DATEDIFF"
1724            | "DATE_DIFF"
1725            | "DATEADD"
1726            | "DATE_ADD"
1727            | "DATE_PART"
1728            | "DATETIME_TRUNC"
1729            | "TIME_TRUNC"
1730            | "TIMESTAMP_TRUNC"
1731            | "TIMESTAMP_DIFF"
1732            | "TIMESTAMPDIFF"
1733    )
1734}
1735
1736fn is_date_part_identifier(value: &str) -> bool {
1737    matches!(
1738        value.to_ascii_uppercase().as_str(),
1739        "YEAR"
1740            | "QUARTER"
1741            | "MONTH"
1742            | "WEEK"
1743            | "DAY"
1744            | "DOW"
1745            | "DOY"
1746            | "HOUR"
1747            | "MINUTE"
1748            | "SECOND"
1749            | "MILLISECOND"
1750            | "MICROSECOND"
1751            | "NANOSECOND"
1752    )
1753}
1754
1755#[cfg(test)]
1756mod tests {
1757    use super::*;
1758    use crate::parser::parse_sql;
1759    use crate::types::IssueAutofixApplicability;
1760
1761    fn run(sql: &str) -> Vec<Issue> {
1762        let statements = parse_sql(sql).expect("parse");
1763        let rule = ReferencesConsistent::default();
1764        statements
1765            .iter()
1766            .enumerate()
1767            .flat_map(|(index, statement)| {
1768                rule.check(
1769                    statement,
1770                    &LintContext {
1771                        sql,
1772                        statement_range: 0..sql.len(),
1773                        statement_index: index,
1774                    },
1775                )
1776            })
1777            .collect()
1778    }
1779
1780    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
1781        let autofix = issue.autofix.as_ref()?;
1782        let mut out = sql.to_string();
1783        let mut edits = autofix.edits.clone();
1784        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
1785        for edit in edits.into_iter().rev() {
1786            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
1787        }
1788        Some(out)
1789    }
1790
1791    fn apply_all_autofixes(sql: &str, issues: &[Issue]) -> String {
1792        let mut edits: Vec<_> = issues
1793            .iter()
1794            .filter_map(|issue| issue.autofix.as_ref())
1795            .flat_map(|autofix| autofix.edits.clone())
1796            .collect();
1797        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
1798        edits.dedup_by(|left, right| {
1799            left.span.start == right.span.start
1800                && left.span.end == right.span.end
1801                && left.replacement == right.replacement
1802        });
1803
1804        let mut out = sql.to_string();
1805        for edit in edits.into_iter().rev() {
1806            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
1807        }
1808        out
1809    }
1810
1811    // --- Edge cases adopted from sqlfluff RF03 ---
1812
1813    #[test]
1814    fn flags_mixed_qualification_single_table() {
1815        let sql = "SELECT my_tbl.bar, baz FROM my_tbl";
1816        let issues = run(sql);
1817        assert_eq!(issues.len(), 2);
1818        assert!(issues
1819            .iter()
1820            .all(|issue| issue.code == issue_codes::LINT_RF_003));
1821        let issue_with_fix = issues
1822            .iter()
1823            .find(|issue| issue.autofix.is_some())
1824            .expect("issue with autofix");
1825        let autofix = issue_with_fix.autofix.as_ref().expect("autofix metadata");
1826        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
1827        let fixed = apply_issue_autofix(sql, issue_with_fix).expect("apply autofix");
1828        assert_eq!(fixed, "SELECT my_tbl.bar, my_tbl.baz FROM my_tbl");
1829    }
1830
1831    #[test]
1832    fn allows_consistently_unqualified_references() {
1833        let issues = run("SELECT bar FROM my_tbl");
1834        assert!(issues.is_empty());
1835    }
1836
1837    #[test]
1838    fn allows_consistently_qualified_references() {
1839        let issues = run("SELECT my_tbl.bar FROM my_tbl");
1840        assert!(issues.is_empty());
1841    }
1842
1843    #[test]
1844    fn flags_mixed_qualification_in_subquery() {
1845        let issues = run("SELECT * FROM (SELECT my_tbl.bar, baz FROM my_tbl)");
1846        assert_eq!(issues.len(), 2);
1847    }
1848
1849    #[test]
1850    fn allows_consistent_references_in_subquery() {
1851        let issues = run("SELECT * FROM (SELECT my_tbl.bar FROM my_tbl)");
1852        assert!(issues.is_empty());
1853    }
1854
1855    #[test]
1856    fn flags_mixed_qualification_with_qualified_wildcard() {
1857        let issues = run("SELECT my_tbl.*, bar FROM my_tbl");
1858        assert_eq!(issues.len(), 2);
1859    }
1860
1861    #[test]
1862    fn allows_consistent_qualified_wildcard_and_columns() {
1863        let issues = run("SELECT my_tbl.*, my_tbl.bar FROM my_tbl");
1864        assert!(issues.is_empty());
1865    }
1866
1867    #[test]
1868    fn qualified_mode_flags_unqualified_references() {
1869        let config = LintConfig {
1870            enabled: true,
1871            disabled_rules: vec![],
1872            rule_configs: std::collections::BTreeMap::from([(
1873                "references.consistent".to_string(),
1874                serde_json::json!({"single_table_references": "qualified"}),
1875            )]),
1876        };
1877        let rule = ReferencesConsistent::from_config(&config);
1878        let sql = "SELECT bar FROM my_tbl";
1879        let statements = parse_sql(sql).expect("parse");
1880        let issues = rule.check(
1881            &statements[0],
1882            &LintContext {
1883                sql,
1884                statement_range: 0..sql.len(),
1885                statement_index: 0,
1886            },
1887        );
1888        assert_eq!(issues.len(), 1);
1889    }
1890
1891    #[test]
1892    fn force_enable_false_disables_rule() {
1893        let config = LintConfig {
1894            enabled: true,
1895            disabled_rules: vec![],
1896            rule_configs: std::collections::BTreeMap::from([(
1897                "LINT_RF_003".to_string(),
1898                serde_json::json!({"force_enable": false}),
1899            )]),
1900        };
1901        let rule = ReferencesConsistent::from_config(&config);
1902        let sql = "SELECT my_tbl.bar, baz FROM my_tbl";
1903        let statements = parse_sql(sql).expect("parse");
1904        let issues = rule.check(
1905            &statements[0],
1906            &LintContext {
1907                sql,
1908                statement_range: 0..sql.len(),
1909                statement_index: 0,
1910            },
1911        );
1912        assert!(issues.is_empty());
1913    }
1914
1915    #[test]
1916    fn autofix_uses_document_spans_for_trimmed_statement_ranges() {
1917        let sql = "-- c1\n-- c2\n-- c3\n-- c4\n-- c5\n-- c6\n-- c7\n-- c8\n-- c9\n-- c10\n-- c11\n-- c12\nSELECT\n    t.a,\n    b,\n    c,\n    d,\n    e,\n    f,\n    g,\n    h,\n    i,\n    j,\n    k,\n    l,\n    m,\n    n\nFROM foo AS t";
1918        let statements = parse_sql(sql).expect("parse");
1919        let statement_start = sql.find("SELECT").expect("statement start");
1920        let statement_end = sql.len();
1921        let rule = ReferencesConsistent::default();
1922
1923        let issues = rule.check(
1924            &statements[0],
1925            &LintContext {
1926                sql,
1927                statement_range: statement_start..statement_end,
1928                statement_index: 0,
1929            },
1930        );
1931
1932        let fixed = apply_all_autofixes(sql, &issues);
1933        assert!(fixed.contains("    t.b,"));
1934        assert!(fixed.contains("    t.n"));
1935        assert!(fixed.contains("FROM foo AS t"));
1936        assert!(!fixed.contains("\n    b,"));
1937    }
1938}