Skip to main content

flowscope_core/linter/rules/
al_002.rs

1//! LINT_AL_002: Column alias style.
2//!
3//! SQLFluff parity: configurable column aliasing style (`explicit`/`implicit`).
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::{Ident, SelectItem, Spanned, Statement};
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10
11use super::semantic_helpers::visit_selects_in_statement;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14enum AliasingPreference {
15    Explicit,
16    Implicit,
17}
18
19impl AliasingPreference {
20    fn from_config(config: &LintConfig, rule_code: &str) -> Self {
21        match config
22            .rule_option_str(rule_code, "aliasing")
23            .unwrap_or("explicit")
24            .to_ascii_lowercase()
25            .as_str()
26        {
27            "implicit" => Self::Implicit,
28            _ => Self::Explicit,
29        }
30    }
31
32    fn message(self) -> &'static str {
33        match self {
34            Self::Explicit => "Use explicit AS when aliasing columns.",
35            Self::Implicit => "Use implicit aliasing when aliasing columns (omit AS).",
36        }
37    }
38
39    fn violation(self, explicit_as: bool) -> bool {
40        match self {
41            Self::Explicit => !explicit_as,
42            Self::Implicit => explicit_as,
43        }
44    }
45}
46
47pub struct AliasingColumnStyle {
48    aliasing: AliasingPreference,
49}
50
51impl AliasingColumnStyle {
52    pub fn from_config(config: &LintConfig) -> Self {
53        Self {
54            aliasing: AliasingPreference::from_config(config, issue_codes::LINT_AL_002),
55        }
56    }
57}
58
59impl Default for AliasingColumnStyle {
60    fn default() -> Self {
61        Self {
62            aliasing: AliasingPreference::Explicit,
63        }
64    }
65}
66
67impl LintRule for AliasingColumnStyle {
68    fn code(&self) -> &'static str {
69        issue_codes::LINT_AL_002
70    }
71
72    fn name(&self) -> &'static str {
73        "Column alias style"
74    }
75
76    fn description(&self) -> &'static str {
77        "Implicit/explicit aliasing of columns."
78    }
79
80    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
81        let mut issues = Vec::new();
82        let tokens =
83            tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
84
85        visit_selects_in_statement(statement, &mut |select| {
86            for item in &select.projection {
87                let SelectItem::ExprWithAlias { alias, .. } = item else {
88                    continue;
89                };
90
91                let Some(occurrence) =
92                    alias_occurrence_in_statement(alias, item, ctx, tokens.as_deref())
93                else {
94                    continue;
95                };
96
97                if occurrence.tsql_equals_assignment {
98                    // TSQL supports `SELECT alias = expr`, which SQLFluff excludes from AL02.
99                    continue;
100                }
101
102                if !self.aliasing.violation(occurrence.explicit_as) {
103                    continue;
104                }
105
106                let mut issue = Issue::info(issue_codes::LINT_AL_002, self.aliasing.message())
107                    .with_statement(ctx.statement_index)
108                    .with_span(ctx.span_from_statement_offset(occurrence.start, occurrence.end));
109                if let Some(edits) = autofix_edits_for_occurrence(occurrence, self.aliasing) {
110                    issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
111                }
112                issues.push(issue);
113            }
114        });
115
116        issues
117    }
118}
119
120#[derive(Clone, Copy)]
121struct AliasOccurrence {
122    start: usize,
123    end: usize,
124    explicit_as: bool,
125    as_span: Option<Span>,
126    tsql_equals_assignment: bool,
127}
128
129fn autofix_edits_for_occurrence(
130    occurrence: AliasOccurrence,
131    aliasing: AliasingPreference,
132) -> Option<Vec<IssuePatchEdit>> {
133    match aliasing {
134        AliasingPreference::Explicit if !occurrence.explicit_as => {
135            let insert = Span::new(occurrence.start, occurrence.start);
136            Some(vec![IssuePatchEdit::new(insert, "AS ")])
137        }
138        AliasingPreference::Implicit if occurrence.explicit_as => {
139            let as_span = occurrence.as_span?;
140            // Replace " AS " (leading whitespace + AS keyword + trailing whitespace)
141            // with a single space to preserve separation between expression and alias.
142            let delete_end = occurrence.start;
143            Some(vec![IssuePatchEdit::new(
144                Span::new(as_span.start, delete_end),
145                " ",
146            )])
147        }
148        _ => None,
149    }
150}
151
152fn alias_occurrence_in_statement(
153    alias: &Ident,
154    item: &SelectItem,
155    ctx: &LintContext,
156    tokens: Option<&[LocatedToken]>,
157) -> Option<AliasOccurrence> {
158    let tokens = tokens?;
159
160    let abs_start = line_col_to_offset(
161        ctx.sql,
162        alias.span.start.line as usize,
163        alias.span.start.column as usize,
164    )?;
165    let abs_end = line_col_to_offset(
166        ctx.sql,
167        alias.span.end.line as usize,
168        alias.span.end.column as usize,
169    )?;
170
171    if abs_start < ctx.statement_range.start || abs_end > ctx.statement_range.end {
172        return None;
173    }
174
175    let rel_start = abs_start - ctx.statement_range.start;
176    let rel_end = abs_end - ctx.statement_range.start;
177    let item_span = item.span();
178    let abs_item_end = line_col_to_offset(
179        ctx.sql,
180        item_span.end.line as usize,
181        item_span.end.column as usize,
182    )?;
183    if abs_item_end < abs_end || abs_item_end > ctx.statement_range.end {
184        return None;
185    }
186    let rel_item_end = abs_item_end - ctx.statement_range.start;
187
188    let (explicit_as, as_span) = explicit_as_before_alias_tokens(tokens, rel_start)?;
189    let tsql_equals_assignment =
190        tsql_assignment_after_alias_tokens(tokens, rel_end, rel_item_end).unwrap_or(false);
191    Some(AliasOccurrence {
192        start: rel_start,
193        end: rel_end,
194        explicit_as,
195        as_span,
196        tsql_equals_assignment,
197    })
198}
199
200fn explicit_as_before_alias_tokens(
201    tokens: &[LocatedToken],
202    alias_start: usize,
203) -> Option<(bool, Option<Span>)> {
204    let token = tokens
205        .iter()
206        .rev()
207        .find(|token| token.end <= alias_start && !is_trivia_token(&token.token))?;
208    if is_as_token(&token.token) {
209        // Include leading whitespace before AS in the span.
210        let leading_ws_start = tokens
211            .iter()
212            .rev()
213            .find(|t| t.end <= token.start && !is_trivia_token(&t.token))
214            .map(|t| t.end)
215            .unwrap_or(token.start);
216        Some((true, Some(Span::new(leading_ws_start, token.end))))
217    } else {
218        Some((false, None))
219    }
220}
221
222fn tsql_assignment_after_alias_tokens(
223    tokens: &[LocatedToken],
224    alias_end: usize,
225    item_end: usize,
226) -> Option<bool> {
227    let token = tokens.iter().find(|token| {
228        token.start >= alias_end && token.end <= item_end && !is_trivia_token(&token.token)
229    })?;
230    Some(matches!(token.token, Token::Eq | Token::Assignment))
231}
232
233fn is_as_token(token: &Token) -> bool {
234    match token {
235        Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
236        _ => false,
237    }
238}
239
240#[derive(Clone)]
241struct LocatedToken {
242    token: Token,
243    start: usize,
244    end: usize,
245}
246
247fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
248    let dialect = dialect.to_sqlparser_dialect();
249    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
250    let tokens = tokenizer.tokenize_with_location().ok()?;
251
252    let mut out = Vec::with_capacity(tokens.len());
253    for token in tokens {
254        let (start, end) = token_with_span_offsets(sql, &token)?;
255        out.push(LocatedToken {
256            token: token.token,
257            start,
258            end,
259        });
260    }
261    Some(out)
262}
263
264fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
265    let statement_start = ctx.statement_range.start;
266    ctx.with_document_tokens(|tokens| {
267        if tokens.is_empty() {
268            return None;
269        }
270
271        Some(
272            tokens
273                .iter()
274                .filter_map(|token| {
275                    let (start, end) = token_with_span_offsets(ctx.sql, token)?;
276                    if start < ctx.statement_range.start || end > ctx.statement_range.end {
277                        return None;
278                    }
279                    Some(LocatedToken {
280                        token: token.token.clone(),
281                        start: start - statement_start,
282                        end: end - statement_start,
283                    })
284                })
285                .collect::<Vec<_>>(),
286        )
287    })
288}
289
290fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
291    let start = line_col_to_offset(
292        sql,
293        token.span.start.line as usize,
294        token.span.start.column as usize,
295    )?;
296    let end = line_col_to_offset(
297        sql,
298        token.span.end.line as usize,
299        token.span.end.column as usize,
300    )?;
301    Some((start, end))
302}
303
304fn is_trivia_token(token: &Token) -> bool {
305    matches!(
306        token,
307        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
308            | Token::Whitespace(Whitespace::SingleLineComment { .. })
309            | Token::Whitespace(Whitespace::MultiLineComment(_))
310    )
311}
312
313fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
314    if line == 0 || column == 0 {
315        return None;
316    }
317
318    let mut current_line = 1usize;
319    let mut current_col = 1usize;
320
321    for (offset, ch) in sql.char_indices() {
322        if current_line == line && current_col == column {
323            return Some(offset);
324        }
325
326        if ch == '\n' {
327            current_line += 1;
328            current_col = 1;
329        } else {
330            current_col += 1;
331        }
332    }
333
334    if current_line == line && current_col == column {
335        return Some(sql.len());
336    }
337
338    None
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::{
345        parser::{parse_sql, parse_sql_with_dialect},
346        types::IssueAutofixApplicability,
347        Dialect,
348    };
349
350    fn run_with_rule(sql: &str, rule: AliasingColumnStyle) -> Vec<Issue> {
351        let stmts = parse_sql(sql).expect("parse");
352        stmts
353            .iter()
354            .enumerate()
355            .flat_map(|(index, stmt)| {
356                rule.check(
357                    stmt,
358                    &LintContext {
359                        sql,
360                        statement_range: 0..sql.len(),
361                        statement_index: index,
362                    },
363                )
364            })
365            .collect()
366    }
367
368    fn run(sql: &str) -> Vec<Issue> {
369        run_with_rule(sql, AliasingColumnStyle::default())
370    }
371
372    #[test]
373    fn flags_implicit_column_alias() {
374        let issues = run("select a + 1 total from t");
375        assert_eq!(issues.len(), 1);
376        assert_eq!(issues[0].code, issue_codes::LINT_AL_002);
377    }
378
379    #[test]
380    fn allows_explicit_column_alias() {
381        let issues = run("select a + 1 as total from t");
382        assert!(issues.is_empty());
383    }
384
385    #[test]
386    fn flags_explicit_aliases_when_implicit_policy_requested() {
387        let config = LintConfig {
388            enabled: true,
389            disabled_rules: vec![],
390            rule_configs: std::collections::BTreeMap::from([(
391                "aliasing.column".to_string(),
392                serde_json::json!({"aliasing": "implicit"}),
393            )]),
394        };
395        let issues = run_with_rule(
396            "select a + 1 as total, b + 1 value from t",
397            AliasingColumnStyle::from_config(&config),
398        );
399        assert_eq!(issues.len(), 1);
400        assert_eq!(issues[0].code, issue_codes::LINT_AL_002);
401    }
402
403    #[test]
404    fn does_not_flag_alias_text_in_string_literal() {
405        let issues = run("select 'a as label' as value from t");
406        assert!(issues.is_empty());
407    }
408
409    #[test]
410    fn explicit_mode_emits_safe_insert_as_autofix_patch() {
411        let sql = "select a + 1 total from t";
412        let issues = run(sql);
413        assert_eq!(issues.len(), 1);
414        let autofix = issues[0]
415            .autofix
416            .as_ref()
417            .expect("expected AL002 core autofix");
418        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
419        assert_eq!(autofix.edits.len(), 1);
420        assert_eq!(autofix.edits[0].replacement, "AS ");
421        assert_eq!(autofix.edits[0].span.start, autofix.edits[0].span.end);
422    }
423
424    #[test]
425    fn implicit_mode_emits_safe_remove_as_autofix_patch() {
426        let config = LintConfig {
427            enabled: true,
428            disabled_rules: vec![],
429            rule_configs: std::collections::BTreeMap::from([(
430                "aliasing.column".to_string(),
431                serde_json::json!({"aliasing": "implicit"}),
432            )]),
433        };
434        let rule = AliasingColumnStyle::from_config(&config);
435        let sql = "select a + 1 as total from t";
436        let issues = run_with_rule(sql, rule);
437        assert_eq!(issues.len(), 1);
438        let autofix = issues[0]
439            .autofix
440            .as_ref()
441            .expect("expected AL002 core autofix in implicit mode");
442        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
443        assert_eq!(autofix.edits.len(), 1);
444        assert_eq!(autofix.edits[0].replacement, " ");
445        // Span should cover " as " (leading whitespace + AS keyword + trailing whitespace).
446        assert_eq!(
447            &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
448            " as "
449        );
450    }
451
452    #[test]
453    fn allows_tsql_assignment_style_alias() {
454        let sql = "select alias1 = col1";
455        let statements = parse_sql_with_dialect(sql, Dialect::Mssql).expect("parse");
456        let issues = AliasingColumnStyle::default().check(
457            &statements[0],
458            &LintContext {
459                sql,
460                statement_range: 0..sql.len(),
461                statement_index: 0,
462            },
463        );
464        assert!(issues.is_empty());
465    }
466}