Skip to main content

flowscope_core/linter/rules/
lt_013.rs

1//! LINT_LT_013: Layout start of file.
2//!
3//! SQLFluff LT13 parity (current scope): avoid leading blank lines.
4
5use 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}