Skip to main content

flowscope_core/linter/rules/
lt_012.rs

1//! LINT_LT_012: Layout end of file.
2//!
3//! SQLFluff LT12 parity (current scope): SQL text should end with exactly one
4//! trailing newline.
5
6use 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}