flowscope_core/linter/rules/
tq_003.rs1use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub struct TsqlEmptyBatch;
13
14impl LintRule for TsqlEmptyBatch {
15 fn code(&self) -> &'static str {
16 issue_codes::LINT_TQ_003
17 }
18
19 fn name(&self) -> &'static str {
20 "TSQL empty batch"
21 }
22
23 fn description(&self) -> &'static str {
24 "Remove empty batches."
25 }
26
27 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
28 if ctx.dialect() != Dialect::Mssql {
29 return Vec::new();
30 }
31
32 if ctx.statement_index != 0 {
35 return Vec::new();
36 }
37
38 let has_violation = has_empty_go_batch_separator(ctx.sql, ctx.dialect(), None);
39 if !has_violation {
40 return Vec::new();
41 }
42
43 let mut issue = Issue::warning(
44 issue_codes::LINT_TQ_003,
45 "Empty TSQL batch detected between GO separators.",
46 )
47 .with_statement(ctx.statement_index);
48
49 let autofix_edits = empty_go_batch_separator_edits(ctx.sql)
50 .into_iter()
51 .map(|edit| {
52 IssuePatchEdit::new(
53 crate::types::Span::new(edit.start, edit.end),
54 edit.replacement,
55 )
56 })
57 .collect::<Vec<_>>();
58
59 if !autofix_edits.is_empty() {
60 issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
61 }
62
63 vec![issue]
64 }
65}
66
67fn has_empty_go_batch_separator(
68 sql: &str,
69 dialect: Dialect,
70 tokens: Option<&[TokenWithSpan]>,
71) -> bool {
72 let owned_tokens;
73 let tokens = if let Some(tokens) = tokens {
74 tokens
75 } else {
76 owned_tokens = match tokenized(sql, dialect) {
77 Some(tokens) => tokens,
78 None => return false,
79 };
80 &owned_tokens
81 };
82
83 let mut line_summary = BTreeMap::<usize, LineSummary>::new();
84 let mut go_candidate_lines = BTreeSet::<usize>::new();
85
86 for token in tokens {
87 update_line_summary(&mut line_summary, token);
88 if let Token::Word(word) = &token.token {
89 if word.value.eq_ignore_ascii_case("GO") {
90 go_candidate_lines.insert(token.span.start.line as usize);
91 }
92 }
93 }
94
95 let mut go_lines = go_candidate_lines
96 .into_iter()
97 .filter(|line| {
98 line_summary
99 .get(line)
100 .is_some_and(|summary| summary.is_go_separator())
101 })
102 .collect::<Vec<_>>();
103
104 if go_lines.len() < 2 {
105 return false;
106 }
107
108 go_lines.sort_unstable();
109 go_lines.dedup();
110
111 go_lines
112 .windows(2)
113 .any(|pair| lines_between_are_empty(&line_summary, pair[0], pair[1]))
114}
115
116fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
117 let dialect = dialect.to_sqlparser_dialect();
118 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
119 tokenizer.tokenize_with_location().ok()
120}
121
122#[derive(Default, Clone, Copy)]
123struct LineSummary {
124 go_count: usize,
125 other_count: usize,
126}
127
128impl LineSummary {
129 fn is_go_separator(self) -> bool {
130 self.go_count == 1 && self.other_count == 0
131 }
132}
133
134fn update_line_summary(summary: &mut BTreeMap<usize, LineSummary>, token: &TokenWithSpan) {
135 let start_line = token.span.start.line as usize;
136 let end_line = token.span.end.line as usize;
137
138 match &token.token {
139 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline) => {}
140 Token::Whitespace(Whitespace::SingleLineComment { .. }) => {
141 summary.entry(start_line).or_default().other_count += 1;
142 }
143 Token::Whitespace(Whitespace::MultiLineComment(_)) => {
144 for line in start_line..=end_line {
145 summary.entry(line).or_default().other_count += 1;
146 }
147 }
148 Token::Word(word) if word.value.eq_ignore_ascii_case("GO") && start_line == end_line => {
149 summary.entry(start_line).or_default().go_count += 1;
150 }
151 _ => {
152 for line in start_line..=end_line {
153 summary.entry(line).or_default().other_count += 1;
154 }
155 }
156 }
157}
158
159fn lines_between_are_empty(
160 line_summary: &BTreeMap<usize, LineSummary>,
161 first_line: usize,
162 second_line: usize,
163) -> bool {
164 if second_line <= first_line {
165 return false;
166 }
167
168 if second_line == first_line + 1 {
169 return true;
170 }
171
172 ((first_line + 1)..second_line).all(|line_number| !line_summary.contains_key(&line_number))
173}
174
175struct Tq003AutofixEdit {
176 start: usize,
177 end: usize,
178 replacement: String,
179}
180
181fn empty_go_batch_separator_edits(sql: &str) -> Vec<Tq003AutofixEdit> {
182 let bytes = sql.as_bytes();
183 let mut edits = Vec::new();
184 let mut index = 0usize;
185
186 while index < bytes.len() {
187 if bytes[index] != b'\n' {
188 index += 1;
189 continue;
190 }
191
192 let mut cursor = index;
193 let mut batch_count = 0usize;
194 while cursor < bytes.len() && bytes[cursor] == b'\n' {
195 let mut go_start = cursor + 1;
196 while go_start < bytes.len() && is_ascii_whitespace_non_newline_byte(bytes[go_start]) {
197 go_start += 1;
198 }
199 let Some(go_end) = match_ascii_keyword_at(bytes, go_start, b"GO") else {
200 break;
201 };
202 let mut after_go = go_end;
203 while after_go < bytes.len() && is_ascii_whitespace_non_newline_byte(bytes[after_go]) {
204 after_go += 1;
205 }
206 batch_count += 1;
207 cursor = after_go;
208 }
209
210 if batch_count >= 2 {
211 edits.push(Tq003AutofixEdit {
212 start: index,
213 end: cursor,
214 replacement: "\nGO".to_string(),
215 });
216 index = cursor;
217 } else {
218 index += 1;
219 }
220 }
221
222 edits
223}
224
225fn is_ascii_whitespace_non_newline_byte(byte: u8) -> bool {
226 byte.is_ascii_whitespace() && byte != b'\n'
227}
228
229fn is_ascii_ident_continue(byte: u8) -> bool {
230 byte.is_ascii_alphanumeric() || byte == b'_'
231}
232
233fn is_word_boundary_for_keyword(bytes: &[u8], idx: usize) -> bool {
234 idx == 0 || idx >= bytes.len() || !is_ascii_ident_continue(bytes[idx])
235}
236
237fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> Option<usize> {
238 let end = start.checked_add(keyword_upper.len())?;
239 if end > bytes.len() {
240 return None;
241 }
242 if !is_word_boundary_for_keyword(bytes, start.saturating_sub(1))
243 || !is_word_boundary_for_keyword(bytes, end)
244 {
245 return None;
246 }
247 let matches = bytes[start..end]
248 .iter()
249 .zip(keyword_upper.iter())
250 .all(|(actual, expected)| actual.to_ascii_uppercase() == *expected);
251 if matches {
252 Some(end)
253 } else {
254 None
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::linter::rule::with_active_dialect;
262 use crate::parser::parse_sql;
263 use crate::types::IssueAutofixApplicability;
264
265 fn run(sql: &str) -> Vec<Issue> {
266 let statements = parse_sql(sql).expect("parse");
267 let rule = TsqlEmptyBatch;
268 with_active_dialect(Dialect::Mssql, || {
269 statements
270 .iter()
271 .enumerate()
272 .flat_map(|(index, statement)| {
273 rule.check(
274 statement,
275 &LintContext {
276 sql,
277 statement_range: 0..sql.len(),
278 statement_index: index,
279 },
280 )
281 })
282 .collect()
283 })
284 }
285
286 fn run_for_statement_sql(sql: &str) -> Vec<Issue> {
287 let statements = parse_sql("SELECT 1").expect("parse placeholder statement");
288 let rule = TsqlEmptyBatch;
289 with_active_dialect(Dialect::Mssql, || {
290 rule.check(
291 &statements[0],
292 &LintContext {
293 sql,
294 statement_range: 0..sql.len(),
295 statement_index: 0,
296 },
297 )
298 })
299 }
300
301 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
302 let autofix = issue.autofix.as_ref()?;
303 let mut out = sql.to_string();
304 let mut edits = autofix.edits.clone();
305 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
306 for edit in edits.into_iter().rev() {
307 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
308 }
309 Some(out)
310 }
311
312 #[test]
313 fn detects_repeated_go_separator_lines() {
314 assert!(has_empty_go_batch_separator(
315 "GO\nGO\n",
316 Dialect::Generic,
317 None
318 ));
319 assert!(has_empty_go_batch_separator(
320 "GO\n\nGO\n",
321 Dialect::Generic,
322 None
323 ));
324 }
325
326 #[test]
327 fn does_not_detect_single_go_separator_line() {
328 assert!(!has_empty_go_batch_separator(
329 "GO\n",
330 Dialect::Generic,
331 None
332 ));
333 }
334
335 #[test]
336 fn does_not_detect_go_text_inside_string_literal() {
337 assert!(!has_empty_go_batch_separator(
338 "SELECT '\nGO\nGO\n' AS sql_snippet",
339 Dialect::Generic,
340 None,
341 ));
342 }
343
344 #[test]
345 fn detects_empty_go_batches_between_statements() {
346 assert!(has_empty_go_batch_separator(
347 "SELECT 1\nGO\nGO\nSELECT 2\n",
348 Dialect::Generic,
349 None,
350 ));
351 }
352
353 #[test]
354 fn emits_safe_autofix_for_empty_go_batches() {
355 let sql = "SELECT 1\nGO\nGO\nSELECT 2\n";
356 let issues = run_for_statement_sql(sql);
357 assert_eq!(issues.len(), 1);
358 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
359 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
360 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
361 assert_eq!(fixed, "SELECT 1\nGO\nSELECT 2\n");
362 }
363
364 #[test]
365 fn does_not_treat_comment_line_between_go_as_empty_batch() {
366 assert!(!has_empty_go_batch_separator(
367 "GO\n-- keep batch non-empty\nGO\n",
368 Dialect::Generic,
369 None,
370 ));
371 }
372
373 #[test]
374 fn rule_does_not_flag_go_text_inside_string_literal() {
375 let issues = run("SELECT '\nGO\nGO\n' AS sql_snippet");
376 assert!(issues.is_empty());
377 }
378
379 #[test]
380 fn rule_does_not_run_for_non_mssql_dialect() {
381 let statements = parse_sql("SELECT 1").expect("parse placeholder statement");
382 let rule = TsqlEmptyBatch;
383 let sql = "SELECT 1\nGO\nGO\n";
384 let issues = with_active_dialect(Dialect::Postgres, || {
385 rule.check(
386 &statements[0],
387 &LintContext {
388 sql,
389 statement_range: 0..sql.len(),
390 statement_index: 0,
391 },
392 )
393 });
394 assert!(issues.is_empty());
395 }
396}