flowscope_core/linter/rules/
lt_012.rs1use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::Statement;
9
10pub struct LayoutEndOfFile;
11
12impl LintRule for LayoutEndOfFile {
13 fn code(&self) -> &'static str {
14 issue_codes::LINT_LT_012
15 }
16
17 fn name(&self) -> &'static str {
18 "Layout end of file"
19 }
20
21 fn description(&self) -> &'static str {
22 "Files must end with a single trailing newline."
23 }
24
25 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
26 let content_end = ctx
27 .sql
28 .trim_end_matches(|ch: char| ch.is_ascii_whitespace())
29 .len();
30 let is_last_statement = ctx.statement_range.end >= content_end;
31 let (trailing_newlines, has_trailing_spaces) = trailing_newline_metrics(ctx.sql);
32 let has_violation = is_last_statement && (trailing_newlines != 1 || has_trailing_spaces);
33
34 if has_violation {
35 let trailing_span = Span::new(content_end, ctx.sql.len());
36 vec![Issue::info(
37 issue_codes::LINT_LT_012,
38 "SQL document should end with a single trailing newline.",
39 )
40 .with_statement(ctx.statement_index)
41 .with_span(trailing_span)
42 .with_autofix_edits(
43 IssueAutofixApplicability::Safe,
44 vec![IssuePatchEdit::new(trailing_span, "\n")],
45 )]
46 } else {
47 Vec::new()
48 }
49 }
50}
51
52fn trailing_newline_metrics(sql: &str) -> (usize, bool) {
53 let mut end = sql.len();
54 while end > 0 {
55 let ch = sql[..end]
56 .chars()
57 .next_back()
58 .expect("string slice should not be empty");
59 if ch == ' ' || ch == '\t' {
60 end -= ch.len_utf8();
61 continue;
62 }
63 break;
64 }
65
66 let has_trailing_spaces = end != sql.len();
67 (trailing_newline_count(&sql[..end]), has_trailing_spaces)
68}
69
70fn trailing_newline_count(sql: &str) -> usize {
71 sql.chars()
72 .rev()
73 .take_while(|ch| *ch == '\n' || *ch == '\r')
74 .filter(|ch| *ch == '\n')
75 .count()
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::parser::parse_sql;
82 use crate::types::IssueAutofixApplicability;
83
84 fn run(sql: &str) -> Vec<Issue> {
85 let statements = parse_sql(sql).expect("parse");
86 let rule = LayoutEndOfFile;
87 statements
88 .iter()
89 .enumerate()
90 .flat_map(|(index, statement)| {
91 rule.check(
92 statement,
93 &LintContext {
94 sql,
95 statement_range: 0..sql.len(),
96 statement_index: index,
97 },
98 )
99 })
100 .collect()
101 }
102
103 fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
104 let autofix = issue.autofix.as_ref()?;
105 let mut edits = autofix.edits.clone();
106 edits.sort_by(|left, right| right.span.start.cmp(&left.span.start));
107
108 let mut out = sql.to_string();
109 for edit in edits {
110 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
111 }
112 Some(out)
113 }
114
115 #[test]
116 fn flags_missing_trailing_newline() {
117 let sql = "SELECT 1\nFROM t";
118 let issues = run(sql);
119 assert_eq!(issues.len(), 1);
120 assert_eq!(issues[0].code, issue_codes::LINT_LT_012);
121 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
122 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
123 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
124 assert_eq!(fixed, "SELECT 1\nFROM t\n");
125 }
126
127 #[test]
128 fn does_not_flag_when_trailing_newline_present() {
129 assert!(run("SELECT 1\nFROM t\n").is_empty());
130 }
131
132 #[test]
133 fn flags_single_line_without_newline() {
134 let sql = "SELECT 1";
135 let issues = run(sql);
136 assert_eq!(issues.len(), 1);
137 assert_eq!(issues[0].code, issue_codes::LINT_LT_012);
138 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
139 assert_eq!(fixed, "SELECT 1\n");
140 }
141
142 #[test]
143 fn flags_multiple_trailing_newlines() {
144 let sql = "SELECT 1\nFROM t\n\n";
145 let issues = run(sql);
146 assert_eq!(issues.len(), 1);
147 assert_eq!(issues[0].code, issue_codes::LINT_LT_012);
148 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
149 assert_eq!(fixed, "SELECT 1\nFROM t\n");
150 }
151
152 #[test]
153 fn flags_trailing_spaces_after_newline() {
154 let sql = "SELECT 1\nFROM t\n ";
155 let issues = run(sql);
156 assert_eq!(issues.len(), 1);
157 assert_eq!(issues[0].code, issue_codes::LINT_LT_012);
158 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
159 assert_eq!(fixed, "SELECT 1\nFROM t\n");
160 }
161
162 #[test]
163 fn statementless_flags_templated_without_raw_final_newline() {
164 let sql = "{{ '\\n\\n' }}";
165 let synthetic = parse_sql("SELECT 1").expect("parse");
166 let rule = LayoutEndOfFile;
167 let issues = rule.check(
168 &synthetic[0],
169 &LintContext {
170 sql,
171 statement_range: 0..sql.len(),
172 statement_index: 0,
173 },
174 );
175 assert_eq!(issues.len(), 1);
176 assert_eq!(issues[0].code, issue_codes::LINT_LT_012);
177 }
178
179 #[test]
180 fn statementless_allows_templated_line_with_raw_final_newline() {
181 let sql = "select * from {{ 'trim_whitespace_table' -}}\n";
182 let synthetic = parse_sql("SELECT 1").expect("parse");
183 let rule = LayoutEndOfFile;
184 let issues = rule.check(
185 &synthetic[0],
186 &LintContext {
187 sql,
188 statement_range: 0..sql.len(),
189 statement_index: 0,
190 },
191 );
192 assert!(issues.is_empty());
193 }
194}