flowscope_core/linter/rules/
lt_006.rs1use crate::linter::rule::{LintContext, LintRule};
7use crate::linter::visit::visit_expressions;
8use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
9use sqlparser::ast::{Expr, Statement};
10use sqlparser::keywords::Keyword;
11use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer, Whitespace};
12use std::collections::HashSet;
13
14pub struct LayoutFunctions;
15
16impl LintRule for LayoutFunctions {
17 fn code(&self) -> &'static str {
18 issue_codes::LINT_LT_006
19 }
20
21 fn name(&self) -> &'static str {
22 "Layout functions"
23 }
24
25 fn description(&self) -> &'static str {
26 "Function name not immediately followed by parenthesis."
27 }
28
29 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
30 let Some(issue_span) = function_spacing_issue_span(statement, ctx) else {
31 return Vec::new();
32 };
33
34 let function_span =
35 ctx.span_from_statement_offset(issue_span.function_start, issue_span.function_end);
36 let gap_span = ctx.span_from_statement_offset(issue_span.gap_start, issue_span.gap_end);
37
38 vec![Issue::info(
39 issue_codes::LINT_LT_006,
40 "Function call spacing appears inconsistent.",
41 )
42 .with_statement(ctx.statement_index)
43 .with_span(function_span)
44 .with_autofix_edits(
45 IssueAutofixApplicability::Safe,
46 vec![IssuePatchEdit::new(gap_span, "")],
47 )]
48 }
49}
50
51#[derive(Clone, Copy, Debug)]
52struct FunctionSpacingIssueSpan {
53 function_start: usize,
54 function_end: usize,
55 gap_start: usize,
56 gap_end: usize,
57}
58
59fn function_spacing_issue_span(
60 statement: &Statement,
61 ctx: &LintContext,
62) -> Option<FunctionSpacingIssueSpan> {
63 let sql = ctx.statement_sql();
64 let tracked_function_names = tracked_function_names(statement);
65
66 let tokens = tokenized_for_context(ctx).or_else(|| tokenized(sql, ctx.dialect()))?;
67
68 for (index, token) in tokens.iter().enumerate() {
69 let Token::Word(word) = &token.token else {
70 continue;
71 };
72
73 if word.quote_style.is_some() {
74 continue;
75 }
76
77 let word_upper = word.value.to_ascii_uppercase();
78 if !tracked_function_names.contains(&word_upper) && !is_always_function_keyword(&word_upper)
79 {
80 continue;
81 }
82 if word_upper == "EXISTS" && !is_select_projection_exists(&tokens, index) {
83 continue;
84 }
85
86 let Some(next_index) = next_non_trivia_index(&tokens, index + 1) else {
87 continue;
88 };
89
90 if !matches!(tokens[next_index].token, Token::LParen) {
91 continue;
92 }
93
94 if next_index == index + 1 {
96 continue;
97 }
98
99 if let Some(prev_index) = prev_non_trivia_index(&tokens, index) {
100 if matches!(&tokens[prev_index].token, Token::Period) {
101 continue;
102 }
103 }
104
105 let function_start = line_col_to_offset(
106 sql,
107 token.span.start.line as usize,
108 token.span.start.column as usize,
109 )?;
110 let function_end = line_col_to_offset(
111 sql,
112 token.span.end.line as usize,
113 token.span.end.column as usize,
114 )?;
115 let gap_end = line_col_to_offset(
116 sql,
117 tokens[next_index].span.start.line as usize,
118 tokens[next_index].span.start.column as usize,
119 )?;
120 if function_end >= gap_end {
121 continue;
122 }
123
124 return Some(FunctionSpacingIssueSpan {
125 function_start,
126 function_end,
127 gap_start: function_end,
128 gap_end,
129 });
130 }
131
132 None
133}
134
135fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
136 let dialect = dialect.to_sqlparser_dialect();
137 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
138 tokenizer.tokenize_with_location().ok()
139}
140
141fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<TokenWithSpan>> {
142 let (statement_start_line, statement_start_column) =
143 offset_to_line_col(ctx.sql, ctx.statement_range.start)?;
144
145 ctx.with_document_tokens(|tokens| {
146 if tokens.is_empty() {
147 return None;
148 }
149
150 let mut out = Vec::new();
151 for token in tokens {
152 let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
153 continue;
154 };
155 if start < ctx.statement_range.start || end > ctx.statement_range.end {
156 continue;
157 }
158
159 let Some(start_loc) = relative_location(
160 token.span.start,
161 statement_start_line,
162 statement_start_column,
163 ) else {
164 continue;
165 };
166 let Some(end_loc) =
167 relative_location(token.span.end, statement_start_line, statement_start_column)
168 else {
169 continue;
170 };
171
172 out.push(TokenWithSpan::new(
173 token.token.clone(),
174 Span::new(start_loc, end_loc),
175 ));
176 }
177
178 if out.is_empty() {
179 None
180 } else {
181 Some(out)
182 }
183 })
184}
185
186fn tracked_function_names(statement: &Statement) -> HashSet<String> {
187 let mut names = HashSet::new();
188 visit_expressions(statement, &mut |expr| {
189 if let Expr::Function(function) = expr {
190 if let Some(last_part) = function.name.0.last() {
191 names.insert(last_part.to_string().to_ascii_uppercase());
192 }
193 }
194 });
195 names
196}
197
198fn next_non_trivia_index(tokens: &[TokenWithSpan], mut index: usize) -> Option<usize> {
199 while index < tokens.len() {
200 if !is_trivia_token(&tokens[index].token) {
201 return Some(index);
202 }
203 index += 1;
204 }
205 None
206}
207
208fn prev_non_trivia_index(tokens: &[TokenWithSpan], mut index: usize) -> Option<usize> {
209 while index > 0 {
210 index -= 1;
211 if !is_trivia_token(&tokens[index].token) {
212 return Some(index);
213 }
214 }
215 None
216}
217
218fn is_trivia_token(token: &Token) -> bool {
219 matches!(
220 token,
221 Token::Whitespace(Whitespace::Space | Whitespace::Newline | Whitespace::Tab)
222 | Token::Whitespace(Whitespace::SingleLineComment { .. })
223 | Token::Whitespace(Whitespace::MultiLineComment(_))
224 )
225}
226
227fn is_always_function_keyword(word: &str) -> bool {
228 matches!(
229 word,
230 "CAST" | "TRY_CAST" | "SAFE_CAST" | "CONVERT" | "EXISTS"
231 )
232}
233
234fn is_select_projection_exists(tokens: &[TokenWithSpan], exists_index: usize) -> bool {
235 let Some(prev_index) = prev_non_trivia_index(tokens, exists_index) else {
236 return false;
237 };
238
239 match &tokens[prev_index].token {
240 Token::Comma => true,
241 Token::Word(word) => word.keyword == Keyword::SELECT,
242 _ => false,
243 }
244}
245
246fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
247 if line == 0 || column == 0 {
248 return None;
249 }
250
251 let mut current_line = 1usize;
252 let mut current_col = 1usize;
253
254 for (offset, ch) in sql.char_indices() {
255 if current_line == line && current_col == column {
256 return Some(offset);
257 }
258
259 if ch == '\n' {
260 current_line += 1;
261 current_col = 1;
262 } else {
263 current_col += 1;
264 }
265 }
266
267 if current_line == line && current_col == column {
268 return Some(sql.len());
269 }
270
271 None
272}
273
274fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
275 let start = line_col_to_offset(
276 sql,
277 token.span.start.line as usize,
278 token.span.start.column as usize,
279 )?;
280 let end = line_col_to_offset(
281 sql,
282 token.span.end.line as usize,
283 token.span.end.column as usize,
284 )?;
285 Some((start, end))
286}
287
288fn offset_to_line_col(sql: &str, offset: usize) -> Option<(usize, usize)> {
289 if offset > sql.len() {
290 return None;
291 }
292 if offset == sql.len() {
293 let mut line = 1usize;
294 let mut column = 1usize;
295 for ch in sql.chars() {
296 if ch == '\n' {
297 line += 1;
298 column = 1;
299 } else {
300 column += 1;
301 }
302 }
303 return Some((line, column));
304 }
305
306 let mut line = 1usize;
307 let mut column = 1usize;
308 for (index, ch) in sql.char_indices() {
309 if index == offset {
310 return Some((line, column));
311 }
312 if ch == '\n' {
313 line += 1;
314 column = 1;
315 } else {
316 column += 1;
317 }
318 }
319
320 None
321}
322
323fn relative_location(
324 location: Location,
325 statement_start_line: usize,
326 statement_start_column: usize,
327) -> Option<Location> {
328 let line = location.line as usize;
329 let column = location.column as usize;
330 if line < statement_start_line {
331 return None;
332 }
333
334 if line == statement_start_line {
335 if column < statement_start_column {
336 return None;
337 }
338 return Some(Location::new(
339 1,
340 (column - statement_start_column + 1) as u64,
341 ));
342 }
343
344 Some(Location::new(
345 (line - statement_start_line + 1) as u64,
346 column as u64,
347 ))
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::parser::parse_sql;
354 use crate::types::IssueAutofixApplicability;
355
356 fn run(sql: &str) -> Vec<Issue> {
357 let statements = parse_sql(sql).expect("parse");
358 let rule = LayoutFunctions;
359 statements
360 .iter()
361 .enumerate()
362 .flat_map(|(index, statement)| {
363 rule.check(
364 statement,
365 &LintContext {
366 sql,
367 statement_range: 0..sql.len(),
368 statement_index: index,
369 },
370 )
371 })
372 .collect()
373 }
374
375 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
376 let autofix = issue.autofix.as_ref()?;
377 let mut edits = autofix.edits.clone();
378 edits.sort_by(|left, right| right.span.start.cmp(&left.span.start));
379
380 let mut out = sql.to_string();
381 for edit in edits {
382 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
383 }
384 Some(out)
385 }
386
387 #[test]
388 fn flags_space_between_function_name_and_paren() {
389 let issues = run("SELECT COUNT (1) FROM t");
390 assert_eq!(issues.len(), 1);
391 assert_eq!(issues[0].code, issue_codes::LINT_LT_006);
392 }
393
394 #[test]
395 fn does_not_flag_normal_function_call() {
396 assert!(run("SELECT COUNT(1) FROM t").is_empty());
397 }
398
399 #[test]
400 fn does_not_flag_table_name_followed_by_paren() {
401 assert!(run("INSERT INTO metrics_table (id) VALUES (1)").is_empty());
402 }
403
404 #[test]
405 fn does_not_flag_string_literal_function_like_text() {
406 assert!(run("SELECT 'COUNT (1)' AS txt").is_empty());
407 }
408
409 #[test]
410 fn flags_space_between_cast_keyword_and_paren() {
411 let issues = run("SELECT CAST (1 AS INT)");
412 assert_eq!(issues.len(), 1);
413 assert_eq!(issues[0].code, issue_codes::LINT_LT_006);
414 }
415
416 #[test]
417 fn flags_space_between_exists_keyword_and_paren() {
418 let sql = "SELECT EXISTS (SELECT 1) AS has_rows";
419 let issues = run(sql);
420 assert_eq!(issues.len(), 1);
421 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
422 assert_eq!(fixed, "SELECT EXISTS(SELECT 1) AS has_rows");
423 }
424
425 #[test]
426 fn does_not_flag_where_exists_predicate_spacing() {
427 assert!(run("SELECT 1 FROM t WHERE NOT EXISTS (SELECT 1)").is_empty());
428 }
429
430 #[test]
431 fn emits_safe_autofix_patch_for_function_spacing() {
432 let sql = "SELECT COUNT (1) FROM t";
433 let issues = run(sql);
434 let issue = &issues[0];
435 let autofix = issue.autofix.as_ref().expect("autofix metadata");
436
437 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
438 assert_eq!(autofix.edits.len(), 1);
439 assert_eq!(autofix.edits[0].replacement, "");
440
441 let fixed = apply_issue_autofix(sql, issue).expect("apply autofix");
442 assert_eq!(fixed, "SELECT COUNT(1) FROM t");
443 }
444}