flowscope_core/linter/rules/
st_012.rs1use 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}