Skip to main content

flowscope_core/linter/rules/
lt_015.rs

1//! LINT_LT_015: Layout newlines.
2//!
3//! SQLFluff LT15 parity (current scope): detect excessive blank lines.
4
5use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::Statement;
9use sqlparser::tokenizer::{
10    Location, Span as TokenSpan, Token, TokenWithSpan, Tokenizer, Whitespace,
11};
12use std::ops::Range;
13
14pub struct LayoutNewlines {
15    maximum_empty_lines_inside_statements: usize,
16    maximum_empty_lines_between_statements: usize,
17    maximum_empty_lines_between_batches: Option<usize>,
18}
19
20impl LayoutNewlines {
21    pub fn from_config(config: &LintConfig) -> Self {
22        Self {
23            maximum_empty_lines_inside_statements: config
24                .rule_option_usize(
25                    issue_codes::LINT_LT_015,
26                    "maximum_empty_lines_inside_statements",
27                )
28                .unwrap_or(1),
29            maximum_empty_lines_between_statements: config
30                .rule_option_usize(
31                    issue_codes::LINT_LT_015,
32                    "maximum_empty_lines_between_statements",
33                )
34                .unwrap_or(1),
35            maximum_empty_lines_between_batches: config.rule_option_usize(
36                issue_codes::LINT_LT_015,
37                "maximum_empty_lines_between_batches",
38            ),
39        }
40    }
41}
42
43impl Default for LayoutNewlines {
44    fn default() -> Self {
45        Self {
46            maximum_empty_lines_inside_statements: 1,
47            maximum_empty_lines_between_statements: 1,
48            maximum_empty_lines_between_batches: None,
49        }
50    }
51}
52
53impl LintRule for LayoutNewlines {
54    fn code(&self) -> &'static str {
55        issue_codes::LINT_LT_015
56    }
57
58    fn name(&self) -> &'static str {
59        "Layout newlines"
60    }
61
62    fn description(&self) -> &'static str {
63        "Too many consecutive blank lines."
64    }
65
66    fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
67        let (inside_range, statement_sql) = trimmed_statement_range_and_sql(ctx);
68        let inside_tokens = tokenized_for_range(ctx, inside_range.clone());
69        let effective_batch_limit = self
70            .maximum_empty_lines_between_batches
71            .unwrap_or(self.maximum_empty_lines_between_statements);
72        let inside_blank_run = if ctx.dialect() == Dialect::Mssql
73            && contains_tsql_batch_separator_line(statement_sql)
74        {
75            max_consecutive_blank_lines_in_tsql_batches(statement_sql)
76        } else {
77            max_consecutive_blank_lines(statement_sql, ctx.dialect(), inside_tokens.as_deref())
78        };
79        let excessive_inside = inside_blank_run > self.maximum_empty_lines_inside_statements;
80
81        let mut gap_range = None;
82        let excessive_between = if ctx.statement_index > 0 {
83            let range = inter_statement_gap_range(ctx.sql, ctx.statement_range.start);
84            let gap_sql = &ctx.sql[range.clone()];
85            let gap_tokens = tokenized_for_range(ctx, range.clone());
86            gap_range = Some(range);
87            if ctx.dialect() == Dialect::Mssql && contains_tsql_batch_separator_line(gap_sql) {
88                max_blank_lines_around_tsql_batch_separator(gap_sql) > effective_batch_limit
89            } else if ctx.dialect() == Dialect::Mssql {
90                blank_lines_in_inter_statement_gap(gap_sql, gap_tokens.as_deref())
91                    > self.maximum_empty_lines_between_statements
92            } else {
93                max_consecutive_blank_lines(gap_sql, ctx.dialect(), gap_tokens.as_deref())
94                    > self.maximum_empty_lines_between_statements
95            }
96        } else {
97            false
98        };
99
100        if excessive_inside || excessive_between {
101            let mut edits = Vec::new();
102            if excessive_inside {
103                edits.extend(excessive_blank_line_edits_for_range(
104                    ctx.sql,
105                    inside_range.clone(),
106                    self.maximum_empty_lines_inside_statements,
107                ));
108            }
109            if excessive_between {
110                if let Some(range) = gap_range {
111                    let max_gap_lines = if ctx.dialect() == Dialect::Mssql {
112                        effective_batch_limit
113                    } else {
114                        self.maximum_empty_lines_between_statements
115                    };
116                    edits.extend(excessive_blank_line_edits_for_range(
117                        ctx.sql,
118                        range,
119                        max_gap_lines,
120                    ));
121                }
122            }
123
124            let mut issue = Issue::info(
125                issue_codes::LINT_LT_015,
126                "SQL contains excessive blank lines.",
127            )
128            .with_statement(ctx.statement_index);
129            if let Some(first_edit) = edits.first() {
130                issue = issue.with_span(first_edit.span);
131            }
132            if !edits.is_empty() {
133                issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
134            }
135            vec![issue]
136        } else {
137            Vec::new()
138        }
139    }
140}
141
142fn trimmed_statement_range_and_sql<'a>(ctx: &'a LintContext) -> (Range<usize>, &'a str) {
143    if let Some(range) = trimmed_statement_range_from_tokens(ctx) {
144        return (range.clone(), &ctx.sql[range]);
145    }
146
147    let statement_sql = ctx.statement_sql();
148    let (start, end) = trim_ascii_whitespace_bounds(statement_sql);
149
150    (
151        (ctx.statement_range.start + start)..(ctx.statement_range.start + end),
152        &statement_sql[start..end],
153    )
154}
155
156fn trim_ascii_whitespace_bounds(sql: &str) -> (usize, usize) {
157    let mut start = sql.len();
158    for (index, ch) in sql.char_indices() {
159        if !ch.is_ascii_whitespace() {
160            start = index;
161            break;
162        }
163    }
164    if start == sql.len() {
165        return (sql.len(), sql.len());
166    }
167
168    let mut end = start;
169    for (index, ch) in sql.char_indices().rev() {
170        if !ch.is_ascii_whitespace() {
171            end = index + ch.len_utf8();
172            break;
173        }
174    }
175
176    (start, end)
177}
178
179fn trimmed_statement_range_from_tokens(ctx: &LintContext) -> Option<Range<usize>> {
180    let statement_start = ctx.statement_range.start;
181    let statement_end = ctx.statement_range.end;
182
183    ctx.with_document_tokens(|tokens| {
184        if tokens.is_empty() {
185            return None;
186        }
187
188        let mut first = None::<usize>;
189        let mut last = None::<usize>;
190
191        for token in tokens {
192            let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
193                continue;
194            };
195            if start < statement_start || end > statement_end {
196                continue;
197            }
198            if is_spacing_whitespace_token(&token.token) {
199                continue;
200            }
201
202            first = Some(first.map_or(start, |current| current.min(start)));
203            last = Some(last.map_or(end, |current| current.max(end)));
204        }
205
206        Some(match (first, last) {
207            (Some(start), Some(end)) => start..end,
208            _ => statement_start..statement_start,
209        })
210    })
211}
212
213fn max_consecutive_blank_lines(
214    sql: &str,
215    dialect: Dialect,
216    tokens: Option<&[TokenWithSpan]>,
217) -> usize {
218    max_consecutive_blank_lines_tokenized(sql, dialect, tokens)
219}
220
221fn max_consecutive_blank_lines_tokenized(
222    sql: &str,
223    dialect: Dialect,
224    tokens: Option<&[TokenWithSpan]>,
225) -> usize {
226    if sql.is_empty() {
227        return 0;
228    }
229
230    let owned_tokens;
231    let tokens = if let Some(tokens) = tokens {
232        tokens
233    } else {
234        owned_tokens = match tokenized(sql, dialect) {
235            Some(tokens) => tokens,
236            None => return 0,
237        };
238        &owned_tokens
239    };
240
241    let mut non_blank_lines = std::collections::BTreeSet::new();
242    for token in tokens {
243        if is_spacing_whitespace_token(&token.token) {
244            continue;
245        }
246        let start_line = token.span.start.line as usize;
247        let end_line = match &token.token {
248            Token::Whitespace(Whitespace::SingleLineComment { .. }) => start_line,
249            _ => token.span.end.line as usize,
250        };
251        for line in start_line..=end_line {
252            non_blank_lines.insert(line);
253        }
254    }
255    if dialect == Dialect::Mssql {
256        mark_tsql_batch_separator_lines(sql, &mut non_blank_lines);
257    }
258
259    let mut blank_run = 0usize;
260    let mut max_run = 0usize;
261    let line_count = line_count_from_tokens_or_sql(sql, tokens);
262
263    for line in 1..=line_count {
264        if non_blank_lines.contains(&line) {
265            blank_run = 0;
266        } else {
267            blank_run += 1;
268            max_run = max_run.max(blank_run);
269        }
270    }
271
272    max_run
273}
274
275fn contains_tsql_batch_separator_line(sql: &str) -> bool {
276    sql.lines()
277        .any(|line| line.trim().eq_ignore_ascii_case("GO"))
278}
279
280fn max_consecutive_blank_lines_in_tsql_batches(sql: &str) -> usize {
281    let mut batches = Vec::<String>::new();
282    let mut current = String::new();
283
284    for line in sql.split_inclusive('\n') {
285        if line
286            .trim_end_matches(['\n', '\r'])
287            .trim()
288            .eq_ignore_ascii_case("GO")
289        {
290            batches.push(std::mem::take(&mut current));
291        } else {
292            current.push_str(line);
293        }
294    }
295    if !current.is_empty() {
296        batches.push(current);
297    }
298
299    if batches.is_empty() {
300        return 0;
301    }
302
303    batches
304        .iter()
305        .map(|batch| {
306            let (start, end) = trim_ascii_whitespace_bounds(batch);
307            if start >= end {
308                0
309            } else {
310                max_consecutive_blank_lines(&batch[start..end], Dialect::Mssql, None)
311            }
312        })
313        .max()
314        .unwrap_or(0)
315}
316
317fn max_blank_lines_around_tsql_batch_separator(gap_sql: &str) -> usize {
318    let lines: Vec<&str> = gap_sql.split('\n').collect();
319    let mut max_blank = 0usize;
320
321    for (index, line) in lines.iter().enumerate() {
322        if !line.trim().eq_ignore_ascii_case("GO") {
323            continue;
324        }
325
326        let mut before = 0usize;
327        let mut cursor = index;
328        while cursor > 0 {
329            let prev = lines[cursor - 1].trim_end_matches('\r');
330            if !prev.trim().is_empty() {
331                break;
332            }
333            before += 1;
334            cursor -= 1;
335        }
336
337        let mut after = 0usize;
338        let mut cursor = index + 1;
339        while cursor < lines.len() {
340            let next = lines[cursor].trim_end_matches('\r');
341            if !next.trim().is_empty() {
342                break;
343            }
344            after += 1;
345            cursor += 1;
346        }
347
348        max_blank = max_blank.max(before.saturating_sub(1));
349        max_blank = max_blank.max(after.saturating_sub(1));
350    }
351
352    max_blank
353}
354
355fn blank_lines_in_inter_statement_gap(gap_sql: &str, tokens: Option<&[TokenWithSpan]>) -> usize {
356    if gap_sql.is_empty() {
357        return 0;
358    }
359
360    if gap_sql.chars().all(|ch| ch.is_ascii_whitespace()) {
361        return count_line_breaks(gap_sql).saturating_sub(1);
362    }
363
364    max_consecutive_blank_lines(gap_sql, Dialect::Mssql, tokens)
365}
366
367fn mark_tsql_batch_separator_lines(
368    sql: &str,
369    non_blank_lines: &mut std::collections::BTreeSet<usize>,
370) {
371    for (line_index, line) in sql.lines().enumerate() {
372        if line.trim().eq_ignore_ascii_case("GO") {
373            non_blank_lines.insert(line_index + 1);
374        }
375    }
376}
377
378fn line_count_from_tokens_or_sql(sql: &str, tokens: &[TokenWithSpan]) -> usize {
379    let token_line_max = tokens
380        .iter()
381        .map(|token| match &token.token {
382            Token::Whitespace(Whitespace::SingleLineComment { .. }) => token.span.start.line,
383            _ => token.span.end.line,
384        } as usize)
385        .max()
386        .unwrap_or(0);
387    let fallback = count_line_breaks(sql) + 1;
388    token_line_max.max(fallback)
389}
390
391fn count_line_breaks(text: &str) -> usize {
392    let mut count = 0usize;
393    let mut chars = text.chars().peekable();
394    while let Some(ch) = chars.next() {
395        if ch == '\n' {
396            count += 1;
397            continue;
398        }
399        if ch == '\r' {
400            count += 1;
401            if matches!(chars.peek(), Some('\n')) {
402                let _ = chars.next();
403            }
404        }
405    }
406    count
407}
408
409fn is_spacing_whitespace_token(token: &Token) -> bool {
410    matches!(
411        token,
412        Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
413    )
414}
415
416fn inter_statement_gap_range(sql: &str, statement_start: usize) -> Range<usize> {
417    let before = &sql[..statement_start];
418    let boundary = before
419        .char_indices()
420        .rev()
421        .find(|(_, ch)| !ch.is_ascii_whitespace())
422        .map(|(idx, ch)| idx + ch.len_utf8())
423        .unwrap_or(0);
424    boundary..statement_start
425}
426
427fn excessive_blank_line_edits_for_range(
428    sql: &str,
429    range: Range<usize>,
430    max_empty_lines: usize,
431) -> Vec<IssuePatchEdit> {
432    if range.is_empty() || range.end > sql.len() {
433        return Vec::new();
434    }
435
436    let bytes = sql.as_bytes();
437    let allowed_newlines = max_empty_lines.saturating_add(1);
438    let replacement = "\n".repeat(allowed_newlines);
439    let mut edits = Vec::new();
440
441    let mut i = range.start;
442    while i < range.end {
443        if bytes[i] != b'\n' {
444            i += 1;
445            continue;
446        }
447
448        let mut j = i + 1;
449        let mut newline_count = 1usize;
450        while j < range.end {
451            let mut k = j;
452            while k < range.end && is_ascii_whitespace_byte(bytes[k]) && bytes[k] != b'\n' {
453                k += 1;
454            }
455            if k < range.end && bytes[k] == b'\n' {
456                newline_count += 1;
457                j = k + 1;
458            } else {
459                break;
460            }
461        }
462
463        if newline_count > allowed_newlines {
464            edits.push(IssuePatchEdit::new(Span::new(i, j), replacement.clone()));
465        }
466        i = j;
467    }
468
469    edits
470}
471
472fn is_ascii_whitespace_byte(byte: u8) -> bool {
473    (byte as char).is_ascii_whitespace()
474}
475
476fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<TokenWithSpan>> {
477    let dialect = dialect.to_sqlparser_dialect();
478    let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
479    tokenizer.tokenize_with_location().ok()
480}
481
482fn tokenized_for_range(ctx: &LintContext, range: Range<usize>) -> Option<Vec<TokenWithSpan>> {
483    if range.is_empty() {
484        return Some(Vec::new());
485    }
486
487    let (range_start_line, range_start_column) = offset_to_line_col(ctx.sql, range.start)?;
488    ctx.with_document_tokens(|tokens| {
489        if tokens.is_empty() {
490            return None;
491        }
492
493        let mut out = Vec::new();
494        for token in tokens {
495            let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
496                continue;
497            };
498            if start < range.start || end > range.end {
499                continue;
500            }
501
502            let Some(start_loc) =
503                relative_location(token.span.start, range_start_line, range_start_column)
504            else {
505                continue;
506            };
507            let Some(end_loc) =
508                relative_location(token.span.end, range_start_line, range_start_column)
509            else {
510                continue;
511            };
512
513            out.push(TokenWithSpan::new(
514                token.token.clone(),
515                TokenSpan::new(start_loc, end_loc),
516            ));
517        }
518
519        Some(out)
520    })
521}
522
523fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
524    if line == 0 || column == 0 {
525        return None;
526    }
527
528    let mut current_line = 1usize;
529    let mut current_col = 1usize;
530
531    for (offset, ch) in sql.char_indices() {
532        if current_line == line && current_col == column {
533            return Some(offset);
534        }
535
536        if ch == '\n' {
537            current_line += 1;
538            current_col = 1;
539        } else {
540            current_col += 1;
541        }
542    }
543
544    if current_line == line && current_col == column {
545        return Some(sql.len());
546    }
547
548    None
549}
550
551fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
552    let start = line_col_to_offset(
553        sql,
554        token.span.start.line as usize,
555        token.span.start.column as usize,
556    )?;
557    let end = line_col_to_offset(
558        sql,
559        token.span.end.line as usize,
560        token.span.end.column as usize,
561    )?;
562    Some((start, end))
563}
564
565fn offset_to_line_col(sql: &str, offset: usize) -> Option<(usize, usize)> {
566    if offset > sql.len() {
567        return None;
568    }
569    if offset == sql.len() {
570        let mut line = 1usize;
571        let mut column = 1usize;
572        for ch in sql.chars() {
573            if ch == '\n' {
574                line += 1;
575                column = 1;
576            } else {
577                column += 1;
578            }
579        }
580        return Some((line, column));
581    }
582
583    let mut line = 1usize;
584    let mut column = 1usize;
585    for (index, ch) in sql.char_indices() {
586        if index == offset {
587            return Some((line, column));
588        }
589        if ch == '\n' {
590            line += 1;
591            column = 1;
592        } else {
593            column += 1;
594        }
595    }
596
597    None
598}
599
600fn relative_location(
601    location: Location,
602    range_start_line: usize,
603    range_start_column: usize,
604) -> Option<Location> {
605    let line = location.line as usize;
606    let column = location.column as usize;
607    if line < range_start_line {
608        return None;
609    }
610
611    if line == range_start_line {
612        if column < range_start_column {
613            return None;
614        }
615        return Some(Location::new(1, (column - range_start_column + 1) as u64));
616    }
617
618    Some(Location::new(
619        (line - range_start_line + 1) as u64,
620        column as u64,
621    ))
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::linter::config::LintConfig;
628    use crate::linter::rule::with_active_dialect;
629    use crate::parser::parse_sql;
630    use crate::types::IssueAutofixApplicability;
631
632    fn run_with_rule(sql: &str, rule: &LayoutNewlines) -> Vec<Issue> {
633        let statements = parse_sql(sql).expect("parse");
634        let mut ranges = Vec::with_capacity(statements.len());
635        let mut search_start = 0usize;
636        for index in 0..statements.len() {
637            if index > 0 {
638                search_start = first_non_whitespace_offset(sql, search_start);
639            }
640            let end = if index + 1 < statements.len() {
641                sql[search_start..]
642                    .find(';')
643                    .map(|offset| search_start + offset + 1)
644                    .unwrap_or(sql.len())
645            } else {
646                sql.len()
647            };
648            ranges.push(search_start..end);
649            search_start = end;
650        }
651
652        statements
653            .iter()
654            .enumerate()
655            .flat_map(|(index, statement)| {
656                rule.check(
657                    statement,
658                    &LintContext {
659                        sql,
660                        statement_range: ranges[index].clone(),
661                        statement_index: index,
662                    },
663                )
664            })
665            .collect()
666    }
667
668    fn first_non_whitespace_offset(sql: &str, from: usize) -> usize {
669        let mut offset = from;
670        for ch in sql[from..].chars() {
671            if ch.is_ascii_whitespace() {
672                offset += ch.len_utf8();
673            } else {
674                break;
675            }
676        }
677        offset
678    }
679
680    fn run(sql: &str) -> Vec<Issue> {
681        run_with_rule(sql, &LayoutNewlines::default())
682    }
683
684    fn run_statementless_with_rule_in_dialect(
685        sql: &str,
686        rule: &LayoutNewlines,
687        dialect: Dialect,
688    ) -> Vec<Issue> {
689        let placeholder = parse_sql("SELECT 1").expect("parse placeholder");
690        with_active_dialect(dialect, || {
691            rule.check(
692                &placeholder[0],
693                &LintContext {
694                    sql,
695                    statement_range: 0..sql.len(),
696                    statement_index: 0,
697                },
698            )
699        })
700    }
701
702    fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
703        let autofix = issue.autofix.as_ref()?;
704        let mut out = sql.to_string();
705        let mut edits = autofix.edits.clone();
706        edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
707        for edit in edits.into_iter().rev() {
708            out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
709        }
710        Some(out)
711    }
712
713    #[test]
714    fn flags_excessive_blank_lines() {
715        let issues = run("SELECT 1\n\n\nFROM t");
716        assert_eq!(issues.len(), 1);
717        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
718        let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
719        assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
720        let fixed = apply_issue_autofix("SELECT 1\n\n\nFROM t", &issues[0]).expect("apply fix");
721        assert_eq!(fixed, "SELECT 1\n\nFROM t");
722    }
723
724    #[test]
725    fn does_not_flag_single_blank_line() {
726        assert!(run("SELECT 1\n\nFROM t").is_empty());
727    }
728
729    #[test]
730    fn flags_blank_lines_with_whitespace() {
731        let issues = run("SELECT 1\n\n   \nFROM t");
732        assert_eq!(issues.len(), 1);
733        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
734    }
735
736    #[test]
737    fn configured_inside_limit_allows_two_blank_lines() {
738        let config = LintConfig {
739            enabled: true,
740            disabled_rules: vec![],
741            rule_configs: std::collections::BTreeMap::from([(
742                "layout.newlines".to_string(),
743                serde_json::json!({"maximum_empty_lines_inside_statements": 2}),
744            )]),
745        };
746        let issues = run_with_rule(
747            "SELECT 1\n\n\nFROM t",
748            &LayoutNewlines::from_config(&config),
749        );
750        assert!(issues.is_empty());
751    }
752
753    #[test]
754    fn configured_between_limit_flags_statement_gap() {
755        let config = LintConfig {
756            enabled: true,
757            disabled_rules: vec![],
758            rule_configs: std::collections::BTreeMap::from([(
759                "LINT_LT_015".to_string(),
760                serde_json::json!({"maximum_empty_lines_between_statements": 1}),
761            )]),
762        };
763        let issues = run_with_rule(
764            "SELECT 1;\n\n\nSELECT 2",
765            &LayoutNewlines::from_config(&config),
766        );
767        assert_eq!(issues.len(), 1);
768        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
769        let fixed = apply_issue_autofix("SELECT 1;\n\n\nSELECT 2", &issues[0]).expect("apply fix");
770        assert_eq!(fixed, "SELECT 1;\n\nSELECT 2");
771    }
772
773    #[test]
774    fn flags_blank_lines_after_inline_comment() {
775        let issues = run("SELECT 1 -- inline\n\n\nFROM t");
776        assert_eq!(issues.len(), 1);
777        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
778    }
779
780    #[test]
781    fn flags_blank_lines_between_statements_with_comment_gap() {
782        let sql = "SELECT 1;\n-- there was a comment\n\n\nSELECT 2";
783        let issues = run(sql);
784        assert_eq!(issues.len(), 1);
785        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
786        let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply fix");
787        assert!(
788            fixed.contains("-- there was a comment"),
789            "comment should remain after LT015 autofix: {fixed}"
790        );
791        assert_eq!(fixed, "SELECT 1;\n-- there was a comment\n\nSELECT 2");
792    }
793
794    #[test]
795    fn flags_excessive_blank_lines_with_crlf_line_breaks() {
796        let issues = run("SELECT 1\r\n\r\n\r\nFROM t");
797        assert_eq!(issues.len(), 1);
798        assert_eq!(issues[0].code, issue_codes::LINT_LT_015);
799    }
800
801    #[test]
802    fn trim_ascii_whitespace_bounds_handles_all_whitespace_input() {
803        let (start, end) = trim_ascii_whitespace_bounds(" \t\r\n ");
804        assert_eq!((start, end), (5, 5));
805    }
806
807    #[test]
808    fn mssql_go_batch_separator_breaks_blank_line_runs() {
809        let sql = "SELECT 1;\n\nGO\n\nSELECT 2;\n";
810        let max = max_consecutive_blank_lines(sql, Dialect::Mssql, None);
811        assert_eq!(
812            max, 1,
813            "GO should be treated as a non-blank batch separator line",
814        );
815    }
816
817    #[test]
818    fn mssql_go_batch_separator_with_two_blank_lines_still_flags() {
819        let sql = "SELECT 1;\n\nGO\n\n\nSELECT 2;\n";
820        let max = max_consecutive_blank_lines(sql, Dialect::Mssql, None);
821        assert_eq!(max, 2);
822    }
823
824    #[test]
825    fn mssql_between_statement_gap_counts_empty_lines_not_line_breaks() {
826        assert_eq!(blank_lines_in_inter_statement_gap("\n\n", None), 1);
827        assert_eq!(blank_lines_in_inter_statement_gap("\n\n\n", None), 2);
828    }
829
830    #[test]
831    fn mssql_passes_single_empty_line_between_batches() {
832        let config = LintConfig {
833            enabled: true,
834            disabled_rules: vec![],
835            rule_configs: std::collections::BTreeMap::from([(
836                "layout.newlines".to_string(),
837                serde_json::json!({"maximum_empty_lines_between_batches": 1}),
838            )]),
839        };
840        let sql = "SELECT 1;\n\nGO\n\nSELECT 2;\n";
841        let issues = run_statementless_with_rule_in_dialect(
842            sql,
843            &LayoutNewlines::from_config(&config),
844            Dialect::Mssql,
845        );
846        assert!(
847            issues.is_empty(),
848            "mssql GO batch with one empty line should pass"
849        );
850    }
851
852    #[test]
853    fn mssql_passes_inside_batch_statement_limit_before_go() {
854        let config = LintConfig {
855            enabled: true,
856            disabled_rules: vec![],
857            rule_configs: std::collections::BTreeMap::from([(
858                "layout.newlines".to_string(),
859                serde_json::json!({
860                    "maximum_empty_lines_inside_statements": 1,
861                    "maximum_empty_lines_between_statements": 1
862                }),
863            )]),
864        };
865        let sql = "SELECT 1;\n\nSELECT 2;\n\nGO\n";
866        let issues = run_statementless_with_rule_in_dialect(
867            sql,
868            &LayoutNewlines::from_config(&config),
869            Dialect::Mssql,
870        );
871        assert!(
872            issues.is_empty(),
873            "inside-batch statement spacing should be evaluated independently of GO separator"
874        );
875    }
876}