Skip to main content

flowscope_core/linter/rules/
lt_006.rs

1//! LINT_LT_006: Layout functions.
2//!
3//! SQLFluff LT06 parity (current scope): flag function-like tokens separated
4//! from opening parenthesis.
5
6use 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        // No whitespace/comment tokens between name and `(` means no spacing issue.
95        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}