flowscope_core/linter/rules/
lt_013.rs1use crate::linter::rule::{LintContext, LintRule};
6use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
7use sqlparser::ast::Statement;
8
9pub struct LayoutStartOfFile;
10
11impl LintRule for LayoutStartOfFile {
12 fn code(&self) -> &'static str {
13 issue_codes::LINT_LT_013
14 }
15
16 fn name(&self) -> &'static str {
17 "Layout start of file"
18 }
19
20 fn description(&self) -> &'static str {
21 "Files must not begin with newlines or whitespace."
22 }
23
24 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
25 if ctx.statement_index > 0 || !has_leading_blank_lines_for_context(ctx) {
26 Vec::new()
27 } else {
28 let Some(trim_end) = leading_blank_line_trim_end(ctx.sql) else {
29 return Vec::new();
30 };
31 let span = Span::new(0, trim_end);
32 vec![Issue::info(
33 issue_codes::LINT_LT_013,
34 "Avoid leading blank lines at the start of SQL file.",
35 )
36 .with_statement(ctx.statement_index)
37 .with_span(span)
38 .with_autofix_edits(
39 IssueAutofixApplicability::Safe,
40 vec![IssuePatchEdit::new(span, "")],
41 )]
42 }
43 }
44}
45
46fn leading_blank_line_trim_end(sql: &str) -> Option<usize> {
47 let first_non_ws = sql
48 .char_indices()
49 .find(|(_, ch)| !ch.is_whitespace())
50 .map(|(idx, _)| idx)
51 .unwrap_or(sql.len());
52 (first_non_ws > 0).then_some(first_non_ws)
53}
54
55fn has_leading_blank_lines_for_context(ctx: &LintContext) -> bool {
56 leading_blank_line_trim_end(ctx.sql).is_some()
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::parser::parse_sql;
63 use crate::types::IssueAutofixApplicability;
64
65 fn run(sql: &str) -> Vec<Issue> {
66 let statements = parse_sql(sql).expect("parse");
67 let rule = LayoutStartOfFile;
68 statements
69 .iter()
70 .enumerate()
71 .flat_map(|(index, statement)| {
72 rule.check(
73 statement,
74 &LintContext {
75 sql,
76 statement_range: 0..sql.len(),
77 statement_index: index,
78 },
79 )
80 })
81 .collect()
82 }
83
84 fn run_statementless(sql: &str) -> Vec<Issue> {
85 let synthetic = parse_sql("SELECT 1").expect("parse");
86 let rule = LayoutStartOfFile;
87 synthetic
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 out = sql.to_string();
106 let mut edits = autofix.edits.clone();
107 edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
108 for edit in edits.iter().rev() {
109 out.replace_range(edit.span.start..edit.span.end, &edit.replacement);
110 }
111 Some(out)
112 }
113
114 #[test]
115 fn flags_leading_blank_lines() {
116 let issues = run("\n\nSELECT 1");
117 assert_eq!(issues.len(), 1);
118 assert_eq!(issues[0].code, issue_codes::LINT_LT_013);
119 let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
120 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
121 let fixed = apply_issue_autofix("\n\nSELECT 1", &issues[0]).expect("apply autofix");
122 assert_eq!(fixed, "SELECT 1");
123 }
124
125 #[test]
126 fn does_not_flag_clean_start() {
127 assert!(run("SELECT 1").is_empty());
128 }
129
130 #[test]
131 fn does_not_flag_leading_comment() {
132 assert!(run("-- comment\nSELECT 1").is_empty());
133 }
134
135 #[test]
136 fn flags_blank_line_before_comment() {
137 let sql = " \n-- comment\nSELECT 1";
138 let issues = run(sql);
139 assert_eq!(issues.len(), 1);
140 assert_eq!(issues[0].code, issue_codes::LINT_LT_013);
141 let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
142 assert!(
143 fixed.starts_with("-- comment"),
144 "comment should remain after LT013 autofix: {fixed}"
145 );
146 }
147
148 #[test]
149 fn does_not_flag_jinja_comment_at_start_of_file() {
150 let sql = "{# I am a comment #}\nSELECT foo FROM bar\n";
151 assert!(run_statementless(sql).is_empty());
152 }
153
154 #[test]
155 fn does_not_flag_jinja_if_at_start_of_file() {
156 let sql = "{% if True %}\nSELECT foo\nFROM bar;\n{% endif %}\n";
157 assert!(run_statementless(sql).is_empty());
158 }
159
160 #[test]
161 fn does_not_flag_jinja_for_at_start_of_file() {
162 let sql = "{% for item in range(10) %}\nSELECT foo_{{ item }}\nFROM bar;\n{% endfor %}\n";
163 assert!(run_statementless(sql).is_empty());
164 }
165}