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}