Skip to main content

flowscope_core/linter/rules/
tq_003.rs

1//! LINT_TQ_003: TSQL empty batch.
2//!
3//! SQLFluff TQ03 parity (current scope): detect empty batches between repeated
4//! `GO` separators.
5
6use 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        // TQ003 is document-level: GO separators can sit outside statement
33        // spans, so evaluate once against the full SQL document.
34        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}