Skip to main content

flowscope_core/linter/rules/
st_012.rs

1//! LINT_ST_012: Structure consecutive semicolons.
2//!
3//! SQLFluff ST12 parity (current scope): detect consecutive semicolons in the
4//! document text.
5
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10
11pub struct StructureConsecutiveSemicolons;
12
13impl LintRule for StructureConsecutiveSemicolons {
14    fn code(&self) -> &'static str {
15        issue_codes::LINT_ST_012
16    }
17
18    fn name(&self) -> &'static str {
19        "Structure consecutive semicolons"
20    }
21
22    fn description(&self) -> &'static str {
23        "Consecutive semicolons detected."
24    }
25
26    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
27        if ctx.statement_index > 0 {
28            Vec::new()
29        } else {
30            let tokens = tokenize_with_offsets_for_context(ctx);
31            let Some(fix) = consecutive_semicolon_fix(ctx.sql, ctx.dialect(), tokens.as_deref())
32            else {
33                return Vec::new();
34            };
35
36            let edits = fix
37                .remove_spans
38                .into_iter()
39                .map(|(start, end)| IssuePatchEdit::new(Span::new(start, end), ""))
40                .collect();
41
42            vec![
43                Issue::warning(issue_codes::LINT_ST_012, "Consecutive semicolons detected.")
44                    .with_statement(ctx.statement_index)
45                    .with_span(Span::new(fix.issue_start, fix.issue_end))
46                    .with_autofix_edits(IssueAutofixApplicability::Safe, edits),
47            ]
48        }
49    }
50}
51
52#[derive(Debug)]
53struct ConsecutiveSemicolonFix {
54    issue_start: usize,
55    issue_end: usize,
56    remove_spans: Vec<(usize, usize)>,
57}
58
59fn consecutive_semicolon_fix(
60    sql: &str,
61    dialect: Dialect,
62    tokens: Option<&[LocatedToken]>,
63) -> Option<ConsecutiveSemicolonFix> {
64    let owned_tokens;
65    let tokens = if let Some(tokens) = tokens {
66        tokens
67    } else {
68        owned_tokens = tokenize_with_offsets(sql, dialect)?;
69        &owned_tokens
70    };
71
72    let mut previous_semicolon_seen = false;
73    let mut remove_spans = Vec::new();
74
75    for token in tokens {
76        if is_trivia_token(&token.token) {
77            continue;
78        }
79
80        if matches!(token.token, Token::SemiColon) {
81            if previous_semicolon_seen {
82                remove_spans.push((token.start, token.end));
83            } else {
84                previous_semicolon_seen = true;
85            }
86        } else {
87            previous_semicolon_seen = false;
88        }
89    }
90
91    let (issue_start, issue_end) = remove_spans.first().copied()?;
92    Some(ConsecutiveSemicolonFix {
93        issue_start,
94        issue_end,
95        remove_spans,
96    })
97}
98
99#[derive(Clone)]
100struct LocatedToken {
101    token: Token,
102    start: usize,
103    end: usize,
104}
105
106fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
107    let dialect = dialect.to_sqlparser_dialect();
108    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
109    let tokens = tokenizer.tokenize_with_location().ok()?;
110
111    let mut out = Vec::with_capacity(tokens.len());
112    for token in tokens {
113        let start = line_col_to_offset(
114            sql,
115            token.span.start.line as usize,
116            token.span.start.column as usize,
117        )?;
118        let end = line_col_to_offset(
119            sql,
120            token.span.end.line as usize,
121            token.span.end.column as usize,
122        )?;
123        out.push(LocatedToken {
124            token: token.token,
125            start,
126            end,
127        });
128    }
129
130    Some(out)
131}
132
133fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
134    let tokens = ctx.with_document_tokens(|tokens| {
135        if tokens.is_empty() {
136            return None;
137        }
138
139        Some(
140            tokens
141                .iter()
142                .filter_map(|token| {
143                    token_with_span_offsets(ctx.sql, token).map(|(start, end)| LocatedToken {
144                        token: token.token.clone(),
145                        start,
146                        end,
147                    })
148                })
149                .collect::<Vec<_>>(),
150        )
151    });
152
153    if let Some(tokens) = tokens {
154        return Some(tokens);
155    }
156
157    tokenize_with_offsets(ctx.sql, ctx.dialect())
158}
159
160fn is_trivia_token(token: &Token) -> bool {
161    matches!(
162        token,
163        Token::Whitespace(Whitespace::Space | Whitespace::Newline | Whitespace::Tab)
164            | Token::Whitespace(Whitespace::SingleLineComment { .. })
165            | Token::Whitespace(Whitespace::MultiLineComment(_))
166    )
167}
168
169fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
170    if line == 0 || column == 0 {
171        return None;
172    }
173
174    let mut current_line = 1usize;
175    let mut current_col = 1usize;
176
177    for (offset, ch) in sql.char_indices() {
178        if current_line == line && current_col == column {
179            return Some(offset);
180        }
181
182        if ch == '\n' {
183            current_line += 1;
184            current_col = 1;
185        } else {
186            current_col += 1;
187        }
188    }
189
190    if current_line == line && current_col == column {
191        return Some(sql.len());
192    }
193
194    None
195}
196
197fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
198    let start = line_col_to_offset(
199        sql,
200        token.span.start.line as usize,
201        token.span.start.column as usize,
202    )?;
203    let end = line_col_to_offset(
204        sql,
205        token.span.end.line as usize,
206        token.span.end.column as usize,
207    )?;
208    Some((start, end))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::linter::rule::with_active_dialect;
215    use crate::parser::{parse_sql, parse_sql_with_dialect};
216    use crate::types::{Dialect, IssueAutofixApplicability};
217
218    fn run(sql: &str) -> Vec<Issue> {
219        let statements = parse_sql(sql).expect("parse");
220        let rule = StructureConsecutiveSemicolons;
221        statements
222            .iter()
223            .enumerate()
224            .flat_map(|(index, statement)| {
225                rule.check(
226                    statement,
227                    &LintContext {
228                        sql,
229                        statement_range: 0..sql.len(),
230                        statement_index: index,
231                    },
232                )
233            })
234            .collect()
235    }
236
237    fn run_in_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
238        let statements = parse_sql_with_dialect(sql, dialect).expect("parse");
239        let rule = StructureConsecutiveSemicolons;
240        let mut issues = Vec::new();
241
242        with_active_dialect(dialect, || {
243            for (index, statement) in statements.iter().enumerate() {
244                issues.extend(rule.check(
245                    statement,
246                    &LintContext {
247                        sql,
248                        statement_range: 0..sql.len(),
249                        statement_index: index,
250                    },
251                ));
252            }
253        });
254
255        issues
256    }
257
258    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
259        let autofix = issue.autofix.as_ref()?;
260        let mut out = sql.to_string();
261        let mut edits = autofix.edits.clone();
262        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
263        for edit in edits.iter().rev() {
264            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
265        }
266        Some(out)
267    }
268
269    #[test]
270    fn flags_consecutive_semicolons() {
271        let issues = run("SELECT 1;;");
272        assert_eq!(issues.len(), 1);
273        assert_eq!(issues[0].code, issue_codes::LINT_ST_012);
274        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
275        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
276        let fixed = apply_issue_autofix("SELECT 1;;", &issues[0]).expect("apply autofix");
277        assert_eq!(fixed, "SELECT 1;");
278    }
279
280    #[test]
281    fn does_not_flag_single_semicolon() {
282        let issues = run("SELECT 1;");
283        assert!(issues.is_empty());
284    }
285
286    #[test]
287    fn does_not_flag_semicolons_inside_string_literal() {
288        let issues = run("SELECT 'a;;b';");
289        assert!(issues.is_empty());
290    }
291
292    #[test]
293    fn does_not_flag_semicolons_inside_comments() {
294        let issues = run("SELECT 1 /* ;; */;");
295        assert!(issues.is_empty());
296    }
297
298    #[test]
299    fn flags_consecutive_semicolons_separated_by_comment() {
300        let sql = "SELECT 1; /* keep */ ;";
301        let issues = run(sql);
302        assert_eq!(issues.len(), 1);
303        assert_eq!(issues[0].code, issue_codes::LINT_ST_012);
304        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
305        assert!(
306            fixed.contains("/* keep */"),
307            "comment should be preserved after ST012 autofix: {fixed}"
308        );
309        assert_eq!(fixed.matches(';').count(), 1);
310    }
311
312    #[test]
313    fn does_not_flag_normal_statement_separator() {
314        let issues = run("SELECT 1; SELECT 2;");
315        assert!(issues.is_empty());
316    }
317
318    #[test]
319    fn mysql_hash_comment_is_treated_as_trivia() {
320        let sql = "SELECT 1; # dialect-specific comment\n;";
321        assert!(consecutive_semicolon_fix(sql, Dialect::Generic, None).is_none());
322        assert!(consecutive_semicolon_fix(sql, Dialect::Mysql, None).is_some());
323
324        let issues = run_in_dialect(sql, Dialect::Mysql);
325        assert_eq!(issues.len(), 1);
326        assert_eq!(issues[0].code, issue_codes::LINT_ST_012);
327    }
328}