1use 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}