Skip to main content

sqrust_rules/convention/
explicit_column_alias.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Query, Select, SelectItem, SetExpr, Statement, TableFactor};
3
4pub struct ExplicitColumnAlias;
5
6/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
7fn line_col(source: &str, offset: usize) -> (usize, usize) {
8    let before = &source[..offset];
9    let line = before.chars().filter(|&c| c == '\n').count() + 1;
10    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
11    (line, col)
12}
13
14/// Returns true if `b` is a valid identifier/word character.
15fn is_word(b: u8) -> bool {
16    b.is_ascii_alphanumeric() || b == b'_'
17}
18
19/// Given source text and an alias name, find the Nth (0-indexed) occurrence of
20/// the alias as a standalone word. Returns the byte offset of that word, or None.
21fn find_alias_occurrence(source: &str, alias: &str, occurrence: usize) -> Option<usize> {
22    let bytes = source.as_bytes();
23    let alias_bytes = alias.as_bytes();
24    let alias_len = alias_bytes.len();
25    let src_len = bytes.len();
26    let mut count = 0usize;
27    let mut i = 0;
28
29    while i + alias_len <= src_len {
30        // Word boundary before
31        let before_ok = i == 0 || !is_word(bytes[i - 1]);
32        if before_ok {
33            let matched = bytes[i..i + alias_len]
34                .iter()
35                .zip(alias_bytes.iter())
36                .all(|(&a, b)| a.eq_ignore_ascii_case(b));
37            if matched {
38                // Word boundary after
39                let after_ok = i + alias_len >= src_len || !is_word(bytes[i + alias_len]);
40                if after_ok {
41                    if count == occurrence {
42                        return Some(i);
43                    }
44                    count += 1;
45                }
46            }
47        }
48        i += 1;
49    }
50    None
51}
52
53/// Check if `AS` (case-insensitive) appears immediately before `pos` in source,
54/// allowing arbitrary whitespace between `AS` and `pos`.
55/// Also handles quoted aliases (pos might point into a quoted string).
56fn has_as_before(source: &str, pos: usize) -> bool {
57    let bytes = source.as_bytes();
58
59    // Walk backwards from pos, skipping whitespace.
60    let mut j = pos;
61    if j == 0 {
62        return false;
63    }
64    // Also skip a leading quote character if the alias is quoted.
65    if j > 0 && (bytes[j - 1] == b'"' || bytes[j - 1] == b'`' || bytes[j - 1] == b'\'') {
66        // The alias starts after a quote — back up past the quote too.
67        j -= 1;
68    }
69
70    // Now skip whitespace going backward.
71    while j > 0 && (bytes[j - 1] == b' ' || bytes[j - 1] == b'\t' || bytes[j - 1] == b'\n' || bytes[j - 1] == b'\r') {
72        j -= 1;
73    }
74
75    if j < 2 {
76        return false;
77    }
78
79    // Check if the two characters before are 'AS' (case-insensitive),
80    // and that the character before that is not a word char (word boundary).
81    let candidate = &bytes[j - 2..j];
82    if !candidate.eq_ignore_ascii_case(b"AS") {
83        return false;
84    }
85    // Word boundary before 'AS'
86    let before_as = j - 2;
87    if before_as > 0 && is_word(bytes[before_as - 1]) {
88        return false;
89    }
90    true
91}
92
93/// Collect (alias_name, occurrence_index) pairs per alias name from a SELECT list,
94/// then check each one in source text.
95fn check_projection(
96    projection: &[SelectItem],
97    source: &str,
98    rule: &'static str,
99    diags: &mut Vec<Diagnostic>,
100    alias_counts: &mut std::collections::HashMap<String, usize>,
101) {
102    for item in projection {
103        if let SelectItem::ExprWithAlias { alias, .. } = item {
104            let alias_str = alias.value.as_str();
105            let key = alias_str.to_lowercase();
106            let occ = *alias_counts.get(&key).unwrap_or(&0);
107            *alias_counts.entry(key).or_insert(0) += 1;
108
109            if let Some(pos) = find_alias_occurrence(source, alias_str, occ) {
110                if !has_as_before(source, pos) {
111                    let (line, col) = line_col(source, pos);
112                    diags.push(Diagnostic {
113                        rule,
114                        message: format!(
115                            "Column alias '{}' omits the AS keyword — use 'expression AS alias' for clarity",
116                            alias_str
117                        ),
118                        line,
119                        col,
120                    });
121                }
122            }
123        }
124    }
125}
126
127fn check_select(
128    sel: &Select,
129    source: &str,
130    rule: &'static str,
131    diags: &mut Vec<Diagnostic>,
132    alias_counts: &mut std::collections::HashMap<String, usize>,
133) {
134    check_projection(&sel.projection, source, rule, diags, alias_counts);
135
136    // Recurse into subqueries in FROM.
137    for twj in &sel.from {
138        recurse_table_factor(&twj.relation, source, rule, diags, alias_counts);
139        for join in &twj.joins {
140            recurse_table_factor(&join.relation, source, rule, diags, alias_counts);
141        }
142    }
143}
144
145fn recurse_table_factor(
146    tf: &TableFactor,
147    source: &str,
148    rule: &'static str,
149    diags: &mut Vec<Diagnostic>,
150    alias_counts: &mut std::collections::HashMap<String, usize>,
151) {
152    if let TableFactor::Derived { subquery, .. } = tf {
153        check_query(subquery, source, rule, diags, alias_counts);
154    }
155}
156
157fn check_set_expr(
158    expr: &SetExpr,
159    source: &str,
160    rule: &'static str,
161    diags: &mut Vec<Diagnostic>,
162    alias_counts: &mut std::collections::HashMap<String, usize>,
163) {
164    match expr {
165        SetExpr::Select(sel) => check_select(sel, source, rule, diags, alias_counts),
166        SetExpr::Query(inner) => check_query(inner, source, rule, diags, alias_counts),
167        SetExpr::SetOperation { left, right, .. } => {
168            check_set_expr(left, source, rule, diags, alias_counts);
169            check_set_expr(right, source, rule, diags, alias_counts);
170        }
171        _ => {}
172    }
173}
174
175fn check_query(
176    query: &Query,
177    source: &str,
178    rule: &'static str,
179    diags: &mut Vec<Diagnostic>,
180    alias_counts: &mut std::collections::HashMap<String, usize>,
181) {
182    if let Some(with) = &query.with {
183        for cte in &with.cte_tables {
184            check_query(&cte.query, source, rule, diags, alias_counts);
185        }
186    }
187    check_set_expr(&query.body, source, rule, diags, alias_counts);
188}
189
190impl Rule for ExplicitColumnAlias {
191    fn name(&self) -> &'static str {
192        "Convention/ExplicitColumnAlias"
193    }
194
195    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
196        if !ctx.parse_errors.is_empty() {
197            return Vec::new();
198        }
199
200        let mut diags = Vec::new();
201        // Track how many times each alias name has been seen (case-insensitive key)
202        // so we can find the correct Nth occurrence in source text.
203        let mut alias_counts: std::collections::HashMap<String, usize> =
204            std::collections::HashMap::new();
205
206        for stmt in &ctx.statements {
207            if let Statement::Query(query) = stmt {
208                check_query(
209                    query,
210                    &ctx.source,
211                    self.name(),
212                    &mut diags,
213                    &mut alias_counts,
214                );
215            }
216        }
217
218        diags
219    }
220}