Skip to main content

sqrust_rules/lint/
duplicate_alias.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Query, SelectItem, SetExpr, Statement, TableFactor};
3use std::collections::HashMap;
4
5pub struct DuplicateAlias;
6
7impl Rule for DuplicateAlias {
8    fn name(&self) -> &'static str {
9        "Lint/DuplicateAlias"
10    }
11
12    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
13        if !ctx.parse_errors.is_empty() {
14            return Vec::new();
15        }
16
17        let mut diags = Vec::new();
18        for stmt in &ctx.statements {
19            if let Statement::Query(query) = stmt {
20                check_query(query, &ctx.source, &mut diags);
21            }
22        }
23        diags
24    }
25}
26
27fn check_query(query: &Query, source: &str, diags: &mut Vec<Diagnostic>) {
28    // Check the optional WITH clause (CTEs)
29    if let Some(with) = &query.with {
30        for cte in &with.cte_tables {
31            check_query(&cte.query, source, diags);
32        }
33    }
34    check_set_expr(&query.body, source, diags);
35}
36
37fn check_set_expr(expr: &SetExpr, source: &str, diags: &mut Vec<Diagnostic>) {
38    match expr {
39        SetExpr::Select(sel) => {
40            // Collect aliases and find duplicates — report each duplicate alias once.
41            let mut seen: HashMap<String, usize> = HashMap::new();
42            // Preserve insertion order for deterministic output — collect dupes in order.
43            let mut dupes: Vec<String> = Vec::new();
44
45            for item in &sel.projection {
46                if let SelectItem::ExprWithAlias { alias, .. } = item {
47                    let name = alias.value.to_lowercase();
48                    let count = seen.entry(name.clone()).or_insert(0);
49                    *count += 1;
50                    if *count == 2 {
51                        // Record duplicate the first time the count hits 2.
52                        dupes.push(name);
53                    }
54                }
55            }
56
57            for dupe in &dupes {
58                // Try to locate `AS <alias>` in source for a useful position.
59                let (line, col) = find_alias_position(source, dupe);
60                diags.push(Diagnostic {
61                    rule: "Lint/DuplicateAlias",
62                    message: format!(
63                        "Column alias '{}' is used more than once in this SELECT",
64                        dupe
65                    ),
66                    line,
67                    col,
68                });
69            }
70
71            // Recurse into subqueries in the FROM clause.
72            for table in &sel.from {
73                check_table_factor(&table.relation, source, diags);
74                for join in &table.joins {
75                    check_table_factor(&join.relation, source, diags);
76                }
77            }
78        }
79        SetExpr::SetOperation { left, right, .. } => {
80            check_set_expr(left, source, diags);
81            check_set_expr(right, source, diags);
82        }
83        // Query expressions (subqueries as SetExpr::Query) — recurse.
84        SetExpr::Query(inner) => {
85            check_query(inner, source, diags);
86        }
87        _ => {}
88    }
89}
90
91fn check_table_factor(tf: &TableFactor, source: &str, diags: &mut Vec<Diagnostic>) {
92    if let TableFactor::Derived { subquery, .. } = tf {
93        check_query(subquery, source, diags);
94    }
95}
96
97/// Finds the first occurrence of `AS <alias>` (case-insensitive) in `source`
98/// and returns its 1-indexed (line, col). Falls back to (1, 1) if not found.
99fn find_alias_position(source: &str, alias: &str) -> (usize, usize) {
100    let source_lower = source.to_lowercase();
101    let pattern = format!("as {}", alias);
102
103    let mut search_from = 0usize;
104    while search_from < source_lower.len() {
105        let Some(rel) = source_lower[search_from..].find(&pattern) else {
106            break;
107        };
108        let abs = search_from + rel;
109        let bytes = source_lower.as_bytes();
110
111        // Check word boundary before 'as'.
112        let before_ok = abs == 0
113            || {
114                let b = bytes[abs - 1];
115                !b.is_ascii_alphanumeric() && b != b'_'
116            };
117
118        let after_pos = abs + pattern.len();
119        // Check word boundary after alias.
120        let after_ok = after_pos >= source_lower.len()
121            || {
122                let b = bytes[after_pos];
123                !b.is_ascii_alphanumeric() && b != b'_'
124            };
125
126        if before_ok && after_ok {
127            return offset_to_line_col(source, abs);
128        }
129        search_from = abs + 1;
130    }
131
132    (1, 1)
133}
134
135/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
136fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
137    let mut line = 1usize;
138    let mut col = 1usize;
139    for (i, ch) in source.char_indices() {
140        if i == offset {
141            break;
142        }
143        if ch == '\n' {
144            line += 1;
145            col = 1;
146        } else {
147            col += 1;
148        }
149    }
150    (line, col)
151}