1use 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 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 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 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 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}