use crate::linter::rule::{LintContext, LintRule};
use crate::linter::visit::visit_expressions;
use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
use sqlparser::ast::{Expr, Statement};
use sqlparser::keywords::Keyword;
use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer, Whitespace};
use std::collections::HashSet;
pub struct LayoutFunctions;
impl LintRule for LayoutFunctions {
fn code(&self) -> &'static str {
issue_codes::LINT_LT_006
}
fn name(&self) -> &'static str {
"Layout functions"
}
fn description(&self) -> &'static str {
"Function name not immediately followed by parenthesis."
}
fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
let Some(issue_span) = function_spacing_issue_span(statement, ctx) else {
return Vec::new();
};
let function_span =
ctx.span_from_statement_offset(issue_span.function_start, issue_span.function_end);
let gap_span = ctx.span_from_statement_offset(issue_span.gap_start, issue_span.gap_end);
vec![Issue::info(
issue_codes::LINT_LT_006,
"Function call spacing appears inconsistent.",
)
.with_statement(ctx.statement_index)
.with_span(function_span)
.with_autofix_edits(
IssueAutofixApplicability::Safe,
vec![IssuePatchEdit::new(gap_span, "")],
)]
}
}
#[derive(Clone, Copy, Debug)]
struct FunctionSpacingIssueSpan {
function_start: usize,
function_end: usize,
gap_start: usize,
gap_end: usize,
}
fn function_spacing_issue_span(
statement: &Statement,
ctx: &LintContext,
) -> Option<FunctionSpacingIssueSpan> {
let sql = ctx.statement_sql();
let tracked_function_names = tracked_function_names(statement);
let tokens = tokenized_for_context(ctx).or_else(|| tokenized(sql, ctx.dialect()))?;
for (index, token) in tokens.iter().enumerate() {
let Token::Word(word) = &token.token else {
continue;
};
if word.quote_style.is_some() {
continue;
}
let word_upper = word.value.to_ascii_uppercase();
if !tracked_function_names.contains(&word_upper) && !is_always_function_keyword(&word_upper)
{
continue;
}
if word_upper == "EXISTS" && !is_select_projection_exists(&tokens, index) {
continue;
}
let Some(next_index) = next_non_trivia_index(&tokens, index + 1) else {
continue;
};
if !matches!(tokens[next_index].token, Token::LParen) {
continue;
}
if next_index == index + 1 {
continue;
}
if let Some(prev_index) = prev_non_trivia_index(&tokens, index) {
if matches!(&tokens[prev_index].token, Token::Period) {
continue;
}
}
let function_start = line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let function_end = line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
let gap_end = line_col_to_offset(
sql,
tokens[next_index].span.start.line as usize,
tokens[next_index].span.start.column as usize,
)?;
if function_end >= gap_end {
continue;
}
return Some(FunctionSpacingIssueSpan {
function_start,
function_end,
gap_start: function_end,
gap_end,
});
}
None
}
fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
let dialect = dialect.to_sqlparser_dialect();
let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
tokenizer.tokenize_with_location().ok()
}
fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<TokenWithSpan>> {
let (statement_start_line, statement_start_column) =
offset_to_line_col(ctx.sql, ctx.statement_range.start)?;
ctx.with_document_tokens(|tokens| {
if tokens.is_empty() {
return None;
}
let mut out = Vec::new();
for token in tokens {
let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
continue;
};
if start < ctx.statement_range.start || end > ctx.statement_range.end {
continue;
}
let Some(start_loc) = relative_location(
token.span.start,
statement_start_line,
statement_start_column,
) else {
continue;
};
let Some(end_loc) =
relative_location(token.span.end, statement_start_line, statement_start_column)
else {
continue;
};
out.push(TokenWithSpan::new(
token.token.clone(),
Span::new(start_loc, end_loc),
));
}
if out.is_empty() {
None
} else {
Some(out)
}
})
}
fn tracked_function_names(statement: &Statement) -> HashSet<String> {
let mut names = HashSet::new();
visit_expressions(statement, &mut |expr| {
if let Expr::Function(function) = expr {
if let Some(last_part) = function.name.0.last() {
names.insert(last_part.to_string().to_ascii_uppercase());
}
}
});
names
}
fn next_non_trivia_index(tokens: &[TokenWithSpan], mut index: usize) -> Option<usize> {
while index < tokens.len() {
if !is_trivia_token(&tokens[index].token) {
return Some(index);
}
index += 1;
}
None
}
fn prev_non_trivia_index(tokens: &[TokenWithSpan], mut index: usize) -> Option<usize> {
while index > 0 {
index -= 1;
if !is_trivia_token(&tokens[index].token) {
return Some(index);
}
}
None
}
fn is_trivia_token(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(Whitespace::Space | Whitespace::Newline | Whitespace::Tab)
| Token::Whitespace(Whitespace::SingleLineComment { .. })
| Token::Whitespace(Whitespace::MultiLineComment(_))
)
}
fn is_always_function_keyword(word: &str) -> bool {
matches!(
word,
"CAST" | "TRY_CAST" | "SAFE_CAST" | "CONVERT" | "EXISTS"
)
}
fn is_select_projection_exists(tokens: &[TokenWithSpan], exists_index: usize) -> bool {
let Some(prev_index) = prev_non_trivia_index(tokens, exists_index) else {
return false;
};
match &tokens[prev_index].token {
Token::Comma => true,
Token::Word(word) => word.keyword == Keyword::SELECT,
_ => false,
}
}
fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
if line == 0 || column == 0 {
return None;
}
let mut current_line = 1usize;
let mut current_col = 1usize;
for (offset, ch) in sql.char_indices() {
if current_line == line && current_col == column {
return Some(offset);
}
if ch == '\n' {
current_line += 1;
current_col = 1;
} else {
current_col += 1;
}
}
if current_line == line && current_col == column {
return Some(sql.len());
}
None
}
fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
let start = line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let end = line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
Some((start, end))
}
fn offset_to_line_col(sql: &str, offset: usize) -> Option<(usize, usize)> {
if offset > sql.len() {
return None;
}
if offset == sql.len() {
let mut line = 1usize;
let mut column = 1usize;
for ch in sql.chars() {
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
return Some((line, column));
}
let mut line = 1usize;
let mut column = 1usize;
for (index, ch) in sql.char_indices() {
if index == offset {
return Some((line, column));
}
if ch == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
None
}
fn relative_location(
location: Location,
statement_start_line: usize,
statement_start_column: usize,
) -> Option<Location> {
let line = location.line as usize;
let column = location.column as usize;
if line < statement_start_line {
return None;
}
if line == statement_start_line {
if column < statement_start_column {
return None;
}
return Some(Location::new(
1,
(column - statement_start_column + 1) as u64,
));
}
Some(Location::new(
(line - statement_start_line + 1) as u64,
column as u64,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_sql;
use crate::types::IssueAutofixApplicability;
fn run(sql: &str) -> Vec<Issue> {
let statements = parse_sql(sql).expect("parse");
let rule = LayoutFunctions;
statements
.iter()
.enumerate()
.flat_map(|(index, statement)| {
rule.check(
statement,
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: index,
},
)
})
.collect()
}
fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
let autofix = issue.autofix.as_ref()?;
let mut edits = autofix.edits.clone();
edits.sort_by(|left, right| right.span.start.cmp(&left.span.start));
let mut out = sql.to_string();
for edit in edits {
out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
}
Some(out)
}
#[test]
fn flags_space_between_function_name_and_paren() {
let issues = run("SELECT COUNT (1) FROM t");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_LT_006);
}
#[test]
fn does_not_flag_normal_function_call() {
assert!(run("SELECT COUNT(1) FROM t").is_empty());
}
#[test]
fn does_not_flag_table_name_followed_by_paren() {
assert!(run("INSERT INTO metrics_table (id) VALUES (1)").is_empty());
}
#[test]
fn does_not_flag_string_literal_function_like_text() {
assert!(run("SELECT 'COUNT (1)' AS txt").is_empty());
}
#[test]
fn flags_space_between_cast_keyword_and_paren() {
let issues = run("SELECT CAST (1 AS INT)");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, issue_codes::LINT_LT_006);
}
#[test]
fn flags_space_between_exists_keyword_and_paren() {
let sql = "SELECT EXISTS (SELECT 1) AS has_rows";
let issues = run(sql);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT EXISTS(SELECT 1) AS has_rows");
}
#[test]
fn does_not_flag_where_exists_predicate_spacing() {
assert!(run("SELECT 1 FROM t WHERE NOT EXISTS (SELECT 1)").is_empty());
}
#[test]
fn emits_safe_autofix_patch_for_function_spacing() {
let sql = "SELECT COUNT (1) FROM t";
let issues = run(sql);
let issue = &issues[0];
let autofix = issue.autofix.as_ref().expect("autofix metadata");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
assert_eq!(autofix.edits.len(), 1);
assert_eq!(autofix.edits[0].replacement, "");
let fixed = apply_issue_autofix(sql, issue).expect("apply autofix");
assert_eq!(fixed, "SELECT COUNT(1) FROM t");
}
}