sqrust_rules/layout/blank_line_between_statements.rs
1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct BlankLineBetweenStatements;
4
5impl Rule for BlankLineBetweenStatements {
6 fn name(&self) -> &'static str {
7 "Layout/BlankLineBetweenStatements"
8 }
9
10 fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11 let mut diags = Vec::new();
12 let source = &ctx.source;
13 let bytes = source.as_bytes();
14 let len = bytes.len();
15
16 // Find positions of all semicolons that are not inside string literals.
17 let mut semi_positions: Vec<usize> = Vec::new();
18 let mut in_string = false;
19 let mut i = 0;
20
21 while i < len {
22 if !in_string && bytes[i] == b'\'' {
23 in_string = true;
24 i += 1;
25 continue;
26 }
27 if in_string {
28 if bytes[i] == b'\'' {
29 if i + 1 < len && bytes[i + 1] == b'\'' {
30 i += 2;
31 continue;
32 }
33 in_string = false;
34 }
35 i += 1;
36 continue;
37 }
38 if bytes[i] == b';' {
39 semi_positions.push(i);
40 }
41 i += 1;
42 }
43
44 // For each semicolon (except the last), check if the next non-whitespace
45 // content after it is preceded by a blank line.
46 for &semi_pos in &semi_positions {
47 // Find the end of the current line (newline after semicolon)
48 let mut j = semi_pos + 1;
49 // Skip rest of current line
50 while j < len && bytes[j] != b'\n' {
51 j += 1;
52 }
53 if j >= len {
54 // Semicolon at end of file — no next statement
55 continue;
56 }
57 // j points to '\n' — move past it
58 j += 1;
59 if j >= len {
60 continue;
61 }
62
63 // Count newlines in what follows — need at least one blank line
64 // A blank line means two consecutive newlines
65 let start_of_next_region = j;
66 let mut blank_line_found = false;
67
68 // Check if there's a blank line before the next content.
69 // After advancing past the `;` line's own newline, the region starts
70 // at what follows. A blank line means the very first line of the region
71 // is empty (contains only spaces/tabs before its newline).
72 let region = &source[start_of_next_region..];
73 for c in region.chars() {
74 match c {
75 '\n' => {
76 // First line of region ended without non-whitespace → blank line!
77 blank_line_found = true;
78 break;
79 }
80 ' ' | '\t' | '\r' => {
81 // whitespace on an otherwise blank line — keep scanning
82 }
83 _ => {
84 // non-whitespace before newline → this line is not blank
85 break;
86 }
87 }
88 }
89
90 if !blank_line_found {
91 // Find the start of the next statement (after the semicolon line)
92 let next_stmt_offset = start_of_next_region
93 + region
94 .find(|c: char| !c.is_whitespace())
95 .unwrap_or(0);
96 // Only flag if there actually is a next statement
97 if region.chars().any(|c| !c.is_whitespace()) {
98 let (line, col) = offset_to_line_col(source, next_stmt_offset);
99 diags.push(Diagnostic {
100 rule: "Layout/BlankLineBetweenStatements",
101 message: "Statements must be separated by a blank line".to_string(),
102 line,
103 col,
104 });
105 }
106 }
107 }
108
109 diags
110 }
111}
112
113fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
114 let before = &source[..offset];
115 let line = before.chars().filter(|&c| c == '\n').count() + 1;
116 let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
117 (line, col)
118}