Skip to main content

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}