Skip to main content

sqrust_rules/layout/
trailing_blank_lines.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct TrailingBlankLines;
4
5impl Rule for TrailingBlankLines {
6    fn name(&self) -> &'static str {
7        "Layout/TrailingBlankLines"
8    }
9
10    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
11        let source = &ctx.source;
12
13        // Split into lines preserving empty trailing lines.
14        // str::lines() strips the final newline, so we need a different approach.
15        let raw_lines: Vec<&str> = source.split('\n').collect();
16
17        // If the source ends with '\n', split('\n') gives us an empty string at
18        // the end — that represents the end-of-file after the last newline, not
19        // a blank line. We handle this by trimming that sentinel off before
20        // counting trailing blanks.
21        //
22        // Example:
23        //   "SELECT 1\n"   → ["SELECT 1", ""]      → sentinel "", 0 trailing blanks
24        //   "SELECT 1\n\n" → ["SELECT 1", "", ""]  → sentinel "", 1 trailing blank ("")
25        //   "SELECT 1"     → ["SELECT 1"]           → no sentinel, 0 trailing blanks
26
27        // Total number of segments from split.
28        let n = raw_lines.len();
29
30        // The last segment is always the sentinel after a trailing '\n', OR the
31        // actual last content line if the source doesn't end with '\n'.
32        // We need to find the last segment with non-whitespace content, then
33        // check if there are blank segments after it (excluding the sentinel).
34
35        // Find the index of the last non-blank line.
36        let last_content_idx = raw_lines
37            .iter()
38            .rposition(|line| !line.trim().is_empty());
39
40        let last_content = match last_content_idx {
41            None => {
42                // Every segment is blank/empty — treat as empty file, no violation.
43                return Vec::new();
44            }
45            Some(idx) => idx,
46        };
47
48        // Segments after last_content_idx that are blank (not the sentinel).
49        // The sentinel is the last segment when the source ends with '\n'.
50        // We want to find blank lines BETWEEN last_content and the end of file.
51        //
52        // Segments from (last_content + 1) up to but NOT including the sentinel
53        // are trailing blank lines. The sentinel itself is just the final newline.
54        //
55        // If source doesn't end with '\n', n-1 is an actual content or blank line,
56        // not a sentinel.
57        let ends_with_newline = source.ends_with('\n');
58
59        // How many blank-line segments exist after last_content?
60        // If ends_with_newline, the last segment (index n-1) is the sentinel and
61        // doesn't count as a blank line by itself.
62        // Trailing blank lines = segments between (last_content+1) and the sentinel.
63        let trailing_blank_count = if ends_with_newline {
64            // segments at indices (last_content+1) .. (n-2) inclusive
65            if last_content + 1 < n - 1 {
66                n - 1 - (last_content + 1)
67            } else {
68                0
69            }
70        } else {
71            // No trailing newline — segments at indices (last_content+1) .. (n-1)
72            // that are blank.
73            (last_content + 1..n)
74                .filter(|&i| raw_lines[i].trim().is_empty())
75                .count()
76        };
77
78        if trailing_blank_count == 0 {
79            return Vec::new();
80        }
81
82        // The first trailing blank line is at index (last_content + 1).
83        // Line number = index + 1 (1-indexed).
84        let first_blank_line = last_content + 2; // (last_content + 1) + 1 for 1-indexing
85
86        vec![Diagnostic {
87            rule: self.name(),
88            message: "File has trailing blank line(s)".to_string(),
89            line: first_blank_line,
90            col: 1,
91        }]
92    }
93
94    fn fix(&self, ctx: &FileContext) -> Option<String> {
95        let diags = self.check(ctx);
96        if diags.is_empty() {
97            return None;
98        }
99
100        let source = &ctx.source;
101
102        // Remove all trailing blank lines, keep a single trailing newline if
103        // the original file had one.
104        //
105        // Strategy: find the end of the last content line and trim everything after it,
106        // then append a newline.
107        let trimmed = source.trim_end_matches(|c: char| c == '\n' || c == '\r' || c == ' ' || c == '\t');
108        // Append exactly one newline to preserve the convention of a final newline.
109        let mut result = trimmed.to_string();
110        result.push('\n');
111        Some(result)
112    }
113}